@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.d.cts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-CTSCI09F.cjs';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.cjs';
5
+ import { I as IAuthConfig } from './session-DJNd1xp_.cjs';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-DJNd1xp_.cjs';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.cjs';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -65,6 +65,28 @@ declare const DEFAULT_TEMPLATES: {
65
65
  };
66
66
  };
67
67
 
68
+ interface ILoginEventDTO {
69
+ id: string;
70
+ method: string;
71
+ outcome: string;
72
+ failureReason: string | null;
73
+ ipAddress: string | null;
74
+ userAgent: string | null;
75
+ createdAt: string;
76
+ }
77
+ interface ILoginHistoryPageDTO {
78
+ events: ILoginEventDTO[];
79
+ nextCursor: string | null;
80
+ }
81
+ interface ISessionDTO {
82
+ id: string;
83
+ current: boolean;
84
+ ipAddress: string | null;
85
+ userAgent: string | null;
86
+ createdAt: string;
87
+ expiresAt: string;
88
+ }
89
+
68
90
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
69
91
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
70
92
  password: z.ZodString;
@@ -183,4 +205,4 @@ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentio
183
205
 
184
206
  declare function validateAuthConfig(config: IAuthConfig): void;
185
207
 
186
- export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
208
+ export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-CTSCI09F.js';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.js';
5
+ import { I as IAuthConfig } from './session-DJNd1xp_.js';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-DJNd1xp_.js';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.js';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -65,6 +65,28 @@ declare const DEFAULT_TEMPLATES: {
65
65
  };
66
66
  };
67
67
 
68
+ interface ILoginEventDTO {
69
+ id: string;
70
+ method: string;
71
+ outcome: string;
72
+ failureReason: string | null;
73
+ ipAddress: string | null;
74
+ userAgent: string | null;
75
+ createdAt: string;
76
+ }
77
+ interface ILoginHistoryPageDTO {
78
+ events: ILoginEventDTO[];
79
+ nextCursor: string | null;
80
+ }
81
+ interface ISessionDTO {
82
+ id: string;
83
+ current: boolean;
84
+ ipAddress: string | null;
85
+ userAgent: string | null;
86
+ createdAt: string;
87
+ expiresAt: string;
88
+ }
89
+
68
90
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
69
91
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
70
92
  password: z.ZodString;
@@ -183,4 +205,4 @@ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentio
183
205
 
184
206
  declare function validateAuthConfig(config: IAuthConfig): void;
185
207
 
186
- export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
208
+ export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, type ISessionDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
package/dist/index.js CHANGED
@@ -691,17 +691,23 @@ var SessionModel = class {
691
691
  this.store = store;
692
692
  }
693
693
  store;
694
- async create(userId, token, expiresAt, sid) {
694
+ async create(userId, token, expiresAt, sid, meta) {
695
695
  await this.store.query(
696
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid)
697
- VALUES ($1, $2, $3, $4)
696
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
697
+ VALUES ($1, $2, $3, $4, $5, $6)
698
698
  ON CONFLICT (token) DO NOTHING`,
699
- [userId, token, expiresAt, sid ?? null]
699
+ [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
700
700
  );
701
701
  }
702
702
  async delete(token) {
703
703
  await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
704
704
  }
705
+ // Delete the session an access token is bound to (by its sid claim). Lets
706
+ // logout revoke the current session without the client resending the refresh
707
+ // token — the access token already identifies its session.
708
+ async deleteBySid(sid) {
709
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);
710
+ }
705
711
  // Revoke every session for a user (e.g. on password change). Access tokens
706
712
  // bound to these sessions via the sid claim die on their next request.
707
713
  async deleteByUser(userId) {
@@ -721,6 +727,42 @@ var SessionModel = class {
721
727
  [userId]
722
728
  );
723
729
  }
730
+ // Live sessions for the Active Sessions screen: unexpired only, carrying the
731
+ // sid so the caller can flag the current one (row.sid === the JWT's sid).
732
+ // No token — that never leaves the server.
733
+ async listLiveByUser(userId) {
734
+ return this.store.query(
735
+ `SELECT id, sid,
736
+ user_agent AS "userAgent",
737
+ ip_address AS "ipAddress",
738
+ created_at AS "createdAt",
739
+ expires_at AS "expiresAt"
740
+ FROM fonderie_sessions
741
+ WHERE user_id = $1 AND expires_at > now()
742
+ ORDER BY created_at DESC`,
743
+ [userId]
744
+ );
745
+ }
746
+ // Terminate one session by row id, scoped to its owner (a user can never
747
+ // delete another user's session). Returns whether a row was removed.
748
+ async terminateById(userId, id) {
749
+ const rows = await this.store.query(
750
+ `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
751
+ [id, userId]
752
+ );
753
+ return rows.length > 0;
754
+ }
755
+ // Terminate all of a user's sessions except the current one (by sid).
756
+ // Returns the number of sessions removed.
757
+ async terminateOthers(userId, keepSid) {
758
+ const rows = await this.store.query(
759
+ `DELETE FROM fonderie_sessions
760
+ WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
761
+ RETURNING id`,
762
+ [userId, keepSid]
763
+ );
764
+ return rows.length;
765
+ }
724
766
  async exists(token) {
725
767
  const rows = await this.store.query(
726
768
  `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
@@ -773,11 +815,92 @@ var BackupCodeModel = class {
773
815
  }
774
816
  };
775
817
 
818
+ // src/models/login-event.model.ts
819
+ var LoginEventModel = class {
820
+ constructor(store) {
821
+ this.store = store;
822
+ }
823
+ store;
824
+ async record(e) {
825
+ await this.store.query(
826
+ `INSERT INTO fonderie_login_events
827
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
828
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
829
+ [
830
+ e.userId,
831
+ e.emailAttempted ?? null,
832
+ e.method,
833
+ e.outcome,
834
+ e.failureReason ?? null,
835
+ e.ipAddress,
836
+ e.userAgent
837
+ ]
838
+ );
839
+ }
840
+ // Fire-and-forget variant for the login paths: recording history must never
841
+ // block or fail a login (same posture as the bus.emit(...).catch(() => {})
842
+ // notification sends).
843
+ recordSafe(e) {
844
+ void this.record(e).catch(() => {
845
+ });
846
+ }
847
+ // One page of a user's own login history, newest first. Keyset-paginated on
848
+ // (created_at, id) — mirrors audit's event list so the two log UIs share a
849
+ // cursor contract. The +1 over-fetch that detects a next page lives here so
850
+ // no outer clamp can shave it off.
851
+ async listByUser(query) {
852
+ const limit = Math.min(query.limit ?? 50, MAX_HISTORY_LIMIT);
853
+ const params = [query.userId];
854
+ const where = ["user_id = $1"];
855
+ if (query.outcome) {
856
+ params.push(query.outcome);
857
+ where.push(`outcome = $${params.length}`);
858
+ }
859
+ if (query.from) {
860
+ params.push(query.from);
861
+ where.push(`created_at >= $${params.length}`);
862
+ }
863
+ if (query.to) {
864
+ params.push(query.to);
865
+ where.push(`created_at <= $${params.length}`);
866
+ }
867
+ if (query.cursor) {
868
+ params.push(query.cursor.createdAt, query.cursor.id);
869
+ where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
870
+ }
871
+ params.push(limit + 1);
872
+ const rows = await this.store.query(
873
+ `SELECT id, method, outcome, failure_reason AS "failureReason",
874
+ ip_address AS "ipAddress", user_agent AS "userAgent",
875
+ created_at AS "createdAt", created_at::text AS "createdAtRaw"
876
+ FROM fonderie_login_events
877
+ WHERE ${where.join(" AND ")}
878
+ ORDER BY created_at DESC, id DESC
879
+ LIMIT $${params.length}`,
880
+ params
881
+ );
882
+ return { events: rows.slice(0, limit), hasMore: rows.length > limit };
883
+ }
884
+ };
885
+ var MAX_HISTORY_LIMIT = 200;
886
+
887
+ // src/services/request-meta.ts
888
+ var UA_MAX = 512;
889
+ function requestMeta(ctx) {
890
+ const ip = ctx.meta["clientIp"];
891
+ const ua = ctx.request.headers.get("user-agent");
892
+ return {
893
+ ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
894
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
895
+ };
896
+ }
897
+
776
898
  // src/controllers/mfa.controller.ts
777
899
  function mfaController(store, config, issuer, bus) {
778
900
  const users = new UserModel(store);
779
901
  const sessions = new SessionModel(store);
780
902
  const backupCodes = new BackupCodeModel(store);
903
+ const loginEvents = new LoginEventModel(store);
781
904
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
782
905
  return {
783
906
  // ── 1. Setup ───────────────────────────────────────────────
@@ -807,6 +930,16 @@ function mfaController(store, config, issuer, bus) {
807
930
  verify: async (ctx) => {
808
931
  const body = ctx.meta["body"];
809
932
  const token = body?.["token"];
933
+ const isLoginCompletion = ctx.user.mfaPending === true;
934
+ const meta = requestMeta(ctx);
935
+ const failedMfa = () => loginEvents.recordSafe({
936
+ userId: ctx.user.id,
937
+ emailAttempted: ctx.user.email,
938
+ method: "mfa",
939
+ outcome: "failed",
940
+ failureReason: "invalid_code",
941
+ ...meta
942
+ });
810
943
  if (typeof token !== "string") {
811
944
  return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
812
945
  }
@@ -847,6 +980,7 @@ function mfaController(store, config, issuer, bus) {
847
980
  );
848
981
  const matched = checks.find((r) => r.match);
849
982
  if (!matched) {
983
+ if (isLoginCompletion) failedMfa();
850
984
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
851
985
  }
852
986
  await backupCodes.consume(matched.id);
@@ -864,6 +998,7 @@ function mfaController(store, config, issuer, bus) {
864
998
  }
865
999
  const secret = mfaCipher.decrypt(storedSecret);
866
1000
  if (!verifyTotpToken(token, secret)) {
1001
+ if (isLoginCompletion) failedMfa();
867
1002
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
868
1003
  }
869
1004
  if (!ctx.user.mfaEnabled) {
@@ -874,7 +1009,16 @@ function mfaController(store, config, issuer, bus) {
874
1009
  loginMethod: ctx.user.loginMethod,
875
1010
  phoneVerified: ctx.user.phoneVerified
876
1011
  });
877
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1012
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1013
+ if (isLoginCompletion) {
1014
+ loginEvents.recordSafe({
1015
+ userId: ctx.user.id,
1016
+ emailAttempted: ctx.user.email,
1017
+ method: "mfa",
1018
+ outcome: "success",
1019
+ ...meta
1020
+ });
1021
+ }
878
1022
  const fullUser = await users.findById(ctx.user.id);
879
1023
  if (!fullUser) {
880
1024
  return setApiResponse2(HTTP2.SERVER_ERROR, "SERVER_ERROR", "User not found after MFA verify");
@@ -1143,6 +1287,7 @@ function extractRefreshToken(ctx) {
1143
1287
  function authController(store, config, bus) {
1144
1288
  const users = new UserModel(store);
1145
1289
  const sessions = new SessionModel(store);
1290
+ const loginEvents = new LoginEventModel(store);
1146
1291
  const passwordReset = new PasswordResetModel(store);
1147
1292
  const emailVerif = new EmailVerificationModel(store);
1148
1293
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1212,7 +1357,7 @@ function authController(store, config, bus) {
1212
1357
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1213
1358
  loginMethod: "email"
1214
1359
  });
1215
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1360
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1216
1361
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1217
1362
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1218
1363
  return Response.json(
@@ -1276,7 +1421,7 @@ function authController(store, config, bus) {
1276
1421
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1277
1422
  loginMethod: "phone"
1278
1423
  });
1279
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1424
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1280
1425
  return Response.json(
1281
1426
  {
1282
1427
  reason: "USER_PHONE_REGISTERED",
@@ -1300,6 +1445,7 @@ function authController(store, config, bus) {
1300
1445
  },
1301
1446
  login: async (ctx) => {
1302
1447
  const body = ctx.meta["body"];
1448
+ const meta = requestMeta(ctx);
1303
1449
  if (typeof body?.["email"] === "string" && typeof body?.["password"] === "string") {
1304
1450
  const { email: rawEmail, password: password2 } = body;
1305
1451
  const email2 = normalizeEmailSafe(rawEmail);
@@ -1308,6 +1454,14 @@ function authController(store, config, bus) {
1308
1454
  }
1309
1455
  const user = await users.findByEmail(email2);
1310
1456
  if (!user || !user.passwordHash) {
1457
+ loginEvents.recordSafe({
1458
+ userId: null,
1459
+ emailAttempted: email2,
1460
+ method: "password",
1461
+ outcome: "failed",
1462
+ failureReason: "unknown_email",
1463
+ ...meta
1464
+ });
1311
1465
  return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1312
1466
  }
1313
1467
  const { valid, needsRehash } = await verifyPasswordForLogin(
@@ -1316,6 +1470,14 @@ function authController(store, config, bus) {
1316
1470
  config.legacyVerify
1317
1471
  );
1318
1472
  if (!valid) {
1473
+ loginEvents.recordSafe({
1474
+ userId: user.id,
1475
+ emailAttempted: email2,
1476
+ method: "password",
1477
+ outcome: "failed",
1478
+ failureReason: "bad_password",
1479
+ ...meta
1480
+ });
1319
1481
  return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1320
1482
  }
1321
1483
  if (needsRehash) {
@@ -1337,7 +1499,14 @@ function authController(store, config, bus) {
1337
1499
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1338
1500
  loginMethod: "email"
1339
1501
  });
1340
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1502
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1503
+ loginEvents.recordSafe({
1504
+ userId: user.id,
1505
+ emailAttempted: email2,
1506
+ method: "password",
1507
+ outcome: "success",
1508
+ ...meta
1509
+ });
1341
1510
  const resolvedLogin = { ...config, ...config.resolve?.(ctx) };
1342
1511
  const requiresVerification = !!resolvedLogin.requireVerification && !user.emailVerifiedAt;
1343
1512
  return Response.json(
@@ -1382,7 +1551,7 @@ function authController(store, config, bus) {
1382
1551
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1383
1552
  loginMethod: "phone"
1384
1553
  });
1385
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1554
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1386
1555
  return Response.json(
1387
1556
  {
1388
1557
  reason: "USER_PHONE_OTP_SENT",
@@ -1405,6 +1574,10 @@ function authController(store, config, bus) {
1405
1574
  );
1406
1575
  },
1407
1576
  logout: async (ctx) => {
1577
+ const sid = ctx.user?.sid;
1578
+ if (sid) {
1579
+ await sessions.deleteBySid(sid).catch(() => void 0);
1580
+ }
1408
1581
  const token = extractRefreshToken(ctx);
1409
1582
  if (token) {
1410
1583
  await sessions.delete(token).catch(() => void 0);
@@ -1443,7 +1616,7 @@ function authController(store, config, bus) {
1443
1616
  loginMethod: payload.loginMethod ?? "email",
1444
1617
  phoneVerified: payload.phoneVerified ?? false
1445
1618
  });
1446
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1619
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1447
1620
  return Response.json(
1448
1621
  {
1449
1622
  reason: "TOKENS_REFRESHED",
@@ -1577,7 +1750,7 @@ function authController(store, config, bus) {
1577
1750
  loginMethod: "phone",
1578
1751
  phoneVerified: true
1579
1752
  });
1580
- await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
1753
+ await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1581
1754
  const verifiedUser = await users.findById(ctx.user.id);
1582
1755
  if (!verifiedUser) {
1583
1756
  return setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "User not found");
@@ -1712,6 +1885,38 @@ function authController(store, config, bus) {
1712
1885
  import { randomInt as randomInt2 } from "crypto";
1713
1886
  import { setApiResponse as setApiResponse4, HTTP as HTTP4, dateOrEmpty as dateOrEmpty2 } from "@fonderie/core";
1714
1887
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
1888
+
1889
+ // src/dtos/login-activity.ts
1890
+ import { encodeKeysetCursor, decodeKeysetCursor } from "@fonderie/core";
1891
+ function toLoginEventDTO(row) {
1892
+ return {
1893
+ id: row.id,
1894
+ method: row.method,
1895
+ outcome: row.outcome,
1896
+ failureReason: row.failureReason,
1897
+ ipAddress: row.ipAddress,
1898
+ userAgent: row.userAgent,
1899
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
1900
+ };
1901
+ }
1902
+ function toLoginHistoryPageDTO(page) {
1903
+ const last = page.events[page.events.length - 1];
1904
+ const nextCursor = page.hasMore && last ? encodeKeysetCursor(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
1905
+ return { events: page.events.map(toLoginEventDTO), nextCursor };
1906
+ }
1907
+ var decodeLoginCursor = decodeKeysetCursor;
1908
+ function toSessionDTO(row, currentSid) {
1909
+ return {
1910
+ id: row.id,
1911
+ current: currentSid !== null && row.sid === currentSid,
1912
+ ipAddress: row.ipAddress,
1913
+ userAgent: row.userAgent,
1914
+ createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
1915
+ expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
1916
+ };
1917
+ }
1918
+
1919
+ // src/controllers/user.controller.ts
1715
1920
  function normalizePhone2(phone2) {
1716
1921
  return phone2.trim().replace(/[\s()\-\.]/g, "");
1717
1922
  }
@@ -1721,6 +1926,7 @@ function isValidPhone2(phone2) {
1721
1926
  function userController(store, config, bus) {
1722
1927
  const users = new UserModel(store);
1723
1928
  const sessions = new SessionModel(store);
1929
+ const loginEvents = new LoginEventModel(store);
1724
1930
  const emailVerif = new EmailVerificationModel(store);
1725
1931
  const phoneVerif = new PhoneVerificationModel(store);
1726
1932
  return {
@@ -1733,6 +1939,69 @@ function userController(store, config, bus) {
1733
1939
  user: toUserDTO(user, ctx.user.phoneVerified)
1734
1940
  });
1735
1941
  },
1942
+ // ── Login history (append-only; the caller's own attempts) ────────
1943
+ loginHistory: async (ctx) => {
1944
+ const params = new URL(ctx.request.url).searchParams;
1945
+ const rawLimit = Number(params.get("limit") ?? 50);
1946
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
1947
+ const outcomeParam = params.get("outcome");
1948
+ if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
1949
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
1950
+ }
1951
+ const cursorParam = params.get("cursor");
1952
+ let cursor = null;
1953
+ if (cursorParam) {
1954
+ cursor = decodeLoginCursor(cursorParam);
1955
+ if (!cursor) {
1956
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
1957
+ }
1958
+ }
1959
+ const query = { userId: ctx.user.id, limit };
1960
+ if (outcomeParam) query.outcome = outcomeParam;
1961
+ if (cursor) query.cursor = cursor;
1962
+ const from = params.get("from");
1963
+ const to = params.get("to");
1964
+ if (from) query.from = new Date(from);
1965
+ if (to) query.to = new Date(to);
1966
+ const page = await loginEvents.listByUser(query);
1967
+ return setApiResponse4(
1968
+ HTTP4.OK,
1969
+ "LOGIN_HISTORY_FETCHED",
1970
+ "Login history retrieved.",
1971
+ toLoginHistoryPageDTO(page)
1972
+ );
1973
+ },
1974
+ // ── Active sessions (live only; the caller's own) ─────────────────
1975
+ listSessions: async (ctx) => {
1976
+ const rows = await sessions.listLiveByUser(ctx.user.id);
1977
+ const currentSid = ctx.user.sid ?? null;
1978
+ return setApiResponse4(HTTP4.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
1979
+ sessions: rows.map((r) => toSessionDTO(r, currentSid))
1980
+ });
1981
+ },
1982
+ terminateSession: async (ctx) => {
1983
+ const id = ctx.meta["params"]?.["id"];
1984
+ if (!id) {
1985
+ return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
1986
+ }
1987
+ const removed = await sessions.terminateById(ctx.user.id, id);
1988
+ if (!removed) {
1989
+ return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
1990
+ }
1991
+ return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
1992
+ },
1993
+ terminateOtherSessions: async (ctx) => {
1994
+ const currentSid = ctx.user.sid ?? null;
1995
+ if (!currentSid) {
1996
+ return setApiResponse4(
1997
+ HTTP4.UNPROCESSABLE,
1998
+ "NO_CURRENT_SESSION",
1999
+ "Cannot terminate others without a current session context"
2000
+ );
2001
+ }
2002
+ const count = await sessions.terminateOthers(ctx.user.id, currentSid);
2003
+ return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2004
+ },
1736
2005
  updateProfile: async (ctx) => {
1737
2006
  const body = ctx.meta["body"];
1738
2007
  const allowed = ["firstName", "lastName", "avatarUrl"];
@@ -1960,6 +2229,7 @@ import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core
1960
2229
  function oauthController(store, config) {
1961
2230
  const users = new UserModel(store);
1962
2231
  const sessions = new SessionModel(store);
2232
+ const loginEvents = new LoginEventModel(store);
1963
2233
  return {
1964
2234
  googleInit: async (_ctx) => {
1965
2235
  const google = config.google;
@@ -2027,8 +2297,17 @@ function oauthController(store, config) {
2027
2297
  if (!normalizedEmail) {
2028
2298
  return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
2029
2299
  }
2300
+ const meta = requestMeta(ctx);
2030
2301
  const upserted = await users.upsertByProvider(normalizedEmail, "google", payload.sub ?? "");
2031
2302
  if (!upserted) {
2303
+ loginEvents.recordSafe({
2304
+ userId: null,
2305
+ emailAttempted: normalizedEmail,
2306
+ method: "oauth-google",
2307
+ outcome: "failed",
2308
+ failureReason: "provider_upsert_failed",
2309
+ ...meta
2310
+ });
2032
2311
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2033
2312
  }
2034
2313
  const fullUser = await users.findById(upserted.id);
@@ -2038,7 +2317,14 @@ function oauthController(store, config) {
2038
2317
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
2039
2318
  loginMethod: "google"
2040
2319
  });
2041
- await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
2320
+ await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2321
+ loginEvents.recordSafe({
2322
+ userId: upserted.id,
2323
+ emailAttempted: normalizedEmail,
2324
+ method: "oauth-google",
2325
+ outcome: "success",
2326
+ ...meta
2327
+ });
2042
2328
  return Response.json(
2043
2329
  {
2044
2330
  reason: "GOOGLE_AUTH_SUCCESS",
@@ -2096,6 +2382,13 @@ function buildAuthRoutes(store, config, bus) {
2096
2382
  R("changePassword", "PUT", "/users/password", requireAuth, validate(changePasswordSchema), user.changePassword),
2097
2383
  R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
2098
2384
  R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
2385
+ // Security surfaces (Protected; the caller's own login history + sessions).
2386
+ // The literal /others route is registered before /:id so it isn't
2387
+ // captured as an id.
2388
+ R("loginHistory", "GET", "/auth/login-history", requireAuth, user.loginHistory),
2389
+ R("listSessions", "GET", "/auth/sessions", requireAuth, user.listSessions),
2390
+ R("terminateOtherSessions", "DELETE", "/auth/sessions/others", requireAuth, user.terminateOtherSessions),
2391
+ R("terminateSession", "DELETE", "/auth/sessions/:id", requireAuth, user.terminateSession),
2099
2392
  // MFA (email sessions only — requireVerified is always enforced here
2100
2393
  // because MFA is a security feature and email verification is meaningful)
2101
2394
  R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
@@ -2216,7 +2509,10 @@ function withSession(store, config) {
2216
2509
  ...user,
2217
2510
  loginMethod: payload.loginMethod ?? "email",
2218
2511
  phoneVerified: payload.phoneVerified ?? false,
2219
- mfaPending: payload.mfaPending ?? false
2512
+ mfaPending: payload.mfaPending ?? false,
2513
+ // The session this request is authenticated on, so the sessions
2514
+ // API can flag the current row and "terminate others" can spare it.
2515
+ sid: payload.sid ?? null
2220
2516
  }
2221
2517
  });
2222
2518
  return next();