@fonderie/auth 7.17.1 → 7.19.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/brain/outcomes.md CHANGED
@@ -98,6 +98,7 @@ location JSONB
98
98
  previous_token_hash TEXT
99
99
  previous_valid_until TIMESTAMPTZ
100
100
  last_used_at TIMESTAMPTZ
101
+ client_kind TEXT
101
102
  -- INDEX idx_fonderie_sessions_expires_at (expires_at)
102
103
  -- INDEX idx_fonderie_sessions_sid (sid)
103
104
  ```
@@ -144,7 +145,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
144
145
  | GET | `/_admin/users` | `async (ctx) => { const params = new URL(ctx.request.url).searchParams; const email = params.get('email')?.trim().toLowerCase(); if (email) { const user = await users.findByEmail(email); return user ? setApiResponse(HTTP.OK, 'USER', 'User', toAdminUserDTO(user)) : NOT_FOUND(); } const rawLimit = Number(params.get('limit') ?? 50); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50; const cursorParam = params.get('cursor'); const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null; if (cursorParam && !cursor) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'Invalid cursor'); const deleted = params.get('deleted') === '1' || params.get('deleted') === 'true'; const page = await users.list({ limit, ...(cursor ? { cursor } : {}), ...(deleted ? { deleted } : {}), }); return setApiResponse(HTTP.OK, 'USERS', 'Users', toAdminUserPageDTO(page)); }` |
145
146
  | GET | `/_admin/users/:id` | `async (ctx) => { // Read-only view: a soft-deleted account resolves (deletedAt set), so // an operator arriving from billing sees what happened to it. const user = await users.findByIdIncludingDeleted(idOf(ctx)); return user ? setApiResponse(HTTP.OK, 'USER', 'User', toAdminUserDTO(user)) : NOT_FOUND(); }` |
146
147
  | GET | `/_admin/users/:id/login-history` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); const params = new URL(ctx.request.url).searchParams; const rawLimit = Number(params.get('limit') ?? 50); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50; const cursorParam = params.get('cursor'); const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null; if (cursorParam && !cursor) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'Invalid cursor'); const page = await events.listByUser({ userId: idOf(ctx), limit, ...(cursor ? { cursor } : {}), }); return setApiResponse( HTTP.OK, 'LOGIN_HISTORY', 'Login history', toLoginHistoryPageDTO(page), ); }` |
147
- | DELETE | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); await sessions.deleteByUser(idOf(ctx)); return setApiResponse(HTTP.OK, 'SESSIONS_REVOKED', 'All sessions revoked'); }` |
148
+ | DELETE | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); await sessions.deleteByUser(idOf(ctx)); await background(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: 'admin' } satisfies ISessionRevokedEvent)); return setApiResponse(HTTP.OK, 'SESSIONS_REVOKED', 'All sessions revoked'); }` |
148
149
  | GET | `/_admin/users/:id/sessions` | `async (ctx) => { if (!(await users.findById(idOf(ctx)))) return NOT_FOUND(); const rows = await sessions.listLiveByUser(idOf(ctx)); return setApiResponse( HTTP.OK, 'SESSIONS', 'Live sessions', rows.map((r) => toSessionDTO(r, null)), ); }` |
149
150
  | POST | `/_admin/users/:id/suspend` | `setSuspended(true)` |
150
151
  | POST | `/_admin/users/:id/unsuspend` | `setSuspended(false)` |
@@ -52,6 +52,7 @@ new AuthModule(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undef
52
52
  .name: "@fonderie/auth"
53
53
  .version: string
54
54
  .describeAdmin(): IAdminDescription
55
+ .describeEvents(): IEventCatalogEntry<unknown>[]
55
56
  .checkReadiness(): IReadinessProblem[]
56
57
  .install(app: IFonderieApp): void
57
58
 
@@ -93,6 +94,7 @@ interface IAuthSecrets {
93
94
  interface IAuthRuntimeConfig {
94
95
  sessionDuration?: string;
95
96
  sessionMaxAge?: string;
97
+ sessionPolicies?: Partial<Record<ClientKind, ISessionPolicy>>;
96
98
  verificationCooldown?: number;
97
99
  mfa?: boolean;
98
100
  requireVerification?: boolean;
@@ -103,7 +105,7 @@ interface IDataExportContributor {
103
105
  collect: (userId: string) => Promise<unknown> | unknown;
104
106
  }
105
107
 
106
- const AUTH_CONFIG_KEYS: Record<keyof IAuthRuntimeConfig, string>
108
+ const AUTH_CONFIG_KEYS: Record<Exclude<keyof IAuthRuntimeConfig, 'sessionPolicies'>, string>
107
109
 
108
110
  const MESSAGE_KEYS: { readonly emailRegistration: "email-registration"; readonly emailVerification: "email-verification"; readonly passwordReset: "password-reset"; readonly phoneOtp: "phone-otp"; readonly mfaEnabled: "mfa-enabled"; readonly mfaDisabled: "mfa-disabled"; readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated"; readonly emailChanged: "email-changed"; readonly phoneChanged: "phone-changed"; readonly passwordRevoked: "password-revoked"; readonly oauthRegistration: "oauth-registration"; readonly oauthLinked: "oauth-linked"; readonly oauthUnlinked: "oauth-unlinked"; }
109
111
 
@@ -159,6 +161,7 @@ interface ISessionDTO {
159
161
  location: IRequestLocation | null;
160
162
  createdAt: string;
161
163
  expiresAt: string;
164
+ clientKind: string | null;
162
165
  }
163
166
 
164
167
  function sanitizeLocation(input: unknown): IRequestLocation | null
@@ -265,9 +268,29 @@ interface IAdminUserDTO extends IUserDTO {
265
268
  createdAt: string;
266
269
  }
267
270
 
268
- function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[]
271
+ function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus | undefined): IAdminRoute[]
269
272
 
270
273
  function toAdminUserDTO(user: IUser): IAdminUserDTO
271
274
 
272
275
  function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO
276
+
277
+ const CLIENT_KINDS: readonly ["mobile", "desktop", "web"]
278
+
279
+ const SESSION_POLICY_PRESETS: Readonly<Record<ClientKind, ISessionPolicy>>
280
+
281
+ function clientKindOf(headers: Headers): "mobile" | "desktop" | "web" | null
282
+
283
+ function configForClient(config: IAuthConfig, runtime: Partial<IAuthRuntimeConfig> | undefined, kind: "mobile" | "desktop" | "web" | null): IAuthConfig
284
+
285
+ function readAuthRuntimeConfig(read: (key: string) => unknown): Partial<IAuthRuntimeConfig>
286
+
287
+ function sessionPolicyConfigKeys(kind: "mobile" | "desktop" | "web"): Record<keyof ISessionPolicy, string>
288
+
289
+ type ClientKind = (typeof CLIENT_KINDS)[number];
290
+
291
+ interface ISessionPolicy {
292
+ sessionDuration?: string;
293
+ sessionMaxAge?: string;
294
+ accessTokenDuration?: string;
295
+ }
273
296
  ```
package/dist/index.cjs CHANGED
@@ -70,19 +70,25 @@ var index_exports = {};
70
70
  __export(index_exports, {
71
71
  AUTH_CONFIG_KEYS: () => AUTH_CONFIG_KEYS,
72
72
  AuthModule: () => AuthModule,
73
+ CLIENT_KINDS: () => CLIENT_KINDS,
73
74
  DEFAULT_TEMPLATES: () => DEFAULT_TEMPLATES,
74
75
  MESSAGE_KEYS: () => MESSAGE_KEYS,
76
+ SESSION_POLICY_PRESETS: () => SESSION_POLICY_PRESETS,
75
77
  buildAuthAccountLimiter: () => buildAuthAccountLimiter,
76
78
  buildAuthIpLimiter: () => buildAuthIpLimiter,
79
+ clientKindOf: () => clientKindOf,
80
+ configForClient: () => configForClient,
77
81
  describeAuthAdminRoutes: () => describeAuthAdminRoutes,
78
82
  importUser: () => importUser,
79
83
  normalizeEmail: () => normalizeEmail,
80
84
  normalizeEmailSafe: () => normalizeEmailSafe,
81
85
  purgeSoftDeletedUsers: () => purgeSoftDeletedUsers,
86
+ readAuthRuntimeConfig: () => readAuthRuntimeConfig,
82
87
  requireAuth: () => import_middlewares3.requireAuth,
83
88
  resolveLocation: () => resolveLocation,
84
89
  sanitizeLocation: () => sanitizeLocation,
85
90
  schemas: () => schemas_exports,
91
+ sessionPolicyConfigKeys: () => sessionPolicyConfigKeys,
86
92
  startUserRetention: () => startUserRetention,
87
93
  toAdminUserDTO: () => toAdminUserDTO,
88
94
  toAdminUserPageDTO: () => toAdminUserPageDTO,
@@ -329,8 +335,10 @@ var EVENT_KEYS = {
329
335
  userPurged: "fonderie.user.purged",
330
336
  emailVerified: "fonderie.user.email_verified",
331
337
  passwordChanged: "fonderie.user.password_changed",
332
- // A session the server revoked — today: a refresh token reused after its
333
- // grace (theft signal). { userId, sessionId, reason }.
338
+ // Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
339
+ // the user's devices live (@fonderie/sse) so a revoked device signs out at
340
+ // once. Reasons: 'terminated' (signed out from the devices list),
341
+ // 'password-changed', 'admin', 'refresh-reuse' (theft signal).
334
342
  sessionRevoked: "fonderie.session.revoked"
335
343
  };
336
344
 
@@ -413,6 +421,79 @@ function validDuration(value, fallback, name) {
413
421
  return fallback;
414
422
  }
415
423
 
424
+ // src/services/session-policy.ts
425
+ var CLIENT_KINDS = ["mobile", "desktop", "web"];
426
+ var SESSION_POLICY_PRESETS = {
427
+ // Personal device; the token lives in the Keychain / Keystore.
428
+ mobile: { sessionDuration: "90d", sessionMaxAge: "365d" },
429
+ // Personal, but often a shared workstation.
430
+ desktop: { sessionDuration: "30d", sessionMaxAge: "180d" },
431
+ // Shared and public computers; cookies.
432
+ web: { sessionDuration: "14d", sessionMaxAge: "90d" }
433
+ };
434
+ function clientKindOf(headers) {
435
+ const v = headers.get("x-client-kind")?.trim().toLowerCase();
436
+ return CLIENT_KINDS.includes(v ?? "") ? v : null;
437
+ }
438
+ var FIELDS = ["sessionDuration", "sessionMaxAge", "accessTokenDuration"];
439
+ function configForClient(config, runtime, kind) {
440
+ const rt = runtime ?? {};
441
+ const code = config;
442
+ const out = { ...config, ...rt };
443
+ for (const field of FIELDS) {
444
+ const value = (kind ? rt.sessionPolicies?.[kind]?.[field] : void 0) ?? rt[field] ?? (kind ? code.sessionPolicies?.[kind]?.[field] : void 0) ?? code[field] ?? (kind ? SESSION_POLICY_PRESETS[kind][field] : void 0);
445
+ if (value === void 0) delete out[field];
446
+ else out[field] = value;
447
+ }
448
+ return out;
449
+ }
450
+ function sessionPolicyConfigKeys(kind) {
451
+ return {
452
+ sessionDuration: `auth.session.duration.${kind}`,
453
+ sessionMaxAge: `auth.session.max_age.${kind}`,
454
+ accessTokenDuration: `auth.access.duration.${kind}`
455
+ };
456
+ }
457
+ function readAuthRuntimeConfig(read) {
458
+ const text = (key) => {
459
+ const v = read(key);
460
+ return typeof v === "string" && v.trim() ? v.trim() : void 0;
461
+ };
462
+ const bool2 = (key) => {
463
+ const v = read(key);
464
+ if (typeof v === "boolean") return v;
465
+ if (typeof v === "string" && ["true", "false"].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === "true";
466
+ return void 0;
467
+ };
468
+ const num = (key) => {
469
+ const v = read(key);
470
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : NaN;
471
+ return Number.isFinite(n) ? n : void 0;
472
+ };
473
+ const out = {};
474
+ const set = (k, v) => {
475
+ if (v !== void 0) out[k] = v;
476
+ };
477
+ set("sessionDuration", text("auth.session.duration"));
478
+ set("sessionMaxAge", text("auth.session.max_age"));
479
+ set("accessTokenDuration", text("auth.access.duration"));
480
+ set("verificationCooldown", num("auth.verification.cooldown"));
481
+ set("mfa", bool2("auth.mfa.enabled"));
482
+ set("requireVerification", bool2("auth.verification.required"));
483
+ const policies = {};
484
+ for (const kind of CLIENT_KINDS) {
485
+ const keys = sessionPolicyConfigKeys(kind);
486
+ const policy = {};
487
+ for (const field of FIELDS) {
488
+ const v = text(keys[field]);
489
+ if (v !== void 0) policy[field] = v;
490
+ }
491
+ if (Object.keys(policy).length) policies[kind] = policy;
492
+ }
493
+ if (Object.keys(policies).length) out.sessionPolicies = policies;
494
+ return out;
495
+ }
496
+
416
497
  // src/services/mfa.ts
417
498
  var import_node_crypto2 = require("crypto");
418
499
  var import_core2 = require("@fonderie/core");
@@ -1036,8 +1117,8 @@ var SessionModel = class {
1036
1117
  async create(userId, token, expiresAt, sid, meta) {
1037
1118
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
1038
1119
  await this.store.query(
1039
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
1040
- VALUES ($1, $2, $3, $4, $5, $6, $7, now())
1120
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)
1121
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
1041
1122
  ON CONFLICT (token) DO NOTHING`,
1042
1123
  [
1043
1124
  userId,
@@ -1046,7 +1127,8 @@ var SessionModel = class {
1046
1127
  sid ?? null,
1047
1128
  meta?.userAgent ?? null,
1048
1129
  meta?.ipAddress ?? null,
1049
- location ? JSON.stringify(location) : null
1130
+ location ? JSON.stringify(location) : null,
1131
+ meta?.clientKind ?? null
1050
1132
  ]
1051
1133
  );
1052
1134
  }
@@ -1061,12 +1143,12 @@ var SessionModel = class {
1061
1143
  async match(token) {
1062
1144
  const hash = hashRefreshToken(token);
1063
1145
  const [current] = await this.store.query(
1064
- `SELECT id, user_id, sid, token, created_at FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1146
+ `SELECT id, user_id, sid, token, created_at, client_kind FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1065
1147
  [hash, token]
1066
1148
  );
1067
1149
  if (current) return { kind: "current", row: toRow(current) };
1068
1150
  const [previous] = await this.store.query(
1069
- `SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
1151
+ `SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace
1070
1152
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1071
1153
  [hash]
1072
1154
  );
@@ -1132,7 +1214,7 @@ var SessionModel = class {
1132
1214
  // No token — that never leaves the server.
1133
1215
  async listLiveByUser(userId) {
1134
1216
  return this.store.query(
1135
- `SELECT id, sid,
1217
+ `SELECT id, sid, client_kind AS "clientKind",
1136
1218
  user_agent AS "userAgent",
1137
1219
  ip_address AS "ipAddress",
1138
1220
  location,
@@ -1147,22 +1229,30 @@ var SessionModel = class {
1147
1229
  // Terminate one session by row id, scoped to its owner (a user can never
1148
1230
  // delete another user's session). Returns whether a row was removed.
1149
1231
  async terminateById(userId, id) {
1232
+ return await this.terminateByIdReturningSid(userId, id) !== void 0;
1233
+ }
1234
+ /** Terminate one of the user's sessions; its sid (null if unbound), or undefined if none was removed. */
1235
+ async terminateByIdReturningSid(userId, id) {
1150
1236
  const rows = await this.store.query(
1151
- `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
1237
+ `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING sid`,
1152
1238
  [id, userId]
1153
1239
  );
1154
- return rows.length > 0;
1240
+ return rows.length ? rows[0]?.sid ?? null : void 0;
1155
1241
  }
1156
1242
  // Terminate all of a user's sessions except the current one (by sid).
1157
1243
  // Returns the number of sessions removed.
1158
1244
  async terminateOthers(userId, keepSid) {
1245
+ return (await this.terminateOthersReturningSids(userId, keepSid)).length;
1246
+ }
1247
+ /** Terminate all of the user's sessions but `keepSid`; the sids removed. */
1248
+ async terminateOthersReturningSids(userId, keepSid) {
1159
1249
  const rows = await this.store.query(
1160
1250
  `DELETE FROM fonderie_sessions
1161
1251
  WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
1162
- RETURNING id`,
1252
+ RETURNING sid`,
1163
1253
  [userId, keepSid]
1164
1254
  );
1165
- return rows.length;
1255
+ return rows.map((r) => r.sid);
1166
1256
  }
1167
1257
  async exists(token) {
1168
1258
  const rows = await this.store.query(
@@ -1181,7 +1271,8 @@ var SessionModel = class {
1181
1271
  }
1182
1272
  };
1183
1273
  function toRow(r) {
1184
- return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token, createdAt: r.created_at ? new Date(r.created_at) : /* @__PURE__ */ new Date() };
1274
+ const kind = CLIENT_KINDS.includes(r.client_kind ?? "") ? r.client_kind : null;
1275
+ return { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token, createdAt: r.created_at ? new Date(r.created_at) : /* @__PURE__ */ new Date(), clientKind: kind };
1185
1276
  }
1186
1277
 
1187
1278
  // src/models/backup-code.model.ts
@@ -1312,7 +1403,8 @@ function requestMeta(ctx) {
1312
1403
  const meta = {
1313
1404
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1314
1405
  userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1315
- headers: ctx.request.headers
1406
+ headers: ctx.request.headers,
1407
+ clientKind: clientKindOf(ctx.request.headers)
1316
1408
  };
1317
1409
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1318
1410
  return meta;
@@ -1427,7 +1519,7 @@ function mfaController(store, config, issuer, bus) {
1427
1519
  await users.enableMfa(ctx.user.id);
1428
1520
  }
1429
1521
  }
1430
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
1522
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1431
1523
  loginMethod: ctx.user.loginMethod,
1432
1524
  phoneVerified: ctx.user.phoneVerified
1433
1525
  });
@@ -1789,7 +1881,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1789
1881
  },
1790
1882
  reqOpts
1791
1883
  ));
1792
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1884
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1793
1885
  loginMethod: "email"
1794
1886
  });
1795
1887
  const registerMeta = requestMeta(ctx);
@@ -1932,7 +2024,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1932
2024
  mfaToken
1933
2025
  });
1934
2026
  }
1935
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
2027
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1936
2028
  loginMethod: "email"
1937
2029
  });
1938
2030
  await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -2025,8 +2117,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2025
2117
  if (!payload || payload.type !== "refresh") {
2026
2118
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2027
2119
  }
2028
- const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
2029
- const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2120
+ const runtime = config.resolve?.(ctx);
2030
2121
  let issued = null;
2031
2122
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2032
2123
  const found = await sessions.match(token);
@@ -2036,10 +2127,12 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2036
2127
  if (found.kind === "reused") {
2037
2128
  await sessions.revokeById(found.row.id);
2038
2129
  await (0, import_core6.background)(
2039
- bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sessionId: found.row.id, reason: "refresh-reuse" })
2130
+ bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
2040
2131
  );
2041
2132
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2042
2133
  }
2134
+ const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2135
+ const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2043
2136
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2044
2137
  await sessions.revokeById(found.row.id);
2045
2138
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
@@ -2221,7 +2314,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2221
2314
  );
2222
2315
  }
2223
2316
  await phoneVerif.deleteByUser(ctx.user.id);
2224
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
2317
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2225
2318
  loginMethod: "phone",
2226
2319
  phoneVerified: true
2227
2320
  });
@@ -2398,6 +2491,7 @@ function toSessionDTO(row, currentSid) {
2398
2491
  ipAddress: row.ipAddress,
2399
2492
  userAgent: row.userAgent,
2400
2493
  location: sanitizeLocation(row.location),
2494
+ clientKind: row.clientKind ?? null,
2401
2495
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2402
2496
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2403
2497
  };
@@ -2471,10 +2565,11 @@ function userController(store, config, bus) {
2471
2565
  if (!id) {
2472
2566
  return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2473
2567
  }
2474
- const removed = await sessions.terminateById(ctx.user.id, id);
2475
- if (!removed) {
2568
+ const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
2569
+ if (removed === void 0) {
2476
2570
  return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2477
2571
  }
2572
+ await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2478
2573
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2479
2574
  },
2480
2575
  terminateOtherSessions: async (ctx) => {
@@ -2486,7 +2581,9 @@ function userController(store, config, bus) {
2486
2581
  "Cannot terminate others without a current session context"
2487
2582
  );
2488
2583
  }
2489
- const count = await sessions.terminateOthers(ctx.user.id, currentSid);
2584
+ const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
2585
+ const count = sids.length;
2586
+ if (count) await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2490
2587
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2491
2588
  },
2492
2589
  updateProfile: async (ctx) => {
@@ -2648,6 +2745,7 @@ function userController(store, config, bus) {
2648
2745
  const hash = await hashPassword2(newPassword);
2649
2746
  await users.updatePassword(ctx.user.id, hash);
2650
2747
  await sessions.deleteByUser(ctx.user.id);
2748
+ await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
2651
2749
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2652
2750
  },
2653
2751
  /**
@@ -2946,7 +3044,7 @@ function oauthController(store, config, bus) {
2946
3044
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2947
3045
  }
2948
3046
  await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2949
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
3047
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
2950
3048
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2951
3049
  await loginEvents.recordSafe({
2952
3050
  userId: upserted.id,
@@ -3097,7 +3195,7 @@ function oauthController(store, config, bus) {
3097
3195
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3098
3196
  }
3099
3197
  await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3100
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
3198
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
3101
3199
  loginMethod: "google"
3102
3200
  });
3103
3201
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -3542,6 +3640,7 @@ function extractToken(request) {
3542
3640
 
3543
3641
  // src/admin.ts
3544
3642
  var import_core12 = require("@fonderie/core");
3643
+ var import_core13 = require("@fonderie/core");
3545
3644
  function toAdminUserDTO(user) {
3546
3645
  return {
3547
3646
  ...toUserDTO(user),
@@ -3558,7 +3657,7 @@ function toAdminUserPageDTO(page) {
3558
3657
  var ADMIN_PREFIX = "/_admin";
3559
3658
  var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
3560
3659
  var NOT_FOUND = () => (0, import_core12.setApiResponse)(import_core12.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
3561
- function adminRouteTable(store) {
3660
+ function adminRouteTable(store, bus) {
3562
3661
  const users = new UserModel(store);
3563
3662
  const sessions = new SessionModel(store);
3564
3663
  const events = new LoginEventModel(store);
@@ -3631,6 +3730,7 @@ function adminRouteTable(store) {
3631
3730
  async (ctx) => {
3632
3731
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3633
3732
  await sessions.deleteByUser(idOf(ctx));
3733
+ await (0, import_core13.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3634
3734
  return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
3635
3735
  }
3636
3736
  ],
@@ -3663,8 +3763,8 @@ function adminRouteTable(store) {
3663
3763
  ["POST", "/_admin/users/:id/unsuspend", setSuspended(false)]
3664
3764
  ];
3665
3765
  }
3666
- function describeAuthAdminRoutes(store) {
3667
- return adminRouteTable(store).map(([method, path, h]) => ({
3766
+ function describeAuthAdminRoutes(store, bus) {
3767
+ return adminRouteTable(store, bus).map(([method, path, h]) => ({
3668
3768
  method,
3669
3769
  path: path.slice(ADMIN_PREFIX.length),
3670
3770
  handlers: [h]
@@ -3685,10 +3785,22 @@ var AuthModule = class {
3685
3785
  name = "@fonderie/auth";
3686
3786
  // Baked in at build time by tsup.base, so the operator's Modules page can
3687
3787
  // say what is actually deployed rather than 'not reported'.
3688
- version = "7.17.1";
3788
+ version = "7.19.0";
3689
3789
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3690
3790
  describeAdmin() {
3691
- return { routes: describeAuthAdminRoutes(this.store) };
3791
+ return { routes: describeAuthAdminRoutes(this.store, this.bus) };
3792
+ }
3793
+ // Live sign-out (docs/SESSION-DESIGN.md, Phase 5): a revoked device hears it
3794
+ // at once over @fonderie/sse — only the user it is about, ids only.
3795
+ describeEvents() {
3796
+ const revoked = {
3797
+ type: EVENT_KEYS.sessionRevoked,
3798
+ description: "Some of your sessions were signed out \u2014 sign out if this device is one of them",
3799
+ audience: "user",
3800
+ scope: (p) => ({ userId: p.userId }),
3801
+ project: (p) => ({ sids: p.sids, reason: p.reason })
3802
+ };
3803
+ return [revoked];
3692
3804
  }
3693
3805
  // Report config problems for app.checkProductionReadiness() (data, not throw).
3694
3806
  checkReadiness() {
@@ -3704,7 +3816,7 @@ var AuthModule = class {
3704
3816
  };
3705
3817
 
3706
3818
  // src/templates.ts
3707
- var import_core13 = require("@fonderie/core");
3819
+ var import_core14 = require("@fonderie/core");
3708
3820
 
3709
3821
  // src/templates.es.ts
3710
3822
  var ES_TEMPLATES = {
@@ -4181,7 +4293,7 @@ change your password \u2014 someone else may be able to sign in as you.`
4181
4293
  If you did this, you're all set. If not, contact support right away.`
4182
4294
  }
4183
4295
  };
4184
- var DEFAULT_TEMPLATES = (0, import_core13.withTranslations)(EN_TEMPLATES, { fr: FR_TEMPLATES, es: ES_TEMPLATES });
4296
+ var DEFAULT_TEMPLATES = (0, import_core14.withTranslations)(EN_TEMPLATES, { fr: FR_TEMPLATES, es: ES_TEMPLATES });
4185
4297
  var SAMPLE_PAYLOADS = {
4186
4298
  [MESSAGE_KEYS.passwordRevoked]: { provider: "Google" },
4187
4299
  [MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },
@@ -4281,19 +4393,25 @@ function startUserRetention(store, options) {
4281
4393
  0 && (module.exports = {
4282
4394
  AUTH_CONFIG_KEYS,
4283
4395
  AuthModule,
4396
+ CLIENT_KINDS,
4284
4397
  DEFAULT_TEMPLATES,
4285
4398
  MESSAGE_KEYS,
4399
+ SESSION_POLICY_PRESETS,
4286
4400
  buildAuthAccountLimiter,
4287
4401
  buildAuthIpLimiter,
4402
+ clientKindOf,
4403
+ configForClient,
4288
4404
  describeAuthAdminRoutes,
4289
4405
  importUser,
4290
4406
  normalizeEmail,
4291
4407
  normalizeEmailSafe,
4292
4408
  purgeSoftDeletedUsers,
4409
+ readAuthRuntimeConfig,
4293
4410
  requireAuth,
4294
4411
  resolveLocation,
4295
4412
  sanitizeLocation,
4296
4413
  schemas,
4414
+ sessionPolicyConfigKeys,
4297
4415
  startUserRetention,
4298
4416
  toAdminUserDTO,
4299
4417
  toAdminUserPageDTO,