@fonderie/auth 5.0.4 → 5.2.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.
package/dist/index.cjs CHANGED
@@ -70,6 +70,7 @@ var index_exports = {};
70
70
  __export(index_exports, {
71
71
  AUTH_CONFIG_KEYS: () => AUTH_CONFIG_KEYS,
72
72
  AuthModule: () => AuthModule,
73
+ DEFAULT_TEMPLATES: () => DEFAULT_TEMPLATES,
73
74
  MESSAGE_KEYS: () => MESSAGE_KEYS,
74
75
  buildAuthAccountLimiter: () => buildAuthAccountLimiter,
75
76
  buildAuthIpLimiter: () => buildAuthIpLimiter,
@@ -730,17 +731,23 @@ var SessionModel = class {
730
731
  this.store = store;
731
732
  }
732
733
  store;
733
- async create(userId, token, expiresAt, sid) {
734
+ async create(userId, token, expiresAt, sid, meta) {
734
735
  await this.store.query(
735
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
736
- VALUES ($1, $2, $3, $4)
736
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
737
+ VALUES ($1, $2, $3, $4, $5, $6)
737
738
  ON CONFLICT (token) DO NOTHING`,
738
- [userId, token, expiresAt, sid ?? null]
739
+ [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
739
740
  );
740
741
  }
741
742
  async delete(token) {
742
743
  await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
743
744
  }
745
+ // Delete the session an access token is bound to (by its sid claim). Lets
746
+ // logout revoke the current session without the client resending the refresh
747
+ // token — the access token already identifies its session.
748
+ async deleteBySid(sid) {
749
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
750
+ }
744
751
  // Revoke every session for a user (e.g. on password change). Access tokens
745
752
  // bound to these sessions via the sid claim die on their next request.
746
753
  async deleteByUser(userId) {
@@ -760,6 +767,42 @@ var SessionModel = class {
760
767
  [userId]
761
768
  );
762
769
  }
770
+ // Live sessions for the Active Sessions screen: unexpired only, carrying the
771
+ // sid so the caller can flag the current one (row.sid === the JWT's sid).
772
+ // No token — that never leaves the server.
773
+ async listLiveByUser(userId) {
774
+ return this.store.query(
775
+ `SELECT id, sid,
776
+ user_agent AS "userAgent",
777
+ ip_address AS "ipAddress",
778
+ created_at AS "createdAt",
779
+ expires_at AS "expiresAt"
780
+ FROM fonderie_sessions
781
+ WHERE user_id = $1 AND expires_at > now()
782
+ ORDER BY created_at DESC`,
783
+ [userId]
784
+ );
785
+ }
786
+ // Terminate one session by row id, scoped to its owner (a user can never
787
+ // delete another user's session). Returns whether a row was removed.
788
+ async terminateById(userId, id) {
789
+ const rows = await this.store.query(
790
+ `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
791
+ [id, userId]
792
+ );
793
+ return rows.length > 0;
794
+ }
795
+ // Terminate all of a user's sessions except the current one (by sid).
796
+ // Returns the number of sessions removed.
797
+ async terminateOthers(userId, keepSid) {
798
+ const rows = await this.store.query(
799
+ `DELETE FROM fonderie_sessions
800
+ WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
801
+ RETURNING id`,
802
+ [userId, keepSid]
803
+ );
804
+ return rows.length;
805
+ }
763
806
  async exists(token) {
764
807
  const rows = await this.store.query(
765
808
  `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
@@ -812,11 +855,92 @@ var BackupCodeModel = class {
812
855
  }
813
856
  };
814
857
 
858
+ // src/models/login-event.model.ts
859
+ var LoginEventModel = class {
860
+ constructor(store) {
861
+ this.store = store;
862
+ }
863
+ store;
864
+ async record(e) {
865
+ await this.store.query(
866
+ `INSERT INTO fonderie_login_events
867
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
868
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
869
+ [
870
+ e.userId,
871
+ e.emailAttempted ?? null,
872
+ e.method,
873
+ e.outcome,
874
+ e.failureReason ?? null,
875
+ e.ipAddress,
876
+ e.userAgent
877
+ ]
878
+ );
879
+ }
880
+ // Fire-and-forget variant for the login paths: recording history must never
881
+ // block or fail a login (same posture as the bus.emit(...).catch(() => {})
882
+ // notification sends).
883
+ recordSafe(e) {
884
+ void this.record(e).catch(() => {
885
+ });
886
+ }
887
+ // One page of a user's own login history, newest first. Keyset-paginated on
888
+ // (created_at, id) — mirrors audit's event list so the two log UIs share a
889
+ // cursor contract. The +1 over-fetch that detects a next page lives here so
890
+ // no outer clamp can shave it off.
891
+ async listByUser(query) {
892
+ const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
893
+ const params = [query.userId];
894
+ const where = ["user_id = $1"];
895
+ if (query.outcome) {
896
+ params.push(query.outcome);
897
+ where.push(`outcome = $${params.length}`);
898
+ }
899
+ if (query.from) {
900
+ params.push(query.from);
901
+ where.push(`created_at >= $${params.length}`);
902
+ }
903
+ if (query.to) {
904
+ params.push(query.to);
905
+ where.push(`created_at <= $${params.length}`);
906
+ }
907
+ if (query.cursor) {
908
+ params.push(query.cursor.createdAt, query.cursor.id);
909
+ where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
910
+ }
911
+ params.push(limit + 1);
912
+ const rows = await this.store.query(
913
+ `SELECT id, method, outcome, failure_reason AS "failureReason",
914
+ ip_address AS "ipAddress", user_agent AS "userAgent",
915
+ created_at AS "createdAt", created_at::text AS "createdAtRaw"
916
+ FROM fonderie_login_events
917
+ WHERE ${where.join(" AND ")}
918
+ ORDER BY created_at DESC, id DESC
919
+ LIMIT $${params.length}`,
920
+ params
921
+ );
922
+ return { events: rows.slice(0, limit), hasMore: rows.length > limit };
923
+ }
924
+ };
925
+ var MAX_HISTORY_LIMIT = 200;
926
+
927
+ // src/services/request-meta.ts
928
+ var UA_MAX = 512;
929
+ function requestMeta(ctx) {
930
+ const ip = ctx.meta["clientIp"];
931
+ const ua = ctx.request.headers.get("user-agent");
932
+ return {
933
+ ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
934
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
935
+ };
936
+ }
937
+
815
938
  // src/controllers/mfa.controller.ts
816
939
  function mfaController(store, config, issuer, bus) {
817
940
  const users = new UserModel(store);
818
941
  const sessions = new SessionModel(store);
819
942
  const backupCodes = new BackupCodeModel(store);
943
+ const loginEvents = new LoginEventModel(store);
820
944
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
821
945
  return {
822
946
  // ── 1. Setup ───────────────────────────────────────────────
@@ -846,6 +970,16 @@ function mfaController(store, config, issuer, bus) {
846
970
  verify: async (ctx) => {
847
971
  const body = ctx.meta["body"];
848
972
  const token = body?.["token"];
973
+ const isLoginCompletion = ctx.user.mfaPending === true;
974
+ const meta = requestMeta(ctx);
975
+ const failedMfa = () => loginEvents.recordSafe({
976
+ userId: ctx.user.id,
977
+ emailAttempted: ctx.user.email,
978
+ method: "mfa",
979
+ outcome: "failed",
980
+ failureReason: "invalid_code",
981
+ ...meta
982
+ });
849
983
  if (typeof token !== "string") {
850
984
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
851
985
  }
@@ -886,6 +1020,7 @@ function mfaController(store, config, issuer, bus) {
886
1020
  );
887
1021
  const matched = checks.find((r) => r.match);
888
1022
  if (!matched) {
1023
+ if (isLoginCompletion) failedMfa();
889
1024
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
890
1025
  }
891
1026
  await backupCodes.consume(matched.id);
@@ -903,6 +1038,7 @@ function mfaController(store, config, issuer, bus) {
903
1038
  }
904
1039
  const secret = mfaCipher.decrypt(storedSecret);
905
1040
  if (!verifyTotpToken(token, secret)) {
1041
+ if (isLoginCompletion) failedMfa();
906
1042
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
907
1043
  }
908
1044
  if (!ctx.user.mfaEnabled) {
@@ -913,7 +1049,16 @@ function mfaController(store, config, issuer, bus) {
913
1049
  loginMethod: ctx.user.loginMethod,
914
1050
  phoneVerified: ctx.user.phoneVerified
915
1051
  });
916
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1052
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1053
+ if (isLoginCompletion) {
1054
+ loginEvents.recordSafe({
1055
+ userId: ctx.user.id,
1056
+ emailAttempted: ctx.user.email,
1057
+ method: "mfa",
1058
+ outcome: "success",
1059
+ ...meta
1060
+ });
1061
+ }
917
1062
  const fullUser = await users.findById(ctx.user.id);
918
1063
  if (!fullUser) {
919
1064
  return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
@@ -1182,6 +1327,7 @@ function extractRefreshToken(ctx) {
1182
1327
  function authController(store, config, bus) {
1183
1328
  const users = new UserModel(store);
1184
1329
  const sessions = new SessionModel(store);
1330
+ const loginEvents = new LoginEventModel(store);
1185
1331
  const passwordReset = new PasswordResetModel(store);
1186
1332
  const emailVerif = new EmailVerificationModel(store);
1187
1333
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1251,7 +1397,7 @@ function authController(store, config, bus) {
1251
1397
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1252
1398
  loginMethod: "email"
1253
1399
  });
1254
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1400
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1255
1401
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1256
1402
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1257
1403
  return Response.json(
@@ -1315,7 +1461,7 @@ function authController(store, config, bus) {
1315
1461
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1316
1462
  loginMethod: "phone"
1317
1463
  });
1318
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1464
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1319
1465
  return Response.json(
1320
1466
  {
1321
1467
  reason: "USER_PHONE_REGISTERED",
@@ -1339,6 +1485,7 @@ function authController(store, config, bus) {
1339
1485
  },
1340
1486
  login: async (ctx) => {
1341
1487
  const body = ctx.meta["body"];
1488
+ const meta = requestMeta(ctx);
1342
1489
  if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
1343
1490
  const { email: rawEmail, password: password2 } = body;
1344
1491
  const email2 = normalizeEmailSafe(rawEmail);
@@ -1347,6 +1494,14 @@ function authController(store, config, bus) {
1347
1494
  }
1348
1495
  const user = await users.findByEmail(email2);
1349
1496
  if (!user || !user.passwordHash) {
1497
+ loginEvents.recordSafe({
1498
+ userId: null,
1499
+ emailAttempted: email2,
1500
+ method: "password",
1501
+ outcome: "failed",
1502
+ failureReason: "unknown_email",
1503
+ ...meta
1504
+ });
1350
1505
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1351
1506
  }
1352
1507
  const { valid, needsRehash } = await verifyPasswordForLogin(
@@ -1355,6 +1510,14 @@ function authController(store, config, bus) {
1355
1510
  config.legacyVerify
1356
1511
  );
1357
1512
  if (!valid) {
1513
+ loginEvents.recordSafe({
1514
+ userId: user.id,
1515
+ emailAttempted: email2,
1516
+ method: "password",
1517
+ outcome: "failed",
1518
+ failureReason: "bad_password",
1519
+ ...meta
1520
+ });
1358
1521
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1359
1522
  }
1360
1523
  if (needsRehash) {
@@ -1376,7 +1539,14 @@ function authController(store, config, bus) {
1376
1539
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1377
1540
  loginMethod: "email"
1378
1541
  });
1379
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1542
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1543
+ loginEvents.recordSafe({
1544
+ userId: user.id,
1545
+ emailAttempted: email2,
1546
+ method: "password",
1547
+ outcome: "success",
1548
+ ...meta
1549
+ });
1380
1550
  const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
1381
1551
  const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
1382
1552
  return Response.json(
@@ -1421,7 +1591,7 @@ function authController(store, config, bus) {
1421
1591
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1422
1592
  loginMethod: "phone"
1423
1593
  });
1424
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1594
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1425
1595
  return Response.json(
1426
1596
  {
1427
1597
  reason: "USER_PHONE_OTP_SENT",
@@ -1444,6 +1614,10 @@ function authController(store, config, bus) {
1444
1614
  );
1445
1615
  },
1446
1616
  logout: async (ctx) => {
1617
+ const sid = ctx.user?.sid;
1618
+ if (sid) {
1619
+ await sessions.deleteBySid(sid).catch(() => void 0);
1620
+ }
1447
1621
  const token = extractRefreshToken(ctx);
1448
1622
  if (token) {
1449
1623
  await sessions.delete(token).catch(() => void 0);
@@ -1482,7 +1656,7 @@ function authController(store, config, bus) {
1482
1656
  loginMethod: payload.loginMethod ?? "email",
1483
1657
  phoneVerified: payload.phoneVerified ?? false
1484
1658
  });
1485
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1659
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1486
1660
  return Response.json(
1487
1661
  {
1488
1662
  reason: "TOKENS_REFRESHED",
@@ -1616,7 +1790,7 @@ function authController(store, config, bus) {
1616
1790
  loginMethod: "phone",
1617
1791
  phoneVerified: true
1618
1792
  });
1619
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1793
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1620
1794
  const verifiedUser = await users.findById(ctx.user.id);
1621
1795
  if (!verifiedUser) {
1622
1796
  return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
@@ -1749,8 +1923,40 @@ function authController(store, config, bus) {
1749
1923
 
1750
1924
  // src/controllers/user.controller.ts
1751
1925
  var import_node_crypto5 = require("crypto");
1752
- var import_core6 = require("@fonderie/core");
1926
+ var import_core7 = require("@fonderie/core");
1753
1927
  var import_events3 = require("@fonderie/events");
1928
+
1929
+ // src/dtos/login-activity.ts
1930
+ var import_core6 = require("@fonderie/core");
1931
+ function toLoginEventDTO(row) {
1932
+ return {
1933
+ id: row.id,
1934
+ method: row.method,
1935
+ outcome: row.outcome,
1936
+ failureReason: row.failureReason,
1937
+ ipAddress: row.ipAddress,
1938
+ userAgent: row.userAgent,
1939
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
1940
+ };
1941
+ }
1942
+ function toLoginHistoryPageDTO(page) {
1943
+ const last = page.events[page.events.length - 1];
1944
+ const nextCursor = page.hasMore && last ? (0, import_core6.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
1945
+ return { events: page.events.map(toLoginEventDTO), nextCursor };
1946
+ }
1947
+ var decodeLoginCursor = import_core6.decodeKeysetCursor;
1948
+ function toSessionDTO(row, currentSid) {
1949
+ return {
1950
+ id: row.id,
1951
+ current: currentSid !== null && row.sid === currentSid,
1952
+ ipAddress: row.ipAddress,
1953
+ userAgent: row.userAgent,
1954
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
1955
+ expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
1956
+ };
1957
+ }
1958
+
1959
+ // src/controllers/user.controller.ts
1754
1960
  function normalizePhone2(phone2) {
1755
1961
  return phone2.trim().replace(/[\s()\-\.]/g, "");
1756
1962
  }
@@ -1760,18 +1966,82 @@ function isValidPhone2(phone2) {
1760
1966
  function userController(store, config, bus) {
1761
1967
  const users = new UserModel(store);
1762
1968
  const sessions = new SessionModel(store);
1969
+ const loginEvents = new LoginEventModel(store);
1763
1970
  const emailVerif = new EmailVerificationModel(store);
1764
1971
  const phoneVerif = new PhoneVerificationModel(store);
1765
1972
  return {
1766
1973
  me: async (ctx) => {
1767
1974
  const user = await users.findById(ctx.user.id);
1768
1975
  if (!user) {
1769
- return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1976
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1770
1977
  }
1771
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
1978
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
1772
1979
  user: toUserDTO(user, ctx.user.phoneVerified)
1773
1980
  });
1774
1981
  },
1982
+ // ── Login history (append-only; the caller's own attempts) ────────
1983
+ loginHistory: async (ctx) => {
1984
+ const params = new URL(ctx.request.url).searchParams;
1985
+ const rawLimit = Number(params.get("limit") ?? 50);
1986
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
1987
+ const outcomeParam = params.get("outcome");
1988
+ if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
1989
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
1990
+ }
1991
+ const cursorParam = params.get("cursor");
1992
+ let cursor = null;
1993
+ if (cursorParam) {
1994
+ cursor = decodeLoginCursor(cursorParam);
1995
+ if (!cursor) {
1996
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
1997
+ }
1998
+ }
1999
+ const query = { userId: ctx.user.id, limit };
2000
+ if (outcomeParam) query.outcome = outcomeParam;
2001
+ if (cursor) query.cursor = cursor;
2002
+ const from = params.get("from");
2003
+ const to = params.get("to");
2004
+ if (from) query.from = new Date(from);
2005
+ if (to) query.to = new Date(to);
2006
+ const page = await loginEvents.listByUser(query);
2007
+ return (0, import_core7.setApiResponse)(
2008
+ import_core7.HTTP.OK,
2009
+ "LOGIN_HISTORY_FETCHED",
2010
+ "Login history retrieved.",
2011
+ toLoginHistoryPageDTO(page)
2012
+ );
2013
+ },
2014
+ // ── Active sessions (live only; the caller's own) ─────────────────
2015
+ listSessions: async (ctx) => {
2016
+ const rows = await sessions.listLiveByUser(ctx.user.id);
2017
+ const currentSid = ctx.user.sid ?? null;
2018
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2019
+ sessions: rows.map((r) => toSessionDTO(r, currentSid))
2020
+ });
2021
+ },
2022
+ terminateSession: async (ctx) => {
2023
+ const id = ctx.meta["params"]?.["id"];
2024
+ if (!id) {
2025
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2026
+ }
2027
+ const removed = await sessions.terminateById(ctx.user.id, id);
2028
+ if (!removed) {
2029
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2030
+ }
2031
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2032
+ },
2033
+ terminateOtherSessions: async (ctx) => {
2034
+ const currentSid = ctx.user.sid ?? null;
2035
+ if (!currentSid) {
2036
+ return (0, import_core7.setApiResponse)(
2037
+ import_core7.HTTP.UNPROCESSABLE,
2038
+ "NO_CURRENT_SESSION",
2039
+ "Cannot terminate others without a current session context"
2040
+ );
2041
+ }
2042
+ const count = await sessions.terminateOthers(ctx.user.id, currentSid);
2043
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2044
+ },
1775
2045
  updateProfile: async (ctx) => {
1776
2046
  const body = ctx.meta["body"];
1777
2047
  const allowed = ["firstName", "lastName", "avatarUrl"];
@@ -1780,18 +2050,18 @@ function userController(store, config, bus) {
1780
2050
  if (body?.[key] !== void 0) fields[key] = body[key];
1781
2051
  }
1782
2052
  if (Object.keys(fields).length === 0) {
1783
- return (0, import_core6.setApiResponse)(
1784
- import_core6.HTTP.UNPROCESSABLE,
2053
+ return (0, import_core7.setApiResponse)(
2054
+ import_core7.HTTP.UNPROCESSABLE,
1785
2055
  "INVALID_PARAMETER",
1786
2056
  "Provide at least one of: firstName, lastName, avatarUrl"
1787
2057
  );
1788
2058
  }
1789
2059
  const row = await users.update(ctx.user.id, fields);
1790
2060
  if (!row) {
1791
- return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2061
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1792
2062
  }
1793
2063
  const updated = await users.findById(ctx.user.id);
1794
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
2064
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
1795
2065
  user: toUserDTO(updated, ctx.user.phoneVerified)
1796
2066
  });
1797
2067
  },
@@ -1808,14 +2078,14 @@ function userController(store, config, bus) {
1808
2078
  if (Object.keys(patch).length > 0) fields.patch = patch;
1809
2079
  const row = await users.updatePreferences(ctx.user.id, fields);
1810
2080
  if (!row) {
1811
- return (0, import_core6.setApiResponse)(
1812
- import_core6.HTTP.UNPROCESSABLE,
2081
+ return (0, import_core7.setApiResponse)(
2082
+ import_core7.HTTP.UNPROCESSABLE,
1813
2083
  "INVALID_PARAMETER",
1814
2084
  "Provide at least one preference field"
1815
2085
  );
1816
2086
  }
1817
2087
  const updated = await users.findById(ctx.user.id);
1818
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2088
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
1819
2089
  user: toUserDTO(updated, ctx.user.phoneVerified)
1820
2090
  });
1821
2091
  },
@@ -1824,23 +2094,23 @@ function userController(store, config, bus) {
1824
2094
  const newEmail = body?.["email"];
1825
2095
  const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
1826
2096
  if (!normalised) {
1827
- return (0, import_core6.setApiResponse)(
1828
- import_core6.HTTP.UNPROCESSABLE,
2097
+ return (0, import_core7.setApiResponse)(
2098
+ import_core7.HTTP.UNPROCESSABLE,
1829
2099
  "INVALID_PARAMETER",
1830
2100
  "A valid email address is required"
1831
2101
  );
1832
2102
  }
1833
2103
  const oldEmail = ctx.user.email;
1834
2104
  if (normalised === oldEmail) {
1835
- return (0, import_core6.setApiResponse)(
1836
- import_core6.HTTP.UNPROCESSABLE,
2105
+ return (0, import_core7.setApiResponse)(
2106
+ import_core7.HTTP.UNPROCESSABLE,
1837
2107
  "INVALID_PARAMETER",
1838
2108
  "New email must differ from current email"
1839
2109
  );
1840
2110
  }
1841
2111
  const existing = await users.findByEmail(normalised);
1842
2112
  if (existing) {
1843
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2113
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
1844
2114
  }
1845
2115
  const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1846
2116
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
@@ -1862,8 +2132,8 @@ function userController(store, config, bus) {
1862
2132
  }).catch(() => {
1863
2133
  });
1864
2134
  }
1865
- return (0, import_core6.setApiResponse)(
1866
- import_core6.HTTP.OK,
2135
+ return (0, import_core7.setApiResponse)(
2136
+ import_core7.HTTP.OK,
1867
2137
  "EMAIL_UPDATED",
1868
2138
  "Email updated. A verification code has been sent to your new address.",
1869
2139
  {
@@ -1875,8 +2145,8 @@ function userController(store, config, bus) {
1875
2145
  const body = ctx.meta["body"];
1876
2146
  const newPhone = body?.["phone"];
1877
2147
  if (!isValidPhone2(newPhone)) {
1878
- return (0, import_core6.setApiResponse)(
1879
- import_core6.HTTP.UNPROCESSABLE,
2148
+ return (0, import_core7.setApiResponse)(
2149
+ import_core7.HTTP.UNPROCESSABLE,
1880
2150
  "INVALID_PARAMETER",
1881
2151
  "A valid phone number is required"
1882
2152
  );
@@ -1884,7 +2154,7 @@ function userController(store, config, bus) {
1884
2154
  const normalised = normalizePhone2(newPhone);
1885
2155
  const existing = await users.findByPhone(normalised);
1886
2156
  if (existing) {
1887
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2157
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
1888
2158
  }
1889
2159
  const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1890
2160
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
@@ -1906,8 +2176,8 @@ function userController(store, config, bus) {
1906
2176
  }).catch(() => {
1907
2177
  });
1908
2178
  }
1909
- return (0, import_core6.setApiResponse)(
1910
- import_core6.HTTP.OK,
2179
+ return (0, import_core7.setApiResponse)(
2180
+ import_core7.HTTP.OK,
1911
2181
  "PHONE_UPDATED",
1912
2182
  "Phone number updated. A verification code has been sent to your new number.",
1913
2183
  {
@@ -1920,22 +2190,22 @@ function userController(store, config, bus) {
1920
2190
  const currentPassword = body?.["currentPassword"];
1921
2191
  const newPassword = body?.["newPassword"];
1922
2192
  if (typeof currentPassword !== "string" || !currentPassword) {
1923
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2193
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
1924
2194
  }
1925
2195
  if (typeof newPassword !== "string" || newPassword.length < 8) {
1926
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2196
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
1927
2197
  }
1928
2198
  const user = await users.findById(ctx.user.id);
1929
- if (!user) return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2199
+ if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1930
2200
  const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
1931
2201
  const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
1932
2202
  if (!valid) {
1933
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2203
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
1934
2204
  }
1935
2205
  const hash = await hashPassword2(newPassword);
1936
2206
  await users.updatePassword(ctx.user.id, hash);
1937
2207
  await sessions.deleteByUser(ctx.user.id);
1938
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2208
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
1939
2209
  },
1940
2210
  deleteMe: async (ctx) => {
1941
2211
  const userId = ctx.user.id;
@@ -1960,7 +2230,7 @@ function userController(store, config, bus) {
1960
2230
  // no MFA secret, no session tokens (session metadata only).
1961
2231
  exportMe: async (ctx) => {
1962
2232
  const user = await users.findById(ctx.user.id);
1963
- if (!user) return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2233
+ if (!user) return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
1964
2234
  const sessionRows = await sessions.listByUser(ctx.user.id);
1965
2235
  const bundle = {
1966
2236
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1969,8 +2239,8 @@ function userController(store, config, bus) {
1969
2239
  id: s.id,
1970
2240
  userAgent: s.userAgent,
1971
2241
  ipAddress: s.ipAddress,
1972
- createdAt: (0, import_core6.dateOrEmpty)(s.createdAt),
1973
- expiresAt: (0, import_core6.dateOrEmpty)(s.expiresAt)
2242
+ createdAt: (0, import_core7.dateOrEmpty)(s.createdAt),
2243
+ expiresAt: (0, import_core7.dateOrEmpty)(s.expiresAt)
1974
2244
  })),
1975
2245
  security: {
1976
2246
  mfaEnabled: user.mfaEnabled === true,
@@ -1989,22 +2259,23 @@ function userController(store, config, bus) {
1989
2259
  }
1990
2260
  bundle["modules"] = modules;
1991
2261
  }
1992
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
2262
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
1993
2263
  }
1994
2264
  };
1995
2265
  }
1996
2266
 
1997
2267
  // src/controllers/oauth.controller.ts
1998
- var import_core7 = require("@fonderie/core");
2268
+ var import_core8 = require("@fonderie/core");
1999
2269
  function oauthController(store, config) {
2000
2270
  const users = new UserModel(store);
2001
2271
  const sessions = new SessionModel(store);
2272
+ const loginEvents = new LoginEventModel(store);
2002
2273
  return {
2003
2274
  googleInit: async (_ctx) => {
2004
2275
  const google = config.google;
2005
2276
  if (!google) {
2006
- return (0, import_core7.setApiResponse)(
2007
- import_core7.HTTP.NOT_IMPLEMENTED,
2277
+ return (0, import_core8.setApiResponse)(
2278
+ import_core8.HTTP.NOT_IMPLEMENTED,
2008
2279
  "NOT_CONFIGURED",
2009
2280
  "Google OAuth not configured"
2010
2281
  );
@@ -2016,8 +2287,8 @@ function oauthController(store, config) {
2016
2287
  scope: "openid email profile"
2017
2288
  });
2018
2289
  const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
2019
- return (0, import_core7.setApiResponse)(
2020
- import_core7.HTTP.OK,
2290
+ return (0, import_core8.setApiResponse)(
2291
+ import_core8.HTTP.OK,
2021
2292
  "GOOGLE_AUTH_URL",
2022
2293
  "Redirect the user to the returned URL to begin Google OAuth.",
2023
2294
  { url }
@@ -2026,8 +2297,8 @@ function oauthController(store, config) {
2026
2297
  googleCallback: async (ctx) => {
2027
2298
  const google = config.google;
2028
2299
  if (!google) {
2029
- return (0, import_core7.setApiResponse)(
2030
- import_core7.HTTP.NOT_IMPLEMENTED,
2300
+ return (0, import_core8.setApiResponse)(
2301
+ import_core8.HTTP.NOT_IMPLEMENTED,
2031
2302
  "NOT_CONFIGURED",
2032
2303
  "Google OAuth not configured"
2033
2304
  );
@@ -2035,7 +2306,7 @@ function oauthController(store, config) {
2035
2306
  const url = new URL(ctx.request.url);
2036
2307
  const code = url.searchParams.get("code");
2037
2308
  if (!code) {
2038
- return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2309
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2039
2310
  }
2040
2311
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
2041
2312
  method: "POST",
@@ -2050,8 +2321,8 @@ function oauthController(store, config) {
2050
2321
  });
2051
2322
  const tokenData = await tokenRes.json();
2052
2323
  if (!tokenData.id_token) {
2053
- return (0, import_core7.setApiResponse)(
2054
- import_core7.HTTP.BAD_REQUEST,
2324
+ return (0, import_core8.setApiResponse)(
2325
+ import_core8.HTTP.BAD_REQUEST,
2055
2326
  "GOOGLE_AUTH_FAILED",
2056
2327
  "OAuth token exchange failed"
2057
2328
  );
@@ -2060,24 +2331,40 @@ function oauthController(store, config) {
2060
2331
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
2061
2332
  );
2062
2333
  if (!payload.email) {
2063
- return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
2334
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
2064
2335
  }
2065
2336
  const normalizedEmail = normalizeEmailSafe(payload.email);
2066
2337
  if (!normalizedEmail) {
2067
- return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
2338
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
2068
2339
  }
2340
+ const meta = requestMeta(ctx);
2069
2341
  const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
2070
2342
  if (!upserted) {
2071
- return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2343
+ loginEvents.recordSafe({
2344
+ userId: null,
2345
+ emailAttempted: normalizedEmail,
2346
+ method: "oauth-google",
2347
+ outcome: "failed",
2348
+ failureReason: "provider_upsert_failed",
2349
+ ...meta
2350
+ });
2351
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2072
2352
  }
2073
2353
  const fullUser = await users.findById(upserted.id);
2074
2354
  if (!fullUser) {
2075
- return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2355
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2076
2356
  }
2077
2357
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
2078
2358
  loginMethod: "google"
2079
2359
  });
2080
- await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
2360
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2361
+ loginEvents.recordSafe({
2362
+ userId: upserted.id,
2363
+ emailAttempted: normalizedEmail,
2364
+ method: "oauth-google",
2365
+ outcome: "success",
2366
+ ...meta
2367
+ });
2081
2368
  return Response.json(
2082
2369
  {
2083
2370
  reason: "GOOGLE_AUTH_SUCCESS",
@@ -2135,6 +2422,13 @@ function buildAuthRoutes(store, config, bus) {
2135
2422
  R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
2136
2423
  R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
2137
2424
  R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
2425
+ // Security surfaces (Protected; the caller's own login history + sessions).
2426
+ // The literal /others route is registered before /:id so it isn't
2427
+ // captured as an id.
2428
+ R("loginHistory", "GET", "/auth/login-history", import_middlewares2.requireAuth, user.loginHistory),
2429
+ R("listSessions", "GET", "/auth/sessions", import_middlewares2.requireAuth, user.listSessions),
2430
+ R("terminateOtherSessions", "DELETE", "/auth/sessions/others", import_middlewares2.requireAuth, user.terminateOtherSessions),
2431
+ R("terminateSession", "DELETE", "/auth/sessions/:id", import_middlewares2.requireAuth, user.terminateSession),
2138
2432
  // MFA (email sessions only — requireVerified is always enforced here
2139
2433
  // because MFA is a security feature and email verification is meaningful)
2140
2434
  R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
@@ -2154,17 +2448,17 @@ function buildAuthRoutes(store, config, bus) {
2154
2448
  }
2155
2449
 
2156
2450
  // src/services/config-guard.ts
2157
- var import_core8 = require("@fonderie/core");
2451
+ var import_core9 = require("@fonderie/core");
2158
2452
  var MODULE = "@fonderie/auth";
2159
2453
  function collectAuthConfigProblems(config) {
2160
2454
  const problems = [];
2161
2455
  const secret = config.jwtSecret ?? "";
2162
- const secretProblem = (0, import_core8.secretStrengthProblem)(secret);
2456
+ const secretProblem = (0, import_core9.secretStrengthProblem)(secret);
2163
2457
  if (secretProblem === "too-short") {
2164
2458
  problems.push({
2165
2459
  module: MODULE,
2166
2460
  severity: "error",
2167
- message: `jwtSecret must be at least ${import_core8.MIN_SECRET_LENGTH} characters (got ${secret.length})`
2461
+ message: `jwtSecret must be at least ${import_core9.MIN_SECRET_LENGTH} characters (got ${secret.length})`
2168
2462
  });
2169
2463
  } else if (secretProblem === "placeholder") {
2170
2464
  problems.push({
@@ -2188,7 +2482,7 @@ function collectAuthConfigProblems(config) {
2188
2482
  severity: "error",
2189
2483
  message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
2190
2484
  });
2191
- } else if (import_core8.PLACEHOLDER_SECRET.test(clientSecret)) {
2485
+ } else if (import_core9.PLACEHOLDER_SECRET.test(clientSecret)) {
2192
2486
  problems.push({
2193
2487
  module: MODULE,
2194
2488
  severity: "error",
@@ -2255,7 +2549,10 @@ function withSession(store, config) {
2255
2549
  ...user,
2256
2550
  loginMethod: payload.loginMethod ?? "email",
2257
2551
  phoneVerified: payload.phoneVerified ?? false,
2258
- mfaPending: payload.mfaPending ?? false
2552
+ mfaPending: payload.mfaPending ?? false,
2553
+ // The session this request is authenticated on, so the sessions
2554
+ // API can flag the current row and "terminate others" can spare it.
2555
+ sid: payload.sid ?? null
2259
2556
  }
2260
2557
  });
2261
2558
  return next();
@@ -2296,6 +2593,122 @@ var AuthModule = class {
2296
2593
  }
2297
2594
  };
2298
2595
 
2596
+ // src/templates.ts
2597
+ var DEFAULT_TEMPLATES = {
2598
+ [MESSAGE_KEYS.emailRegistration]: {
2599
+ subject: "Confirm your account",
2600
+ html: `<h1>Welcome aboard</h1>
2601
+ <p>Hi {{firstName}},</p>
2602
+ <p>Thanks for signing up. Confirm your account with this code:</p>
2603
+ <p><span class="pin-code">{{pin}}</span></p>
2604
+ <p class="muted">This code expires in 24 hours. If you didn&rsquo;t create an account, you can safely ignore this email.</p>`,
2605
+ text: `Welcome aboard
2606
+
2607
+ Hi {{firstName}},
2608
+
2609
+ Thanks for signing up. Confirm your account with this code: {{pin}}
2610
+
2611
+ This code expires in 24 hours. If you didn't create an account, you can safely ignore this email.`
2612
+ },
2613
+ [MESSAGE_KEYS.emailVerification]: {
2614
+ subject: "Your verification code",
2615
+ html: `<h1>Verify your email</h1>
2616
+ <p>Hi {{firstName}},</p>
2617
+ <p>Use this code to finish setting up your account:</p>
2618
+ <p><span class="pin-code">{{pin}}</span></p>
2619
+ <p class="muted">This code expires in 24 hours. If you didn&rsquo;t create an account, you can safely ignore this email.</p>`,
2620
+ text: `Verify your email
2621
+
2622
+ Hi {{firstName}},
2623
+
2624
+ Use this code to finish setting up your account: {{pin}}
2625
+
2626
+ This code expires in 24 hours. If you didn't create an account, you can safely ignore this email.`
2627
+ },
2628
+ [MESSAGE_KEYS.passwordReset]: {
2629
+ subject: "Reset your password",
2630
+ html: `<h1>Reset your password</h1>
2631
+ <p>We received a request to reset your password. Use this code to continue:</p>
2632
+ <p><span class="pin-code">{{pin}}</span></p>
2633
+ <p class="muted">This code expires soon. If you didn&rsquo;t request a reset, no action is needed &mdash; your password stays the same.</p>`,
2634
+ text: `Reset your password
2635
+
2636
+ We received a request to reset your password. Use this code to continue: {{pin}}
2637
+
2638
+ This code expires soon. If you didn't request a reset, no action is needed \u2014 your password stays the same.`
2639
+ },
2640
+ // SMS — text only, no subject/html.
2641
+ [MESSAGE_KEYS.phoneOtp]: {
2642
+ text: "{{otp}} is your verification code. It expires in 10 minutes."
2643
+ },
2644
+ [MESSAGE_KEYS.mfaEnabled]: {
2645
+ subject: "Two-factor authentication is on",
2646
+ html: `<h1>Two-factor authentication enabled</h1>
2647
+ <p>Two-factor authentication was just turned on for your account. From now on you&rsquo;ll enter a code from your authenticator app when you sign in.</p>
2648
+ <p class="muted">If you didn&rsquo;t do this, contact support right away &mdash; someone may have access to your account.</p>`,
2649
+ text: `Two-factor authentication enabled
2650
+
2651
+ Two-factor authentication was just turned on for your account. From now on you'll enter a code from your authenticator app when you sign in.
2652
+
2653
+ If you didn't do this, contact support right away \u2014 someone may have access to your account.`
2654
+ },
2655
+ [MESSAGE_KEYS.mfaDisabled]: {
2656
+ subject: "Two-factor authentication is off",
2657
+ html: `<h1>Two-factor authentication disabled</h1>
2658
+ <p>Two-factor authentication was just turned off for your account. Your account is now protected by your password alone.</p>
2659
+ <p class="muted">If you didn&rsquo;t do this, contact support right away and re-enable two-factor authentication &mdash; someone may have access to your account.</p>`,
2660
+ text: `Two-factor authentication disabled
2661
+
2662
+ Two-factor authentication was just turned off for your account. Your account is now protected by your password alone.
2663
+
2664
+ If you didn't do this, contact support right away and re-enable two-factor authentication \u2014 someone may have access to your account.`
2665
+ },
2666
+ [MESSAGE_KEYS.mfaBackupCodesRegenerated]: {
2667
+ subject: "Your backup codes were regenerated",
2668
+ html: `<h1>New backup codes generated</h1>
2669
+ <p>A new set of two-factor backup codes was just generated for your account. Your previous backup codes no longer work.</p>
2670
+ <p class="muted">If you didn&rsquo;t do this, contact support right away &mdash; someone may have access to your account.</p>`,
2671
+ text: `New backup codes generated
2672
+
2673
+ A new set of two-factor backup codes was just generated for your account. Your previous backup codes no longer work.
2674
+
2675
+ If you didn't do this, contact support right away \u2014 someone may have access to your account.`
2676
+ },
2677
+ [MESSAGE_KEYS.emailChanged]: {
2678
+ subject: "Your email address was changed",
2679
+ html: `<h1>Your email was changed</h1>
2680
+ <p>The email address on your account was just changed to <strong>{{newEmail}}</strong>.</p>
2681
+ <p class="muted">If you made this change, you&rsquo;re all set. If not, contact support right away &mdash; someone may have access to your account.</p>`,
2682
+ text: `Your email was changed
2683
+
2684
+ The email address on your account was just changed to {{newEmail}}.
2685
+
2686
+ If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
2687
+ },
2688
+ [MESSAGE_KEYS.phoneChanged]: {
2689
+ subject: "Your phone number was changed",
2690
+ html: `<h1>Your phone number was changed</h1>
2691
+ <p>The phone number on your account was just updated.</p>
2692
+ <p class="muted">If you made this change, you&rsquo;re all set. If not, contact support right away &mdash; someone may have access to your account.</p>`,
2693
+ text: `Your phone number was changed
2694
+
2695
+ The phone number on your account was just updated.
2696
+
2697
+ If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
2698
+ }
2699
+ };
2700
+ var SAMPLE_PAYLOADS = {
2701
+ [MESSAGE_KEYS.emailRegistration]: { firstName: "", pin: "123456" },
2702
+ [MESSAGE_KEYS.emailVerification]: { firstName: "Ada", pin: "123456" },
2703
+ [MESSAGE_KEYS.passwordReset]: { pin: "123456" },
2704
+ [MESSAGE_KEYS.phoneOtp]: { otp: "123456" },
2705
+ [MESSAGE_KEYS.mfaEnabled]: {},
2706
+ [MESSAGE_KEYS.mfaDisabled]: {},
2707
+ [MESSAGE_KEYS.mfaBackupCodesRegenerated]: {},
2708
+ [MESSAGE_KEYS.emailChanged]: { newEmail: "new@example.com" },
2709
+ [MESSAGE_KEYS.phoneChanged]: {}
2710
+ };
2711
+
2299
2712
  // src/middlewares/require-auth.ts
2300
2713
  var import_middlewares3 = require("@fonderie/core/middlewares");
2301
2714
 
@@ -2369,6 +2782,7 @@ function startUserRetention(store, options) {
2369
2782
  0 && (module.exports = {
2370
2783
  AUTH_CONFIG_KEYS,
2371
2784
  AuthModule,
2785
+ DEFAULT_TEMPLATES,
2372
2786
  MESSAGE_KEYS,
2373
2787
  buildAuthAccountLimiter,
2374
2788
  buildAuthIpLimiter,