@fonderie/auth 7.22.0 → 7.22.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
@@ -142,7 +142,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
142
142
 
143
143
  | Method | Path | Middleware chain (auth / validation / handler) |
144
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
+ | GET | `/_admin/users` | `async (ctx) => { const params = new URL(ctx.request.url).searchParams; // Accounts are stored under normalizeEmail: a search for the address a // user typed ('Jane+work@x.com') finds the account ('jane@x.com'). const raw = params.get('email')?.trim(); const email = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : undefined; 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
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'); }` |
package/dist/index.cjs CHANGED
@@ -125,6 +125,41 @@ var import_middlewares = require("@fonderie/core/middlewares");
125
125
 
126
126
  // src/services/rate-limit.ts
127
127
  var import_rate_limit = require("@fonderie/rate-limit");
128
+
129
+ // src/services/email.ts
130
+ function normalizeEmail(email2) {
131
+ if (typeof email2 !== "string" || email2.length === 0) {
132
+ throw new Error("Invalid email");
133
+ }
134
+ const lower = email2.trim().toLowerCase();
135
+ if (lower.length === 0) {
136
+ throw new Error("Email cannot be empty");
137
+ }
138
+ const atIndex = lower.indexOf("@");
139
+ if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
140
+ throw new Error("Invalid email format");
141
+ }
142
+ const local = lower.substring(0, atIndex);
143
+ const domain = lower.substring(atIndex + 1);
144
+ if (!local || !domain) {
145
+ throw new Error("Invalid email format");
146
+ }
147
+ const plusIndex = local.indexOf("+");
148
+ const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
149
+ if (!normalizedLocal) {
150
+ throw new Error("Invalid email format");
151
+ }
152
+ return `${normalizedLocal}@${domain}`;
153
+ }
154
+ function normalizeEmailSafe(email2) {
155
+ try {
156
+ return normalizeEmail(email2);
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ // src/services/rate-limit.ts
128
163
  var min = (n) => n * 60;
129
164
  var DEFAULTS = {
130
165
  // login: 10/15min per IP AND 5/15min per account (credential stuffing
@@ -177,7 +212,9 @@ function buildAuthAccountLimiter(route, store, config) {
177
212
  return (0, import_rate_limit.rateLimit)({
178
213
  store: r.store,
179
214
  rule: r.account.rule,
180
- key: (0, import_rate_limit.byBodyField)(`auth:${route}`, r.account.field)
215
+ // The account bucket is keyed the way accounts are stored, so a rotating
216
+ // '+tag' on the same address does not buy fresh attempts.
217
+ key: (0, import_rate_limit.byBodyField)(`auth:${route}`, r.account.field, r.account.field === "email" ? normalizeEmailSafe : void 0)
181
218
  });
182
219
  }
183
220
 
@@ -1633,39 +1670,6 @@ function checkCooldown(lastSentAt, cooldownMs) {
1633
1670
  // src/controllers/auth.controller.ts
1634
1671
  init_password();
1635
1672
 
1636
- // src/services/email.ts
1637
- function normalizeEmail(email2) {
1638
- if (typeof email2 !== "string" || email2.length === 0) {
1639
- throw new Error("Invalid email");
1640
- }
1641
- const lower = email2.trim().toLowerCase();
1642
- if (lower.length === 0) {
1643
- throw new Error("Email cannot be empty");
1644
- }
1645
- const atIndex = lower.indexOf("@");
1646
- if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
1647
- throw new Error("Invalid email format");
1648
- }
1649
- const local = lower.substring(0, atIndex);
1650
- const domain = lower.substring(atIndex + 1);
1651
- if (!local || !domain) {
1652
- throw new Error("Invalid email format");
1653
- }
1654
- const plusIndex = local.indexOf("+");
1655
- const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
1656
- if (!normalizedLocal) {
1657
- throw new Error("Invalid email format");
1658
- }
1659
- return `${normalizedLocal}@${domain}`;
1660
- }
1661
- function normalizeEmailSafe(email2) {
1662
- try {
1663
- return normalizeEmail(email2);
1664
- } catch {
1665
- return null;
1666
- }
1667
- }
1668
-
1669
1673
  // src/models/password-reset.model.ts
1670
1674
  var import_node_crypto5 = require("crypto");
1671
1675
  var hashSecret = (value) => (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
@@ -3355,7 +3359,7 @@ function oauthController(store, config, bus) {
3355
3359
  const meta = requestMeta(ctx);
3356
3360
  await loginEvents.recordSafe({
3357
3361
  userId: null,
3358
- emailAttempted: claims.email ?? null,
3362
+ emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
3359
3363
  method: "oauth-apple",
3360
3364
  outcome: "failed",
3361
3365
  failureReason: "token_replayed",
@@ -3403,7 +3407,7 @@ function oauthController(store, config, bus) {
3403
3407
  if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3404
3408
  await loginEvents.recordSafe({
3405
3409
  userId: null,
3406
- emailAttempted: claims.email ?? null,
3410
+ emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
3407
3411
  method: "oauth-google",
3408
3412
  outcome: "failed",
3409
3413
  failureReason: "token_replayed",
@@ -3761,7 +3765,8 @@ function adminRouteTable(store, bus) {
3761
3765
  // see anything cannot discover who signed up this morning.
3762
3766
  async (ctx) => {
3763
3767
  const params = new URL(ctx.request.url).searchParams;
3764
- const email2 = params.get("email")?.trim().toLowerCase();
3768
+ const raw = params.get("email")?.trim();
3769
+ const email2 = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : void 0;
3765
3770
  if (email2) {
3766
3771
  const user = await users.findByEmail(email2);
3767
3772
  return user ? (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
@@ -3865,7 +3870,7 @@ var AuthModule = class {
3865
3870
  name = "@fonderie/auth";
3866
3871
  // Baked in at build time by tsup.base, so the operator's Modules page can
3867
3872
  // say what is actually deployed rather than 'not reported'.
3868
- version = "7.22.0";
3873
+ version = "7.22.1";
3869
3874
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3870
3875
  describeAdmin() {
3871
3876
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };
@@ -4721,7 +4726,9 @@ async function importUser(store, user) {
4721
4726
  placeholders.push(`$${vals.length}`);
4722
4727
  };
4723
4728
  if (user.id !== void 0) add("id", user.id);
4724
- add("email", user.email.toLowerCase().trim());
4729
+ const email2 = normalizeEmailSafe(user.email);
4730
+ if (!email2) throw new Error(`[auth] importUser: '${user.email}' is not an email address`);
4731
+ add("email", email2);
4725
4732
  if (user.passwordHash !== void 0) add("password_hash", user.passwordHash);
4726
4733
  if (user.firstName !== void 0) add("first_name", user.firstName);
4727
4734
  if (user.lastName !== void 0) add("last_name", user.lastName);