@fonderie/auth 5.1.0 → 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
@@ -731,17 +731,23 @@ var SessionModel = class {
731
731
  this.store = store;
732
732
  }
733
733
  store;
734
- async create(userId, token, expiresAt, sid) {
734
+ async create(userId, token, expiresAt, sid, meta) {
735
735
  await this.store.query(
736
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
737
- 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)
738
738
  ON CONFLICT (token) DO NOTHING`,
739
- [userId, token, expiresAt, sid ?? null]
739
+ [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
740
740
  );
741
741
  }
742
742
  async delete(token) {
743
743
  await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
744
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
+ }
745
751
  // Revoke every session for a user (e.g. on password change). Access tokens
746
752
  // bound to these sessions via the sid claim die on their next request.
747
753
  async deleteByUser(userId) {
@@ -761,6 +767,42 @@ var SessionModel = class {
761
767
  [userId]
762
768
  );
763
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
+ }
764
806
  async exists(token) {
765
807
  const rows = await this.store.query(
766
808
  `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
@@ -813,11 +855,92 @@ var BackupCodeModel = class {
813
855
  }
814
856
  };
815
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
+
816
938
  // src/controllers/mfa.controller.ts
817
939
  function mfaController(store, config, issuer, bus) {
818
940
  const users = new UserModel(store);
819
941
  const sessions = new SessionModel(store);
820
942
  const backupCodes = new BackupCodeModel(store);
943
+ const loginEvents = new LoginEventModel(store);
821
944
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
822
945
  return {
823
946
  // ── 1. Setup ───────────────────────────────────────────────
@@ -847,6 +970,16 @@ function mfaController(store, config, issuer, bus) {
847
970
  verify: async (ctx) => {
848
971
  const body = ctx.meta["body"];
849
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
+ });
850
983
  if (typeof token !== "string") {
851
984
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
852
985
  }
@@ -887,6 +1020,7 @@ function mfaController(store, config, issuer, bus) {
887
1020
  );
888
1021
  const matched = checks.find((r) => r.match);
889
1022
  if (!matched) {
1023
+ if (isLoginCompletion) failedMfa();
890
1024
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
891
1025
  }
892
1026
  await backupCodes.consume(matched.id);
@@ -904,6 +1038,7 @@ function mfaController(store, config, issuer, bus) {
904
1038
  }
905
1039
  const secret = mfaCipher.decrypt(storedSecret);
906
1040
  if (!verifyTotpToken(token, secret)) {
1041
+ if (isLoginCompletion) failedMfa();
907
1042
  return (0, import_core4.setApiResponse)(import_core4.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
908
1043
  }
909
1044
  if (!ctx.user.mfaEnabled) {
@@ -914,7 +1049,16 @@ function mfaController(store, config, issuer, bus) {
914
1049
  loginMethod: ctx.user.loginMethod,
915
1050
  phoneVerified: ctx.user.phoneVerified
916
1051
  });
917
- 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
+ }
918
1062
  const fullUser = await users.findById(ctx.user.id);
919
1063
  if (!fullUser) {
920
1064
  return (0, import_core4.setApiResponse)(import_core4.HTTP.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
@@ -1183,6 +1327,7 @@ function extractRefreshToken(ctx) {
1183
1327
  function authController(store, config, bus) {
1184
1328
  const users = new UserModel(store);
1185
1329
  const sessions = new SessionModel(store);
1330
+ const loginEvents = new LoginEventModel(store);
1186
1331
  const passwordReset = new PasswordResetModel(store);
1187
1332
  const emailVerif = new EmailVerificationModel(store);
1188
1333
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1252,7 +1397,7 @@ function authController(store, config, bus) {
1252
1397
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1253
1398
  loginMethod: "email"
1254
1399
  });
1255
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1400
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1256
1401
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1257
1402
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1258
1403
  return Response.json(
@@ -1316,7 +1461,7 @@ function authController(store, config, bus) {
1316
1461
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1317
1462
  loginMethod: "phone"
1318
1463
  });
1319
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1464
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1320
1465
  return Response.json(
1321
1466
  {
1322
1467
  reason: "USER_PHONE_REGISTERED",
@@ -1340,6 +1485,7 @@ function authController(store, config, bus) {
1340
1485
  },
1341
1486
  login: async (ctx) => {
1342
1487
  const body = ctx.meta["body"];
1488
+ const meta = requestMeta(ctx);
1343
1489
  if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
1344
1490
  const { email: rawEmail, password: password2 } = body;
1345
1491
  const email2 = normalizeEmailSafe(rawEmail);
@@ -1348,6 +1494,14 @@ function authController(store, config, bus) {
1348
1494
  }
1349
1495
  const user = await users.findByEmail(email2);
1350
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
+ });
1351
1505
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1352
1506
  }
1353
1507
  const { valid, needsRehash } = await verifyPasswordForLogin(
@@ -1356,6 +1510,14 @@ function authController(store, config, bus) {
1356
1510
  config.legacyVerify
1357
1511
  );
1358
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
+ });
1359
1521
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1360
1522
  }
1361
1523
  if (needsRehash) {
@@ -1377,7 +1539,14 @@ function authController(store, config, bus) {
1377
1539
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1378
1540
  loginMethod: "email"
1379
1541
  });
1380
- 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
+ });
1381
1550
  const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
1382
1551
  const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
1383
1552
  return Response.json(
@@ -1422,7 +1591,7 @@ function authController(store, config, bus) {
1422
1591
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1423
1592
  loginMethod: "phone"
1424
1593
  });
1425
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1594
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1426
1595
  return Response.json(
1427
1596
  {
1428
1597
  reason: "USER_PHONE_OTP_SENT",
@@ -1445,6 +1614,10 @@ function authController(store, config, bus) {
1445
1614
  );
1446
1615
  },
1447
1616
  logout: async (ctx) => {
1617
+ const sid = ctx.user?.sid;
1618
+ if (sid) {
1619
+ await sessions.deleteBySid(sid).catch(() => void 0);
1620
+ }
1448
1621
  const token = extractRefreshToken(ctx);
1449
1622
  if (token) {
1450
1623
  await sessions.delete(token).catch(() => void 0);
@@ -1483,7 +1656,7 @@ function authController(store, config, bus) {
1483
1656
  loginMethod: payload.loginMethod ?? "email",
1484
1657
  phoneVerified: payload.phoneVerified ?? false
1485
1658
  });
1486
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1659
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1487
1660
  return Response.json(
1488
1661
  {
1489
1662
  reason: "TOKENS_REFRESHED",
@@ -1617,7 +1790,7 @@ function authController(store, config, bus) {
1617
1790
  loginMethod: "phone",
1618
1791
  phoneVerified: true
1619
1792
  });
1620
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1793
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1621
1794
  const verifiedUser = await users.findById(ctx.user.id);
1622
1795
  if (!verifiedUser) {
1623
1796
  return (0, import_core5.setApiResponse)(import_core5.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
@@ -1750,8 +1923,40 @@ function authController(store, config, bus) {
1750
1923
 
1751
1924
  // src/controllers/user.controller.ts
1752
1925
  var import_node_crypto5 = require("crypto");
1753
- var import_core6 = require("@fonderie/core");
1926
+ var import_core7 = require("@fonderie/core");
1754
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
1755
1960
  function normalizePhone2(phone2) {
1756
1961
  return phone2.trim().replace(/[\s()\-\.]/g, "");
1757
1962
  }
@@ -1761,18 +1966,82 @@ function isValidPhone2(phone2) {
1761
1966
  function userController(store, config, bus) {
1762
1967
  const users = new UserModel(store);
1763
1968
  const sessions = new SessionModel(store);
1969
+ const loginEvents = new LoginEventModel(store);
1764
1970
  const emailVerif = new EmailVerificationModel(store);
1765
1971
  const phoneVerif = new PhoneVerificationModel(store);
1766
1972
  return {
1767
1973
  me: async (ctx) => {
1768
1974
  const user = await users.findById(ctx.user.id);
1769
1975
  if (!user) {
1770
- 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");
1771
1977
  }
1772
- 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.", {
1773
1979
  user: toUserDTO(user, ctx.user.phoneVerified)
1774
1980
  });
1775
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
+ },
1776
2045
  updateProfile: async (ctx) => {
1777
2046
  const body = ctx.meta["body"];
1778
2047
  const allowed = ["firstName", "lastName", "avatarUrl"];
@@ -1781,18 +2050,18 @@ function userController(store, config, bus) {
1781
2050
  if (body?.[key] !== void 0) fields[key] = body[key];
1782
2051
  }
1783
2052
  if (Object.keys(fields).length === 0) {
1784
- return (0, import_core6.setApiResponse)(
1785
- import_core6.HTTP.UNPROCESSABLE,
2053
+ return (0, import_core7.setApiResponse)(
2054
+ import_core7.HTTP.UNPROCESSABLE,
1786
2055
  "INVALID_PARAMETER",
1787
2056
  "Provide at least one of: firstName, lastName, avatarUrl"
1788
2057
  );
1789
2058
  }
1790
2059
  const row = await users.update(ctx.user.id, fields);
1791
2060
  if (!row) {
1792
- 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");
1793
2062
  }
1794
2063
  const updated = await users.findById(ctx.user.id);
1795
- 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.", {
1796
2065
  user: toUserDTO(updated, ctx.user.phoneVerified)
1797
2066
  });
1798
2067
  },
@@ -1809,14 +2078,14 @@ function userController(store, config, bus) {
1809
2078
  if (Object.keys(patch).length > 0) fields.patch = patch;
1810
2079
  const row = await users.updatePreferences(ctx.user.id, fields);
1811
2080
  if (!row) {
1812
- return (0, import_core6.setApiResponse)(
1813
- import_core6.HTTP.UNPROCESSABLE,
2081
+ return (0, import_core7.setApiResponse)(
2082
+ import_core7.HTTP.UNPROCESSABLE,
1814
2083
  "INVALID_PARAMETER",
1815
2084
  "Provide at least one preference field"
1816
2085
  );
1817
2086
  }
1818
2087
  const updated = await users.findById(ctx.user.id);
1819
- 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.", {
1820
2089
  user: toUserDTO(updated, ctx.user.phoneVerified)
1821
2090
  });
1822
2091
  },
@@ -1825,23 +2094,23 @@ function userController(store, config, bus) {
1825
2094
  const newEmail = body?.["email"];
1826
2095
  const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
1827
2096
  if (!normalised) {
1828
- return (0, import_core6.setApiResponse)(
1829
- import_core6.HTTP.UNPROCESSABLE,
2097
+ return (0, import_core7.setApiResponse)(
2098
+ import_core7.HTTP.UNPROCESSABLE,
1830
2099
  "INVALID_PARAMETER",
1831
2100
  "A valid email address is required"
1832
2101
  );
1833
2102
  }
1834
2103
  const oldEmail = ctx.user.email;
1835
2104
  if (normalised === oldEmail) {
1836
- return (0, import_core6.setApiResponse)(
1837
- import_core6.HTTP.UNPROCESSABLE,
2105
+ return (0, import_core7.setApiResponse)(
2106
+ import_core7.HTTP.UNPROCESSABLE,
1838
2107
  "INVALID_PARAMETER",
1839
2108
  "New email must differ from current email"
1840
2109
  );
1841
2110
  }
1842
2111
  const existing = await users.findByEmail(normalised);
1843
2112
  if (existing) {
1844
- 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");
1845
2114
  }
1846
2115
  const pin = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1847
2116
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
@@ -1863,8 +2132,8 @@ function userController(store, config, bus) {
1863
2132
  }).catch(() => {
1864
2133
  });
1865
2134
  }
1866
- return (0, import_core6.setApiResponse)(
1867
- import_core6.HTTP.OK,
2135
+ return (0, import_core7.setApiResponse)(
2136
+ import_core7.HTTP.OK,
1868
2137
  "EMAIL_UPDATED",
1869
2138
  "Email updated. A verification code has been sent to your new address.",
1870
2139
  {
@@ -1876,8 +2145,8 @@ function userController(store, config, bus) {
1876
2145
  const body = ctx.meta["body"];
1877
2146
  const newPhone = body?.["phone"];
1878
2147
  if (!isValidPhone2(newPhone)) {
1879
- return (0, import_core6.setApiResponse)(
1880
- import_core6.HTTP.UNPROCESSABLE,
2148
+ return (0, import_core7.setApiResponse)(
2149
+ import_core7.HTTP.UNPROCESSABLE,
1881
2150
  "INVALID_PARAMETER",
1882
2151
  "A valid phone number is required"
1883
2152
  );
@@ -1885,7 +2154,7 @@ function userController(store, config, bus) {
1885
2154
  const normalised = normalizePhone2(newPhone);
1886
2155
  const existing = await users.findByPhone(normalised);
1887
2156
  if (existing) {
1888
- 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");
1889
2158
  }
1890
2159
  const otp = (0, import_node_crypto5.randomInt)(1e5, 1e6).toString();
1891
2160
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
@@ -1907,8 +2176,8 @@ function userController(store, config, bus) {
1907
2176
  }).catch(() => {
1908
2177
  });
1909
2178
  }
1910
- return (0, import_core6.setApiResponse)(
1911
- import_core6.HTTP.OK,
2179
+ return (0, import_core7.setApiResponse)(
2180
+ import_core7.HTTP.OK,
1912
2181
  "PHONE_UPDATED",
1913
2182
  "Phone number updated. A verification code has been sent to your new number.",
1914
2183
  {
@@ -1921,22 +2190,22 @@ function userController(store, config, bus) {
1921
2190
  const currentPassword = body?.["currentPassword"];
1922
2191
  const newPassword = body?.["newPassword"];
1923
2192
  if (typeof currentPassword !== "string" || !currentPassword) {
1924
- 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");
1925
2194
  }
1926
2195
  if (typeof newPassword !== "string" || newPassword.length < 8) {
1927
- 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");
1928
2197
  }
1929
2198
  const user = await users.findById(ctx.user.id);
1930
- 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");
1931
2200
  const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
1932
2201
  const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
1933
2202
  if (!valid) {
1934
- 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");
1935
2204
  }
1936
2205
  const hash = await hashPassword2(newPassword);
1937
2206
  await users.updatePassword(ctx.user.id, hash);
1938
2207
  await sessions.deleteByUser(ctx.user.id);
1939
- 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.");
1940
2209
  },
1941
2210
  deleteMe: async (ctx) => {
1942
2211
  const userId = ctx.user.id;
@@ -1961,7 +2230,7 @@ function userController(store, config, bus) {
1961
2230
  // no MFA secret, no session tokens (session metadata only).
1962
2231
  exportMe: async (ctx) => {
1963
2232
  const user = await users.findById(ctx.user.id);
1964
- 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");
1965
2234
  const sessionRows = await sessions.listByUser(ctx.user.id);
1966
2235
  const bundle = {
1967
2236
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1970,8 +2239,8 @@ function userController(store, config, bus) {
1970
2239
  id: s.id,
1971
2240
  userAgent: s.userAgent,
1972
2241
  ipAddress: s.ipAddress,
1973
- createdAt: (0, import_core6.dateOrEmpty)(s.createdAt),
1974
- expiresAt: (0, import_core6.dateOrEmpty)(s.expiresAt)
2242
+ createdAt: (0, import_core7.dateOrEmpty)(s.createdAt),
2243
+ expiresAt: (0, import_core7.dateOrEmpty)(s.expiresAt)
1975
2244
  })),
1976
2245
  security: {
1977
2246
  mfaEnabled: user.mfaEnabled === true,
@@ -1990,22 +2259,23 @@ function userController(store, config, bus) {
1990
2259
  }
1991
2260
  bundle["modules"] = modules;
1992
2261
  }
1993
- 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);
1994
2263
  }
1995
2264
  };
1996
2265
  }
1997
2266
 
1998
2267
  // src/controllers/oauth.controller.ts
1999
- var import_core7 = require("@fonderie/core");
2268
+ var import_core8 = require("@fonderie/core");
2000
2269
  function oauthController(store, config) {
2001
2270
  const users = new UserModel(store);
2002
2271
  const sessions = new SessionModel(store);
2272
+ const loginEvents = new LoginEventModel(store);
2003
2273
  return {
2004
2274
  googleInit: async (_ctx) => {
2005
2275
  const google = config.google;
2006
2276
  if (!google) {
2007
- return (0, import_core7.setApiResponse)(
2008
- import_core7.HTTP.NOT_IMPLEMENTED,
2277
+ return (0, import_core8.setApiResponse)(
2278
+ import_core8.HTTP.NOT_IMPLEMENTED,
2009
2279
  "NOT_CONFIGURED",
2010
2280
  "Google OAuth not configured"
2011
2281
  );
@@ -2017,8 +2287,8 @@ function oauthController(store, config) {
2017
2287
  scope: "openid email profile"
2018
2288
  });
2019
2289
  const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
2020
- return (0, import_core7.setApiResponse)(
2021
- import_core7.HTTP.OK,
2290
+ return (0, import_core8.setApiResponse)(
2291
+ import_core8.HTTP.OK,
2022
2292
  "GOOGLE_AUTH_URL",
2023
2293
  "Redirect the user to the returned URL to begin Google OAuth.",
2024
2294
  { url }
@@ -2027,8 +2297,8 @@ function oauthController(store, config) {
2027
2297
  googleCallback: async (ctx) => {
2028
2298
  const google = config.google;
2029
2299
  if (!google) {
2030
- return (0, import_core7.setApiResponse)(
2031
- import_core7.HTTP.NOT_IMPLEMENTED,
2300
+ return (0, import_core8.setApiResponse)(
2301
+ import_core8.HTTP.NOT_IMPLEMENTED,
2032
2302
  "NOT_CONFIGURED",
2033
2303
  "Google OAuth not configured"
2034
2304
  );
@@ -2036,7 +2306,7 @@ function oauthController(store, config) {
2036
2306
  const url = new URL(ctx.request.url);
2037
2307
  const code = url.searchParams.get("code");
2038
2308
  if (!code) {
2039
- 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");
2040
2310
  }
2041
2311
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
2042
2312
  method: "POST",
@@ -2051,8 +2321,8 @@ function oauthController(store, config) {
2051
2321
  });
2052
2322
  const tokenData = await tokenRes.json();
2053
2323
  if (!tokenData.id_token) {
2054
- return (0, import_core7.setApiResponse)(
2055
- import_core7.HTTP.BAD_REQUEST,
2324
+ return (0, import_core8.setApiResponse)(
2325
+ import_core8.HTTP.BAD_REQUEST,
2056
2326
  "GOOGLE_AUTH_FAILED",
2057
2327
  "OAuth token exchange failed"
2058
2328
  );
@@ -2061,24 +2331,40 @@ function oauthController(store, config) {
2061
2331
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
2062
2332
  );
2063
2333
  if (!payload.email) {
2064
- 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");
2065
2335
  }
2066
2336
  const normalizedEmail = normalizeEmailSafe(payload.email);
2067
2337
  if (!normalizedEmail) {
2068
- 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");
2069
2339
  }
2340
+ const meta = requestMeta(ctx);
2070
2341
  const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
2071
2342
  if (!upserted) {
2072
- 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");
2073
2352
  }
2074
2353
  const fullUser = await users.findById(upserted.id);
2075
2354
  if (!fullUser) {
2076
- 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");
2077
2356
  }
2078
2357
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
2079
2358
  loginMethod: "google"
2080
2359
  });
2081
- 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
+ });
2082
2368
  return Response.json(
2083
2369
  {
2084
2370
  reason: "GOOGLE_AUTH_SUCCESS",
@@ -2136,6 +2422,13 @@ function buildAuthRoutes(store, config, bus) {
2136
2422
  R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
2137
2423
  R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
2138
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),
2139
2432
  // MFA (email sessions only — requireVerified is always enforced here
2140
2433
  // because MFA is a security feature and email verification is meaningful)
2141
2434
  R("mfaSetup", "POST", "/auth/mfa/setup", import_middlewares2.requireAuth, requireEmailLogin, import_middlewares2.requireVerified, mfa.setup),
@@ -2155,17 +2448,17 @@ function buildAuthRoutes(store, config, bus) {
2155
2448
  }
2156
2449
 
2157
2450
  // src/services/config-guard.ts
2158
- var import_core8 = require("@fonderie/core");
2451
+ var import_core9 = require("@fonderie/core");
2159
2452
  var MODULE = "@fonderie/auth";
2160
2453
  function collectAuthConfigProblems(config) {
2161
2454
  const problems = [];
2162
2455
  const secret = config.jwtSecret ?? "";
2163
- const secretProblem = (0, import_core8.secretStrengthProblem)(secret);
2456
+ const secretProblem = (0, import_core9.secretStrengthProblem)(secret);
2164
2457
  if (secretProblem === "too-short") {
2165
2458
  problems.push({
2166
2459
  module: MODULE,
2167
2460
  severity: "error",
2168
- 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})`
2169
2462
  });
2170
2463
  } else if (secretProblem === "placeholder") {
2171
2464
  problems.push({
@@ -2189,7 +2482,7 @@ function collectAuthConfigProblems(config) {
2189
2482
  severity: "error",
2190
2483
  message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
2191
2484
  });
2192
- } else if (import_core8.PLACEHOLDER_SECRET.test(clientSecret)) {
2485
+ } else if (import_core9.PLACEHOLDER_SECRET.test(clientSecret)) {
2193
2486
  problems.push({
2194
2487
  module: MODULE,
2195
2488
  severity: "error",
@@ -2256,7 +2549,10 @@ function withSession(store, config) {
2256
2549
  ...user,
2257
2550
  loginMethod: payload.loginMethod ?? "email",
2258
2551
  phoneVerified: payload.phoneVerified ?? false,
2259
- 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
2260
2556
  }
2261
2557
  });
2262
2558
  return next();