@fonderie/auth 7.18.0 → 7.19.1

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
@@ -145,7 +145,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
145
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)); }` |
146
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(); }` |
147
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), ); }` |
148
- | 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'); }` |
149
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)), ); }` |
150
150
  | POST | `/_admin/users/:id/suspend` | `setSuspended(true)` |
151
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
 
@@ -267,7 +268,7 @@ interface IAdminUserDTO extends IUserDTO {
267
268
  createdAt: string;
268
269
  }
269
270
 
270
- function describeAuthAdminRoutes(store: IStoreAdapter): IAdminRoute[]
271
+ function describeAuthAdminRoutes(store: IStoreAdapter, bus?: EventBus | undefined): IAdminRoute[]
271
272
 
272
273
  function toAdminUserDTO(user: IUser): IAdminUserDTO
273
274
 
package/dist/index.cjs CHANGED
@@ -335,8 +335,10 @@ var EVENT_KEYS = {
335
335
  userPurged: "fonderie.user.purged",
336
336
  emailVerified: "fonderie.user.email_verified",
337
337
  passwordChanged: "fonderie.user.password_changed",
338
- // A session the server revoked — today: a refresh token reused after its
339
- // 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).
340
342
  sessionRevoked: "fonderie.session.revoked"
341
343
  };
342
344
 
@@ -1227,22 +1229,30 @@ var SessionModel = class {
1227
1229
  // Terminate one session by row id, scoped to its owner (a user can never
1228
1230
  // delete another user's session). Returns whether a row was removed.
1229
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) {
1230
1236
  const rows = await this.store.query(
1231
- `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`,
1232
1238
  [id, userId]
1233
1239
  );
1234
- return rows.length > 0;
1240
+ return rows.length ? rows[0]?.sid ?? null : void 0;
1235
1241
  }
1236
1242
  // Terminate all of a user's sessions except the current one (by sid).
1237
1243
  // Returns the number of sessions removed.
1238
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) {
1239
1249
  const rows = await this.store.query(
1240
1250
  `DELETE FROM fonderie_sessions
1241
1251
  WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)
1242
- RETURNING id`,
1252
+ RETURNING sid`,
1243
1253
  [userId, keepSid]
1244
1254
  );
1245
- return rows.length;
1255
+ return rows.map((r) => r.sid);
1246
1256
  }
1247
1257
  async exists(token) {
1248
1258
  const rows = await this.store.query(
@@ -2117,7 +2127,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2117
2127
  if (found.kind === "reused") {
2118
2128
  await sessions.revokeById(found.row.id);
2119
2129
  await (0, import_core6.background)(
2120
- 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" })
2121
2131
  );
2122
2132
  return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2123
2133
  }
@@ -2555,10 +2565,11 @@ function userController(store, config, bus) {
2555
2565
  if (!id) {
2556
2566
  return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2557
2567
  }
2558
- const removed = await sessions.terminateById(ctx.user.id, id);
2559
- if (!removed) {
2568
+ const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
2569
+ if (removed === void 0) {
2560
2570
  return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2561
2571
  }
2572
+ await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2562
2573
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2563
2574
  },
2564
2575
  terminateOtherSessions: async (ctx) => {
@@ -2570,7 +2581,9 @@ function userController(store, config, bus) {
2570
2581
  "Cannot terminate others without a current session context"
2571
2582
  );
2572
2583
  }
2573
- 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" }));
2574
2587
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2575
2588
  },
2576
2589
  updateProfile: async (ctx) => {
@@ -2732,6 +2745,7 @@ function userController(store, config, bus) {
2732
2745
  const hash = await hashPassword2(newPassword);
2733
2746
  await users.updatePassword(ctx.user.id, hash);
2734
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" }));
2735
2749
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2736
2750
  },
2737
2751
  /**
@@ -3626,6 +3640,7 @@ function extractToken(request) {
3626
3640
 
3627
3641
  // src/admin.ts
3628
3642
  var import_core12 = require("@fonderie/core");
3643
+ var import_core13 = require("@fonderie/core");
3629
3644
  function toAdminUserDTO(user) {
3630
3645
  return {
3631
3646
  ...toUserDTO(user),
@@ -3642,7 +3657,7 @@ function toAdminUserPageDTO(page) {
3642
3657
  var ADMIN_PREFIX = "/_admin";
3643
3658
  var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
3644
3659
  var NOT_FOUND = () => (0, import_core12.setApiResponse)(import_core12.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
3645
- function adminRouteTable(store) {
3660
+ function adminRouteTable(store, bus) {
3646
3661
  const users = new UserModel(store);
3647
3662
  const sessions = new SessionModel(store);
3648
3663
  const events = new LoginEventModel(store);
@@ -3715,6 +3730,7 @@ function adminRouteTable(store) {
3715
3730
  async (ctx) => {
3716
3731
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3717
3732
  await sessions.deleteByUser(idOf(ctx));
3733
+ await (0, import_core13.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3718
3734
  return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
3719
3735
  }
3720
3736
  ],
@@ -3747,8 +3763,8 @@ function adminRouteTable(store) {
3747
3763
  ["POST", "/_admin/users/:id/unsuspend", setSuspended(false)]
3748
3764
  ];
3749
3765
  }
3750
- function describeAuthAdminRoutes(store) {
3751
- return adminRouteTable(store).map(([method, path, h]) => ({
3766
+ function describeAuthAdminRoutes(store, bus) {
3767
+ return adminRouteTable(store, bus).map(([method, path, h]) => ({
3752
3768
  method,
3753
3769
  path: path.slice(ADMIN_PREFIX.length),
3754
3770
  handlers: [h]
@@ -3769,10 +3785,22 @@ var AuthModule = class {
3769
3785
  name = "@fonderie/auth";
3770
3786
  // Baked in at build time by tsup.base, so the operator's Modules page can
3771
3787
  // say what is actually deployed rather than 'not reported'.
3772
- version = "7.18.0";
3788
+ version = "7.19.1";
3773
3789
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3774
3790
  describeAdmin() {
3775
- 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];
3776
3804
  }
3777
3805
  // Report config problems for app.checkProductionReadiness() (data, not throw).
3778
3806
  checkReadiness() {
@@ -3788,7 +3816,7 @@ var AuthModule = class {
3788
3816
  };
3789
3817
 
3790
3818
  // src/templates.ts
3791
- var import_core13 = require("@fonderie/core");
3819
+ var import_core14 = require("@fonderie/core");
3792
3820
 
3793
3821
  // src/templates.es.ts
3794
3822
  var ES_TEMPLATES = {
@@ -4265,7 +4293,7 @@ change your password \u2014 someone else may be able to sign in as you.`
4265
4293
  If you did this, you're all set. If not, contact support right away.`
4266
4294
  }
4267
4295
  };
4268
- 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 });
4269
4297
  var SAMPLE_PAYLOADS = {
4270
4298
  [MESSAGE_KEYS.passwordRevoked]: { provider: "Google" },
4271
4299
  [MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },