@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/dist/index.d.cts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { IUser } from './types.cjs';
2
2
  export { IMfaChallenge, ISession } from './types.cjs';
3
- import { IFonderieModule, IAdminDescription, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
3
+ import { IFonderieModule, IAdminDescription, IEventCatalogEntry, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig, a as IRequestLocation } from './session-C-ptRylj.cjs';
7
- export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, d as IAuthRateLimitConfig, e as IAuthRuntimeConfig, f as IAuthSecrets, g as IDataExportContributor, h as ILocationRequest, L as LocationResolver, M as MESSAGE_KEYS, i as buildAuthAccountLimiter, j as buildAuthIpLimiter, r as resolveLocation, s as sanitizeLocation, w as withSession } from './session-C-ptRylj.cjs';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-B-IgXFu8.cjs';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-B-IgXFu8.cjs';
8
8
  import { IUserDTO } from './dtos/user.cjs';
9
9
  export { toUserDTO } from './dtos/user.cjs';
10
10
  export { requireAuth, validate } from '@fonderie/core/middlewares';
@@ -19,6 +19,7 @@ declare class AuthModule implements IFonderieModule {
19
19
  readonly version: string;
20
20
  constructor(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undefined);
21
21
  describeAdmin(): IAdminDescription;
22
+ describeEvents(): IEventCatalogEntry[];
22
23
  checkReadiness(): IReadinessProblem[];
23
24
  install(app: IFonderieApp): void;
24
25
  }
@@ -50,6 +51,8 @@ interface ISessionDTO {
50
51
  location: IRequestLocation | null;
51
52
  createdAt: string;
52
53
  expiresAt: string;
54
+ /** The platform declared at sign-in (mobile | desktop | web), or null. */
55
+ clientKind: string | null;
53
56
  }
54
57
 
55
58
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
@@ -201,6 +204,6 @@ interface IAdminUserPageDTO {
201
204
  nextCursor: string | null;
202
205
  }
203
206
  declare function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO;
204
- declare function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[];
207
+ declare function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus): IAdminRoute[];
205
208
 
206
209
  export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, IRequestLocation, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { IUser } from './types.js';
2
2
  export { IMfaChallenge, ISession } from './types.js';
3
- import { IFonderieModule, IAdminDescription, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
3
+ import { IFonderieModule, IAdminDescription, IEventCatalogEntry, IReadinessProblem, IFonderieApp, IDefaultTemplate, IAdminRoute } from '@fonderie/core';
4
4
  import { IStoreAdapter } from '@fonderie/store';
5
5
  import { EventBus } from '@fonderie/events';
6
- import { I as IAuthConfig, a as IRequestLocation } from './session-C-ptRylj.js';
7
- export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, d as IAuthRateLimitConfig, e as IAuthRuntimeConfig, f as IAuthSecrets, g as IDataExportContributor, h as ILocationRequest, L as LocationResolver, M as MESSAGE_KEYS, i as buildAuthAccountLimiter, j as buildAuthIpLimiter, r as resolveLocation, s as sanitizeLocation, w as withSession } from './session-C-ptRylj.js';
6
+ import { I as IAuthConfig, a as IRequestLocation } from './session-B-IgXFu8.js';
7
+ export { A as AUTH_CONFIG_KEYS, b as AuthLimitedRoute, c as AuthMessageKey, C as CLIENT_KINDS, d as ClientKind, e as IAuthRateLimitConfig, f as IAuthRuntimeConfig, g as IAuthSecrets, h as IDataExportContributor, i as ILocationRequest, j as ISessionPolicy, L as LocationResolver, M as MESSAGE_KEYS, S as SESSION_POLICY_PRESETS, k as buildAuthAccountLimiter, l as buildAuthIpLimiter, m as clientKindOf, n as configForClient, r as readAuthRuntimeConfig, o as resolveLocation, s as sanitizeLocation, p as sessionPolicyConfigKeys, w as withSession } from './session-B-IgXFu8.js';
8
8
  import { IUserDTO } from './dtos/user.js';
9
9
  export { toUserDTO } from './dtos/user.js';
10
10
  export { requireAuth, validate } from '@fonderie/core/middlewares';
@@ -19,6 +19,7 @@ declare class AuthModule implements IFonderieModule {
19
19
  readonly version: string;
20
20
  constructor(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undefined);
21
21
  describeAdmin(): IAdminDescription;
22
+ describeEvents(): IEventCatalogEntry[];
22
23
  checkReadiness(): IReadinessProblem[];
23
24
  install(app: IFonderieApp): void;
24
25
  }
@@ -50,6 +51,8 @@ interface ISessionDTO {
50
51
  location: IRequestLocation | null;
51
52
  createdAt: string;
52
53
  expiresAt: string;
54
+ /** The platform declared at sign-in (mobile | desktop | web), or null. */
55
+ clientKind: string | null;
53
56
  }
54
57
 
55
58
  declare const registerSchema: z.ZodUnion<readonly [z.ZodObject<{
@@ -201,6 +204,6 @@ interface IAdminUserPageDTO {
201
204
  nextCursor: string | null;
202
205
  }
203
206
  declare function toAdminUserPageDTO(page: IUserPage): IAdminUserPageDTO;
204
- declare function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[];
207
+ declare function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus): IAdminRoute[];
205
208
 
206
209
  export { AuthModule, type ChangePasswordInput, DEFAULT_TEMPLATES, type IAdminUserDTO, IAuthConfig, type IImportUser, type ILoginEventDTO, type ILoginHistoryPageDTO, type IPurgeOptions, IRequestLocation, type ISessionDTO, IUser, IUserDTO, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, describeAuthAdminRoutes, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, toAdminUserDTO, toAdminUserPageDTO, validateAuthConfig };
package/dist/index.js CHANGED
@@ -284,8 +284,10 @@ var EVENT_KEYS = {
284
284
  userPurged: "fonderie.user.purged",
285
285
  emailVerified: "fonderie.user.email_verified",
286
286
  passwordChanged: "fonderie.user.password_changed",
287
- // A session the server revoked — today: a refresh token reused after its
288
- // grace (theft signal). { userId, sessionId, reason }.
287
+ // Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
288
+ // the user's devices live (@fonderie/sse) so a revoked device signs out at
289
+ // once. Reasons: 'terminated' (signed out from the devices list),
290
+ // 'password-changed', 'admin', 'refresh-reuse' (theft signal).
289
291
  sessionRevoked: "fonderie.session.revoked"
290
292
  };
291
293
 
@@ -368,6 +370,79 @@ function validDuration(value, fallback, name) {
368
370
  return fallback;
369
371
  }
370
372
 
373
+ // src/services/session-policy.ts
374
+ var CLIENT_KINDS = ["mobile", "desktop", "web"];
375
+ var SESSION_POLICY_PRESETS = {
376
+ // Personal device; the token lives in the Keychain / Keystore.
377
+ mobile: { sessionDuration: "90d", sessionMaxAge: "365d" },
378
+ // Personal, but often a shared workstation.
379
+ desktop: { sessionDuration: "30d", sessionMaxAge: "180d" },
380
+ // Shared and public computers; cookies.
381
+ web: { sessionDuration: "14d", sessionMaxAge: "90d" }
382
+ };
383
+ function clientKindOf(headers) {
384
+ const v = headers.get("x-client-kind")?.trim().toLowerCase();
385
+ return CLIENT_KINDS.includes(v ?? "") ? v : null;
386
+ }
387
+ var FIELDS = ["sessionDuration", "sessionMaxAge", "accessTokenDuration"];
388
+ function configForClient(config, runtime, kind) {
389
+ const rt = runtime ?? {};
390
+ const code = config;
391
+ const out = { ...config, ...rt };
392
+ for (const field of FIELDS) {
393
+ 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);
394
+ if (value === void 0) delete out[field];
395
+ else out[field] = value;
396
+ }
397
+ return out;
398
+ }
399
+ function sessionPolicyConfigKeys(kind) {
400
+ return {
401
+ sessionDuration: `auth.session.duration.${kind}`,
402
+ sessionMaxAge: `auth.session.max_age.${kind}`,
403
+ accessTokenDuration: `auth.access.duration.${kind}`
404
+ };
405
+ }
406
+ function readAuthRuntimeConfig(read) {
407
+ const text = (key) => {
408
+ const v = read(key);
409
+ return typeof v === "string" && v.trim() ? v.trim() : void 0;
410
+ };
411
+ const bool2 = (key) => {
412
+ const v = read(key);
413
+ if (typeof v === "boolean") return v;
414
+ if (typeof v === "string" && ["true", "false"].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === "true";
415
+ return void 0;
416
+ };
417
+ const num = (key) => {
418
+ const v = read(key);
419
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : NaN;
420
+ return Number.isFinite(n) ? n : void 0;
421
+ };
422
+ const out = {};
423
+ const set = (k, v) => {
424
+ if (v !== void 0) out[k] = v;
425
+ };
426
+ set("sessionDuration", text("auth.session.duration"));
427
+ set("sessionMaxAge", text("auth.session.max_age"));
428
+ set("accessTokenDuration", text("auth.access.duration"));
429
+ set("verificationCooldown", num("auth.verification.cooldown"));
430
+ set("mfa", bool2("auth.mfa.enabled"));
431
+ set("requireVerification", bool2("auth.verification.required"));
432
+ const policies = {};
433
+ for (const kind of CLIENT_KINDS) {
434
+ const keys = sessionPolicyConfigKeys(kind);
435
+ const policy = {};
436
+ for (const field of FIELDS) {
437
+ const v = text(keys[field]);
438
+ if (v !== void 0) policy[field] = v;
439
+ }
440
+ if (Object.keys(policy).length) policies[kind] = policy;
441
+ }
442
+ if (Object.keys(policies).length) out.sessionPolicies = policies;
443
+ return out;
444
+ }
445
+
371
446
  // src/services/mfa.ts
372
447
  import { createHmac, randomBytes } from "crypto";
373
448
  import { constantTimeEqual } from "@fonderie/core";
@@ -991,8 +1066,8 @@ var SessionModel = class {
991
1066
  async create(userId, token, expiresAt, sid, meta) {
992
1067
  const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
993
1068
  await this.store.query(
994
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at)
995
- VALUES ($1, $2, $3, $4, $5, $6, $7, now())
1069
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)
1070
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)
996
1071
  ON CONFLICT (token) DO NOTHING`,
997
1072
  [
998
1073
  userId,
@@ -1001,7 +1076,8 @@ var SessionModel = class {
1001
1076
  sid ?? null,
1002
1077
  meta?.userAgent ?? null,
1003
1078
  meta?.ipAddress ?? null,
1004
- location ? JSON.stringify(location) : null
1079
+ location ? JSON.stringify(location) : null,
1080
+ meta?.clientKind ?? null
1005
1081
  ]
1006
1082
  );
1007
1083
  }
@@ -1016,12 +1092,12 @@ var SessionModel = class {
1016
1092
  async match(token) {
1017
1093
  const hash = hashRefreshToken(token);
1018
1094
  const [current] = await this.store.query(
1019
- `SELECT id, user_id, sid, token, created_at FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1095
+ `SELECT id, user_id, sid, token, created_at, client_kind FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,
1020
1096
  [hash, token]
1021
1097
  );
1022
1098
  if (current) return { kind: "current", row: toRow(current) };
1023
1099
  const [previous] = await this.store.query(
1024
- `SELECT id, user_id, sid, token, created_at, (previous_valid_until > now()) AS in_grace
1100
+ `SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace
1025
1101
  FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,
1026
1102
  [hash]
1027
1103
  );
@@ -1087,7 +1163,7 @@ var SessionModel = class {
1087
1163
  // No token — that never leaves the server.
1088
1164
  async listLiveByUser(userId) {
1089
1165
  return this.store.query(
1090
- `SELECT id, sid,
1166
+ `SELECT id, sid, client_kind AS "clientKind",
1091
1167
  user_agent AS "userAgent",
1092
1168
  ip_address AS "ipAddress",
1093
1169
  location,
@@ -1102,22 +1178,30 @@ var SessionModel = class {
1102
1178
  // Terminate one session by row id, scoped to its owner (a user can never
1103
1179
  // delete another user's session). Returns whether a row was removed.
1104
1180
  async terminateById(userId, id) {
1181
+ return await this.terminateByIdReturningSid(userId, id) !== void 0;
1182
+ }
1183
+ /** Terminate one of the user's sessions; its sid (null if unbound), or undefined if none was removed. */
1184
+ async terminateByIdReturningSid(userId, id) {
1105
1185
  const rows = await this.store.query(
1106
- `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,
1186
+ `DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING sid`,
1107
1187
  [id, userId]
1108
1188
  );
1109
- return rows.length > 0;
1189
+ return rows.length ? rows[0]?.sid ?? null : void 0;
1110
1190
  }
1111
1191
  // Terminate all of a user's sessions except the current one (by sid).
1112
1192
  // Returns the number of sessions removed.
1113
1193
  async terminateOthers(userId, keepSid) {
1194
+ return (await this.terminateOthersReturningSids(userId, keepSid)).length;
1195
+ }
1196
+ /** Terminate all of the user's sessions but `keepSid`; the sids removed. */
1197
+ async terminateOthersReturningSids(userId, keepSid) {
1114
1198
  const rows = await this.store.query(
1115
1199
  `DELETE FROM fonderie_sessions
1116
1200
  WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
1117
- RETURNING id`,
1201
+ RETURNING sid`,
1118
1202
  [userId, keepSid]
1119
1203
  );
1120
- return rows.length;
1204
+ return rows.map((r) => r.sid);
1121
1205
  }
1122
1206
  async exists(token) {
1123
1207
  const rows = await this.store.query(
@@ -1136,7 +1220,8 @@ var SessionModel = class {
1136
1220
  }
1137
1221
  };
1138
1222
  function toRow(r) {
1139
- 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() };
1223
+ const kind = CLIENT_KINDS.includes(r.client_kind ?? "") ? r.client_kind : null;
1224
+ 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 };
1140
1225
  }
1141
1226
 
1142
1227
  // src/models/backup-code.model.ts
@@ -1267,7 +1352,8 @@ function requestMeta(ctx) {
1267
1352
  const meta = {
1268
1353
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1269
1354
  userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1270
- headers: ctx.request.headers
1355
+ headers: ctx.request.headers,
1356
+ clientKind: clientKindOf(ctx.request.headers)
1271
1357
  };
1272
1358
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1273
1359
  return meta;
@@ -1382,7 +1468,7 @@ function mfaController(store, config, issuer, bus) {
1382
1468
  await users.enableMfa(ctx.user.id);
1383
1469
  }
1384
1470
  }
1385
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
1471
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1386
1472
  loginMethod: ctx.user.loginMethod,
1387
1473
  phoneVerified: ctx.user.phoneVerified
1388
1474
  });
@@ -1744,7 +1830,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1744
1830
  },
1745
1831
  reqOpts
1746
1832
  ));
1747
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1833
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1748
1834
  loginMethod: "email"
1749
1835
  });
1750
1836
  const registerMeta = requestMeta(ctx);
@@ -1887,7 +1973,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1887
1973
  mfaToken
1888
1974
  });
1889
1975
  }
1890
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1976
+ const { accessToken, refreshToken, sid } = issueTokenPair(user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
1891
1977
  loginMethod: "email"
1892
1978
  });
1893
1979
  await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -1980,8 +2066,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1980
2066
  if (!payload || payload.type !== "refresh") {
1981
2067
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
1982
2068
  }
1983
- const resolvedRefresh = { ...config, ...config.resolve?.(ctx) };
1984
- const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2069
+ const runtime = config.resolve?.(ctx);
1985
2070
  let issued = null;
1986
2071
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
1987
2072
  const found = await sessions.match(token);
@@ -1991,10 +2076,12 @@ function authController(store, config, bus, locales = defineLocales()) {
1991
2076
  if (found.kind === "reused") {
1992
2077
  await sessions.revokeById(found.row.id);
1993
2078
  await background3(
1994
- bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sessionId: found.row.id, reason: "refresh-reuse" })
2079
+ bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
1995
2080
  );
1996
2081
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
1997
2082
  }
2083
+ const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2084
+ const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
1998
2085
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
1999
2086
  await sessions.revokeById(found.row.id);
2000
2087
  return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
@@ -2176,7 +2263,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2176
2263
  );
2177
2264
  }
2178
2265
  await phoneVerif.deleteByUser(ctx.user.id);
2179
- const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
2266
+ const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2180
2267
  loginMethod: "phone",
2181
2268
  phoneVerified: true
2182
2269
  });
@@ -2353,6 +2440,7 @@ function toSessionDTO(row, currentSid) {
2353
2440
  ipAddress: row.ipAddress,
2354
2441
  userAgent: row.userAgent,
2355
2442
  location: sanitizeLocation(row.location),
2443
+ clientKind: row.clientKind ?? null,
2356
2444
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2357
2445
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2358
2446
  };
@@ -2426,10 +2514,11 @@ function userController(store, config, bus) {
2426
2514
  if (!id) {
2427
2515
  return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
2428
2516
  }
2429
- const removed = await sessions.terminateById(ctx.user.id, id);
2430
- if (!removed) {
2517
+ const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
2518
+ if (removed === void 0) {
2431
2519
  return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
2432
2520
  }
2521
+ await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2433
2522
  return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2434
2523
  },
2435
2524
  terminateOtherSessions: async (ctx) => {
@@ -2441,7 +2530,9 @@ function userController(store, config, bus) {
2441
2530
  "Cannot terminate others without a current session context"
2442
2531
  );
2443
2532
  }
2444
- const count = await sessions.terminateOthers(ctx.user.id, currentSid);
2533
+ const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
2534
+ const count = sids.length;
2535
+ if (count) await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2445
2536
  return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2446
2537
  },
2447
2538
  updateProfile: async (ctx) => {
@@ -2603,6 +2694,7 @@ function userController(store, config, bus) {
2603
2694
  const hash = await hashPassword2(newPassword);
2604
2695
  await users.updatePassword(ctx.user.id, hash);
2605
2696
  await sessions.deleteByUser(ctx.user.id);
2697
+ await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
2606
2698
  return setApiResponse4(HTTP4.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2607
2699
  },
2608
2700
  /**
@@ -2901,7 +2993,7 @@ function oauthController(store, config, bus) {
2901
2993
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2902
2994
  }
2903
2995
  await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2904
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2996
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), { loginMethod: "apple" });
2905
2997
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2906
2998
  await loginEvents.recordSafe({
2907
2999
  userId: upserted.id,
@@ -3052,7 +3144,7 @@ function oauthController(store, config, bus) {
3052
3144
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
3053
3145
  }
3054
3146
  await announceOAuthUpsert(ctx, upserted, "google", fullUser);
3055
- const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
3147
+ const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
3056
3148
  loginMethod: "google"
3057
3149
  });
3058
3150
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
@@ -3497,6 +3589,7 @@ function extractToken(request) {
3497
3589
 
3498
3590
  // src/admin.ts
3499
3591
  import { HTTP as HTTP7, encodeKeysetCursor as encodeKeysetCursor2, setApiResponse as setApiResponse7 } from "@fonderie/core";
3592
+ import { background as background6 } from "@fonderie/core";
3500
3593
  function toAdminUserDTO(user) {
3501
3594
  return {
3502
3595
  ...toUserDTO(user),
@@ -3513,7 +3606,7 @@ function toAdminUserPageDTO(page) {
3513
3606
  var ADMIN_PREFIX = "/_admin";
3514
3607
  var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
3515
3608
  var NOT_FOUND = () => setApiResponse7(HTTP7.NOT_FOUND, "NOT_FOUND", "No such user");
3516
- function adminRouteTable(store) {
3609
+ function adminRouteTable(store, bus) {
3517
3610
  const users = new UserModel(store);
3518
3611
  const sessions = new SessionModel(store);
3519
3612
  const events = new LoginEventModel(store);
@@ -3586,6 +3679,7 @@ function adminRouteTable(store) {
3586
3679
  async (ctx) => {
3587
3680
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3588
3681
  await sessions.deleteByUser(idOf(ctx));
3682
+ await background6(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3589
3683
  return setApiResponse7(HTTP7.OK, "SESSIONS_REVOKED", "All sessions revoked");
3590
3684
  }
3591
3685
  ],
@@ -3618,8 +3712,8 @@ function adminRouteTable(store) {
3618
3712
  ["POST", "/_admin/users/:id/unsuspend", setSuspended(false)]
3619
3713
  ];
3620
3714
  }
3621
- function describeAuthAdminRoutes(store) {
3622
- return adminRouteTable(store).map(([method, path, h]) => ({
3715
+ function describeAuthAdminRoutes(store, bus) {
3716
+ return adminRouteTable(store, bus).map(([method, path, h]) => ({
3623
3717
  method,
3624
3718
  path: path.slice(ADMIN_PREFIX.length),
3625
3719
  handlers: [h]
@@ -3640,10 +3734,22 @@ var AuthModule = class {
3640
3734
  name = "@fonderie/auth";
3641
3735
  // Baked in at build time by tsup.base, so the operator's Modules page can
3642
3736
  // say what is actually deployed rather than 'not reported'.
3643
- version = "7.17.1";
3737
+ version = "7.19.0";
3644
3738
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3645
3739
  describeAdmin() {
3646
- return { routes: describeAuthAdminRoutes(this.store) };
3740
+ return { routes: describeAuthAdminRoutes(this.store, this.bus) };
3741
+ }
3742
+ // Live sign-out (docs/SESSION-DESIGN.md, Phase 5): a revoked device hears it
3743
+ // at once over @fonderie/sse — only the user it is about, ids only.
3744
+ describeEvents() {
3745
+ const revoked = {
3746
+ type: EVENT_KEYS.sessionRevoked,
3747
+ description: "Some of your sessions were signed out \u2014 sign out if this device is one of them",
3748
+ audience: "user",
3749
+ scope: (p) => ({ userId: p.userId }),
3750
+ project: (p) => ({ sids: p.sids, reason: p.reason })
3751
+ };
3752
+ return [revoked];
3647
3753
  }
3648
3754
  // Report config problems for app.checkProductionReadiness() (data, not throw).
3649
3755
  checkReadiness() {
@@ -4235,19 +4341,25 @@ function startUserRetention(store, options) {
4235
4341
  export {
4236
4342
  AUTH_CONFIG_KEYS,
4237
4343
  AuthModule,
4344
+ CLIENT_KINDS,
4238
4345
  DEFAULT_TEMPLATES,
4239
4346
  MESSAGE_KEYS,
4347
+ SESSION_POLICY_PRESETS,
4240
4348
  buildAuthAccountLimiter,
4241
4349
  buildAuthIpLimiter,
4350
+ clientKindOf,
4351
+ configForClient,
4242
4352
  describeAuthAdminRoutes,
4243
4353
  importUser,
4244
4354
  normalizeEmail,
4245
4355
  normalizeEmailSafe,
4246
4356
  purgeSoftDeletedUsers,
4357
+ readAuthRuntimeConfig,
4247
4358
  requireAuth2 as requireAuth,
4248
4359
  resolveLocation,
4249
4360
  sanitizeLocation,
4250
4361
  schemas_exports as schemas,
4362
+ sessionPolicyConfigKeys,
4251
4363
  startUserRetention,
4252
4364
  toAdminUserDTO,
4253
4365
  toAdminUserPageDTO,