@fonderie/auth 7.11.1 → 7.12.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
@@ -138,8 +138,8 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
138
138
 
139
139
  | Method | Path | Middleware chain (auth / validation / handler) |
140
140
  |---|---|---|
141
- | 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 page = await users.list({ limit, ...(cursor ? { cursor } : {}) }); return setApiResponse(HTTP.OK, 'USERS', 'Users', toAdminUserPageDTO(page)); }` |
142
- | GET | `/_admin/users/:id` | `async (ctx) => { const user = await users.findById(idOf(ctx)); return user ? setApiResponse(HTTP.OK, 'USER', 'User', toAdminUserDTO(user)) : NOT_FOUND(); }` |
141
+ | 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)); }` |
142
+ | 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(); }` |
143
143
  | 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), ); }` |
144
144
  | 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'); }` |
145
145
  | 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)), ); }` |
@@ -213,12 +213,15 @@ function normalizeEmailSafe(email: string): string | null
213
213
 
214
214
  function importUser(store: IStoreAdapter, user: IImportUser): Promise<{ id: string; }>
215
215
 
216
- function purgeSoftDeletedUsers(store: IStoreAdapter, { olderThanDays }: IPurgeOptions): Promise<number>
216
+ function purgeSoftDeletedUsers(store: IStoreAdapter, { olderThanDays, bus }: IPurgeOptions): Promise<number>
217
217
 
218
218
  function startUserRetention(store: IStoreAdapter, options: IUserRetentionScheduleOptions): { stop: () => void; }
219
219
 
220
220
  interface IPurgeOptions {
221
221
  olderThanDays: number;
222
+ bus?: {
223
+ emit(type: string, payload: unknown): Promise<void>;
224
+ } | undefined;
222
225
  }
223
226
 
224
227
  interface IUserRetentionScheduleOptions extends IPurgeOptions {
package/dist/index.cjs CHANGED
@@ -320,6 +320,8 @@ var MESSAGE_KEYS = {
320
320
  var EVENT_KEYS = {
321
321
  userRegistered: "fonderie.user.registered",
322
322
  userDeleted: "fonderie.user.deleted",
323
+ // Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
324
+ userPurged: "fonderie.user.purged",
323
325
  emailVerified: "fonderie.user.email_verified",
324
326
  passwordChanged: "fonderie.user.password_changed"
325
327
  };
@@ -593,7 +595,7 @@ var UserModel = class {
593
595
  async list(query = {}) {
594
596
  const limit = Math.min(query.limit ?? 50, MAX_LIST_LIMIT);
595
597
  const params = [];
596
- const where = ["deleted_at IS NULL"];
598
+ const where = [query.deleted ? "deleted_at IS NOT NULL" : "deleted_at IS NULL"];
597
599
  if (query.cursor) {
598
600
  params.push(query.cursor.createdAt, query.cursor.id);
599
601
  where.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
@@ -609,6 +611,16 @@ var UserModel = class {
609
611
  );
610
612
  return { users: rows.slice(0, limit), hasMore: rows.length > limit };
611
613
  }
614
+ // For operator views only: a soft-deleted account still resolves, with
615
+ // deletedAt set, so the console can say "deleted on …" instead of "no such
616
+ // user". Nothing that authenticates or acts on an account may use this.
617
+ async findByIdIncludingDeleted(id) {
618
+ const [row] = await this.store.query(
619
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1`,
620
+ [id]
621
+ );
622
+ return row ?? null;
623
+ }
612
624
  async findById(id) {
613
625
  const [row] = await this.store.query(
614
626
  `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,
@@ -3426,7 +3438,12 @@ function adminRouteTable(store) {
3426
3438
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3427
3439
  if (cursorParam && !cursor)
3428
3440
  return (0, import_core12.setApiResponse)(import_core12.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3429
- const page = await users.list({ limit, ...cursor ? { cursor } : {} });
3441
+ const deleted = params.get("deleted") === "1" || params.get("deleted") === "true";
3442
+ const page = await users.list({
3443
+ limit,
3444
+ ...cursor ? { cursor } : {},
3445
+ ...deleted ? { deleted } : {}
3446
+ });
3430
3447
  return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USERS", "Users", toAdminUserPageDTO(page));
3431
3448
  }
3432
3449
  ],
@@ -3434,7 +3451,7 @@ function adminRouteTable(store) {
3434
3451
  "GET",
3435
3452
  "/_admin/users/:id",
3436
3453
  async (ctx) => {
3437
- const user = await users.findById(idOf(ctx));
3454
+ const user = await users.findByIdIncludingDeleted(idOf(ctx));
3438
3455
  return user ? (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3439
3456
  }
3440
3457
  ],
@@ -3513,7 +3530,7 @@ var AuthModule = class {
3513
3530
  name = "@fonderie/auth";
3514
3531
  // Baked in at build time by tsup.base, so the operator's Modules page can
3515
3532
  // say what is actually deployed rather than 'not reported'.
3516
- version = "7.11.1";
3533
+ version = "7.12.0";
3517
3534
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3518
3535
  describeAdmin() {
3519
3536
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -3744,7 +3761,7 @@ async function importUser(store, user) {
3744
3761
  }
3745
3762
 
3746
3763
  // src/services/retention.ts
3747
- async function purgeSoftDeletedUsers(store, { olderThanDays }) {
3764
+ async function purgeSoftDeletedUsers(store, { olderThanDays, bus }) {
3748
3765
  if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
3749
3766
  throw new Error("[auth] purgeSoftDeletedUsers: olderThanDays must be a non-negative number");
3750
3767
  }
@@ -3755,6 +3772,9 @@ async function purgeSoftDeletedUsers(store, { olderThanDays }) {
3755
3772
  RETURNING id`,
3756
3773
  [olderThanDays]
3757
3774
  );
3775
+ if (bus) {
3776
+ for (const { id } of rows) await bus.emit("fonderie.user.purged", { userId: id });
3777
+ }
3758
3778
  return rows.length;
3759
3779
  }
3760
3780
  function startUserRetention(store, options) {
@@ -3763,7 +3783,10 @@ function startUserRetention(store, options) {
3763
3783
  const run = async () => {
3764
3784
  if (stopped) return;
3765
3785
  try {
3766
- const deleted = await purgeSoftDeletedUsers(store, { olderThanDays: options.olderThanDays });
3786
+ const deleted = await purgeSoftDeletedUsers(store, {
3787
+ olderThanDays: options.olderThanDays,
3788
+ ...options.bus ? { bus: options.bus } : {}
3789
+ });
3767
3790
  options.onPurge?.(deleted);
3768
3791
  } catch (err) {
3769
3792
  console.error("[auth] scheduled user retention purge failed:", err);