@fonderie/auth 7.22.0 → 7.23.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.js CHANGED
@@ -44,7 +44,7 @@ var init_password = __esm({
44
44
  });
45
45
 
46
46
  // src/routes.ts
47
- import { setApiResponse as setApiResponse6, HTTP as HTTP6 } from "@fonderie/core";
47
+ import { setApiResponse as setApiResponse7, HTTP as HTTP7 } from "@fonderie/core";
48
48
  import { requireAuth, requireAnyAuth, requireVerified } from "@fonderie/core/middlewares";
49
49
 
50
50
  // src/middlewares/require-email-login.ts
@@ -74,6 +74,41 @@ import {
74
74
  rateLimit,
75
75
  StoreAdapterStore
76
76
  } from "@fonderie/rate-limit";
77
+
78
+ // src/services/email.ts
79
+ function normalizeEmail(email2) {
80
+ if (typeof email2 !== "string" || email2.length === 0) {
81
+ throw new Error("Invalid email");
82
+ }
83
+ const lower = email2.trim().toLowerCase();
84
+ if (lower.length === 0) {
85
+ throw new Error("Email cannot be empty");
86
+ }
87
+ const atIndex = lower.indexOf("@");
88
+ if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
89
+ throw new Error("Invalid email format");
90
+ }
91
+ const local = lower.substring(0, atIndex);
92
+ const domain = lower.substring(atIndex + 1);
93
+ if (!local || !domain) {
94
+ throw new Error("Invalid email format");
95
+ }
96
+ const plusIndex = local.indexOf("+");
97
+ const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
98
+ if (!normalizedLocal) {
99
+ throw new Error("Invalid email format");
100
+ }
101
+ return `${normalizedLocal}@${domain}`;
102
+ }
103
+ function normalizeEmailSafe(email2) {
104
+ try {
105
+ return normalizeEmail(email2);
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // src/services/rate-limit.ts
77
112
  var min = (n) => n * 60;
78
113
  var DEFAULTS = {
79
114
  // login: 10/15min per IP AND 5/15min per account (credential stuffing
@@ -126,7 +161,9 @@ function buildAuthAccountLimiter(route, store, config) {
126
161
  return rateLimit({
127
162
  store: r.store,
128
163
  rule: r.account.rule,
129
- key: byBodyField(`auth:${route}`, r.account.field)
164
+ // The account bucket is keyed the way accounts are stored, so a rotating
165
+ // '+tag' on the same address does not buy fresh attempts.
166
+ key: byBodyField(`auth:${route}`, r.account.field, r.account.field === "email" ? normalizeEmailSafe : void 0)
130
167
  });
131
168
  }
132
169
 
@@ -301,6 +338,10 @@ var EVENT_KEYS = {
301
338
  // 'password-changed', 'admin', 'refresh-reuse' (theft signal).
302
339
  sessionRevoked: "fonderie.session.revoked"
303
340
  };
341
+ var deletionGracePeriodDays = (config) => {
342
+ const days = config.accountDeletion?.gracePeriodDays;
343
+ return typeof days === "number" && Number.isFinite(days) && days >= 0 ? days : 30;
344
+ };
304
345
 
305
346
  // src/services/jwt.ts
306
347
  import { createHash, randomUUID } from "crypto";
@@ -717,6 +758,27 @@ var UserModel = class {
717
758
  );
718
759
  return row ?? null;
719
760
  }
761
+ /**
762
+ * An ARCHIVED (soft-deleted, not yet purged) account holding this address.
763
+ * Only for telling its owner — after they prove it is theirs — that it is
764
+ * scheduled for deletion, and for refusing to create a second account on
765
+ * the address meanwhile. Never for signing in.
766
+ */
767
+ async findArchivedByEmail(email2) {
768
+ const [row] = await this.store.query(
769
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NOT NULL`,
770
+ [email2]
771
+ );
772
+ return row ?? null;
773
+ }
774
+ /** As findArchivedByEmail, for a phone number. */
775
+ async findArchivedByPhone(phone2) {
776
+ const [row] = await this.store.query(
777
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NOT NULL`,
778
+ [phone2]
779
+ );
780
+ return row ?? null;
781
+ }
720
782
  async findByPhone(phone2) {
721
783
  const [row] = await this.store.query(
722
784
  `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,
@@ -1571,7 +1633,7 @@ function mfaController(store, config, issuer, bus) {
1571
1633
  // src/controllers/auth.controller.ts
1572
1634
  import { randomBytes as randomBytes3, randomInt } from "crypto";
1573
1635
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT2 } from "@fonderie/events";
1574
- import { setApiResponse as setApiResponse3, HTTP as HTTP3, background as background3, canonicalLocale, defineLocales } from "@fonderie/core";
1636
+ import { setApiResponse as setApiResponse4, HTTP as HTTP4, background as background3, canonicalLocale, defineLocales } from "@fonderie/core";
1575
1637
 
1576
1638
  // src/services/cooldown.ts
1577
1639
  function checkCooldown(lastSentAt, cooldownMs) {
@@ -1582,37 +1644,26 @@ function checkCooldown(lastSentAt, cooldownMs) {
1582
1644
  // src/controllers/auth.controller.ts
1583
1645
  init_password();
1584
1646
 
1585
- // src/services/email.ts
1586
- function normalizeEmail(email2) {
1587
- if (typeof email2 !== "string" || email2.length === 0) {
1588
- throw new Error("Invalid email");
1589
- }
1590
- const lower = email2.trim().toLowerCase();
1591
- if (lower.length === 0) {
1592
- throw new Error("Email cannot be empty");
1593
- }
1594
- const atIndex = lower.indexOf("@");
1595
- if (atIndex === -1 || lower.lastIndexOf("@") !== atIndex) {
1596
- throw new Error("Invalid email format");
1597
- }
1598
- const local = lower.substring(0, atIndex);
1599
- const domain = lower.substring(atIndex + 1);
1600
- if (!local || !domain) {
1601
- throw new Error("Invalid email format");
1602
- }
1603
- const plusIndex = local.indexOf("+");
1604
- const normalizedLocal = plusIndex === -1 ? local : local.substring(0, plusIndex);
1605
- if (!normalizedLocal) {
1606
- throw new Error("Invalid email format");
1607
- }
1608
- return `${normalizedLocal}@${domain}`;
1647
+ // src/services/pending-deletion.ts
1648
+ import { setApiResponse as setApiResponse3, HTTP as HTTP3 } from "@fonderie/core";
1649
+ function deletionDate(deletedAt, config) {
1650
+ return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
1609
1651
  }
1610
- function normalizeEmailSafe(email2) {
1611
- try {
1612
- return normalizeEmail(email2);
1613
- } catch {
1614
- return null;
1615
- }
1652
+ function pendingDeletionResponse(deletedAt, config) {
1653
+ const deleteOn = deletionDate(deletedAt, config);
1654
+ return setApiResponse3(
1655
+ HTTP3.FORBIDDEN,
1656
+ "ACCOUNT_PENDING_DELETION",
1657
+ `This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
1658
+ { requestedAt: deletedAt.toISOString(), deleteOn: deleteOn.toISOString() }
1659
+ );
1660
+ }
1661
+ function archivedAddressResponse() {
1662
+ return setApiResponse3(
1663
+ HTTP3.CONFLICT,
1664
+ "ACCOUNT_PENDING_DELETION",
1665
+ "An account using this address is scheduled for deletion. Sign in to keep it, or wait until it is deleted."
1666
+ );
1616
1667
  }
1617
1668
 
1618
1669
  // src/models/password-reset.model.ts
@@ -1787,19 +1838,20 @@ function authController(store, config, bus, locales = defineLocales()) {
1787
1838
  if (typeof email2 === "string" && typeof password2 === "string") {
1788
1839
  const normalizedEmail = normalizeEmailSafe(email2);
1789
1840
  if (!normalizedEmail) {
1790
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1841
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1791
1842
  }
1792
1843
  if (password2.length < 8) {
1793
- return setApiResponse3(
1794
- HTTP3.UNPROCESSABLE,
1844
+ return setApiResponse4(
1845
+ HTTP4.UNPROCESSABLE,
1795
1846
  "INVALID_PARAMETER",
1796
1847
  "password must be at least 8 characters"
1797
1848
  );
1798
1849
  }
1799
1850
  const existing = await users.findByEmail(normalizedEmail);
1800
1851
  if (existing) {
1801
- return setApiResponse3(HTTP3.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1852
+ return setApiResponse4(HTTP4.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1802
1853
  }
1854
+ if (await users.findArchivedByEmail(normalizedEmail)) return archivedAddressResponse();
1803
1855
  const passwordHash = await hashPassword(password2);
1804
1856
  const row = await users.create(
1805
1857
  normalizedEmail,
@@ -1809,14 +1861,14 @@ function authController(store, config, bus, locales = defineLocales()) {
1809
1861
  locale
1810
1862
  );
1811
1863
  if (!row) {
1812
- return setApiResponse3(HTTP3.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1864
+ return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1813
1865
  }
1814
1866
  const pin = randomInt(1e5, 1e6).toString();
1815
1867
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1816
1868
  await emailVerif.create(row.id, pin, expiresAt);
1817
1869
  const user = await users.findById(row.id);
1818
1870
  if (!user) {
1819
- return setApiResponse3(HTTP3.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1871
+ return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1820
1872
  }
1821
1873
  const reqId = ctx.meta["requestId"];
1822
1874
  const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
@@ -1874,8 +1926,9 @@ function authController(store, config, bus, locales = defineLocales()) {
1874
1926
  if (isValidPhone(phone2)) {
1875
1927
  const existing = await users.findByPhone(normalizePhone(phone2));
1876
1928
  if (existing) {
1877
- return setApiResponse3(HTTP3.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
1929
+ return setApiResponse4(HTTP4.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
1878
1930
  }
1931
+ if (await users.findArchivedByPhone(normalizePhone(phone2))) return archivedAddressResponse();
1879
1932
  const { id } = await users.findOrCreateByPhone(
1880
1933
  normalizePhone(phone2),
1881
1934
  firstName ?? null,
@@ -1887,7 +1940,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1887
1940
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1888
1941
  const user = await users.findById(id);
1889
1942
  if (!user) {
1890
- return setApiResponse3(HTTP3.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1943
+ return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1891
1944
  }
1892
1945
  const reqId2 = ctx.meta["requestId"];
1893
1946
  const reqOpts2 = reqId2 !== void 0 ? { requestId: reqId2 } : void 0;
@@ -1925,8 +1978,8 @@ function authController(store, config, bus, locales = defineLocales()) {
1925
1978
  { status: 202 }
1926
1979
  );
1927
1980
  }
1928
- return setApiResponse3(
1929
- HTTP3.UNPROCESSABLE,
1981
+ return setApiResponse4(
1982
+ HTTP4.UNPROCESSABLE,
1930
1983
  "INVALID_PARAMETER",
1931
1984
  "Provide email + password or a valid phone number"
1932
1985
  );
@@ -1938,9 +1991,25 @@ function authController(store, config, bus, locales = defineLocales()) {
1938
1991
  const { email: rawEmail, password: password2 } = body;
1939
1992
  const email2 = normalizeEmailSafe(rawEmail);
1940
1993
  if (!email2) {
1941
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1994
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1942
1995
  }
1943
1996
  const user = await users.findByEmail(email2);
1997
+ if (!user) {
1998
+ const archived = await users.findArchivedByEmail(email2);
1999
+ if (archived?.passwordHash && archived.deletedAt) {
2000
+ const { valid: valid2 } = await verifyPasswordForLogin(password2, archived.passwordHash, config.legacyVerify);
2001
+ await loginEvents.recordSafe({
2002
+ userId: archived.id,
2003
+ emailAttempted: email2,
2004
+ method: "password",
2005
+ outcome: "failed",
2006
+ failureReason: valid2 ? "pending_deletion" : "bad_password",
2007
+ ...meta
2008
+ });
2009
+ if (valid2) return pendingDeletionResponse(archived.deletedAt, config);
2010
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2011
+ }
2012
+ }
1944
2013
  if (!user || !user.passwordHash) {
1945
2014
  await loginEvents.recordSafe({
1946
2015
  userId: null,
@@ -1950,7 +2019,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1950
2019
  failureReason: "unknown_email",
1951
2020
  ...meta
1952
2021
  });
1953
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2022
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1954
2023
  }
1955
2024
  const { valid, needsRehash } = await verifyPasswordForLogin(
1956
2025
  password2,
@@ -1966,21 +2035,21 @@ function authController(store, config, bus, locales = defineLocales()) {
1966
2035
  failureReason: "bad_password",
1967
2036
  ...meta
1968
2037
  });
1969
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2038
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1970
2039
  }
1971
2040
  if (needsRehash) {
1972
2041
  await users.updatePassword(user.id, await hashPassword(password2));
1973
2042
  }
1974
2043
  if (user.suspended) {
1975
- return setApiResponse3(
1976
- HTTP3.FORBIDDEN,
2044
+ return setApiResponse4(
2045
+ HTTP4.FORBIDDEN,
1977
2046
  "ACCOUNT_SUSPENDED",
1978
2047
  "Account suspended. Please contact support."
1979
2048
  );
1980
2049
  }
1981
2050
  if (user.mfaEnabled) {
1982
2051
  const mfaToken = issueMfaPendingToken(user.id, config, "email");
1983
- return setApiResponse3(HTTP3.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
2052
+ return setApiResponse4(HTTP4.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
1984
2053
  mfaToken
1985
2054
  });
1986
2055
  }
@@ -2017,11 +2086,11 @@ function authController(store, config, bus, locales = defineLocales()) {
2017
2086
  if (isValidPhone(phone2)) {
2018
2087
  const user = await users.findByPhone(normalizePhone(phone2));
2019
2088
  if (!user) {
2020
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2089
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2021
2090
  }
2022
2091
  if (user.suspended) {
2023
- return setApiResponse3(
2024
- HTTP3.FORBIDDEN,
2092
+ return setApiResponse4(
2093
+ HTTP4.FORBIDDEN,
2025
2094
  "ACCOUNT_SUSPENDED",
2026
2095
  "Account suspended. Please contact support."
2027
2096
  );
@@ -2045,8 +2114,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2045
2114
  { status: 202 }
2046
2115
  );
2047
2116
  }
2048
- return setApiResponse3(
2049
- HTTP3.UNPROCESSABLE,
2117
+ return setApiResponse4(
2118
+ HTTP4.UNPROCESSABLE,
2050
2119
  "INVALID_PARAMETER",
2051
2120
  "Provide email + password or a valid phone number"
2052
2121
  );
@@ -2071,35 +2140,35 @@ function authController(store, config, bus, locales = defineLocales()) {
2071
2140
  refresh: async (ctx) => {
2072
2141
  const token = extractRefreshToken(ctx);
2073
2142
  if (!token) {
2074
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2143
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2075
2144
  }
2076
2145
  const payload = verifyToken(token, config);
2077
2146
  if (!payload || payload.type !== "refresh") {
2078
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2147
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2079
2148
  }
2080
2149
  const runtime = config.resolve?.(ctx);
2081
2150
  let issued = null;
2082
2151
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2083
2152
  const found = await sessions.match(token);
2084
2153
  if (found.kind === "unknown") {
2085
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2154
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2086
2155
  }
2087
2156
  if (found.kind === "reused") {
2088
2157
  await sessions.revokeById(found.row.id);
2089
2158
  await background3(
2090
2159
  bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
2091
2160
  );
2092
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2161
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2093
2162
  }
2094
2163
  const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2095
2164
  const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2096
2165
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2097
2166
  await sessions.revokeById(found.row.id);
2098
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2167
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2099
2168
  }
2100
2169
  const user = await users.findById(found.row.userId);
2101
2170
  if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2102
- return setApiResponse3(HTTP3.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2171
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2103
2172
  }
2104
2173
  const pair = issueTokenPair(user.id, resolvedRefresh, {
2105
2174
  loginMethod: payload.loginMethod ?? "email",
@@ -2112,7 +2181,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2112
2181
  if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
2113
2182
  }
2114
2183
  if (!issued) {
2115
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2184
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2116
2185
  }
2117
2186
  const { accessToken, refreshToken } = issued;
2118
2187
  return Response.json(
@@ -2131,16 +2200,16 @@ function authController(store, config, bus, locales = defineLocales()) {
2131
2200
  const body = ctx.meta["body"];
2132
2201
  const rawEmail = body?.["email"];
2133
2202
  if (typeof rawEmail !== "string") {
2134
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2203
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2135
2204
  }
2136
2205
  const email2 = normalizeEmailSafe(rawEmail);
2137
2206
  if (!email2) {
2138
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2207
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2139
2208
  }
2140
2209
  const user = await users.findByEmail(email2);
2141
2210
  if (!user) {
2142
- return setApiResponse3(
2143
- HTTP3.OK,
2211
+ return setApiResponse4(
2212
+ HTTP4.OK,
2144
2213
  "PASSWORD_RESET_EMAIL_SENT",
2145
2214
  "Password reset email sent (if account exists)."
2146
2215
  );
@@ -2149,8 +2218,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2149
2218
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2150
2219
  const remaining = checkCooldown(await passwordReset.findLastSentAt(user.id), cooldown);
2151
2220
  if (remaining > 0) {
2152
- return setApiResponse3(
2153
- HTTP3.OK,
2221
+ return setApiResponse4(
2222
+ HTTP4.OK,
2154
2223
  "PASSWORD_RESET_EMAIL_SENT",
2155
2224
  "Password reset email sent (if account exists)."
2156
2225
  );
@@ -2167,8 +2236,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2167
2236
  recipient: { email: email2, phone: null, deviceToken: null },
2168
2237
  data: { pin, token, resetUrl }
2169
2238
  }));
2170
- return setApiResponse3(
2171
- HTTP3.OK,
2239
+ return setApiResponse4(
2240
+ HTTP4.OK,
2172
2241
  "PASSWORD_RESET_EMAIL_SENT",
2173
2242
  "Password reset email sent (if account exists)."
2174
2243
  );
@@ -2179,11 +2248,11 @@ function authController(store, config, bus, locales = defineLocales()) {
2179
2248
  const password2 = body?.["password"];
2180
2249
  const rawToken = body?.["token"];
2181
2250
  if (typeof password2 !== "string") {
2182
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2251
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2183
2252
  }
2184
2253
  if (password2.length < 8) {
2185
- return setApiResponse3(
2186
- HTTP3.UNPROCESSABLE,
2254
+ return setApiResponse4(
2255
+ HTTP4.UNPROCESSABLE,
2187
2256
  "INVALID_PARAMETER",
2188
2257
  "password must be at least 8 characters"
2189
2258
  );
@@ -2193,8 +2262,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2193
2262
  row = await passwordReset.findByToken(rawToken.trim());
2194
2263
  } else {
2195
2264
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2196
- return setApiResponse3(
2197
- HTTP3.UNPROCESSABLE,
2265
+ return setApiResponse4(
2266
+ HTTP4.UNPROCESSABLE,
2198
2267
  "INVALID_PARAMETER",
2199
2268
  "a 6-digit pin or a reset token is required"
2200
2269
  );
@@ -2202,33 +2271,41 @@ function authController(store, config, bus, locales = defineLocales()) {
2202
2271
  row = await passwordReset.findByPin(raw.trim());
2203
2272
  }
2204
2273
  if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
2205
- return setApiResponse3(
2206
- HTTP3.BAD_REQUEST,
2274
+ return setApiResponse4(
2275
+ HTTP4.BAD_REQUEST,
2207
2276
  "PASSWORD_RESET_FAILED",
2208
2277
  "Invalid or expired reset credentials"
2209
2278
  );
2210
2279
  }
2211
2280
  const passwordHash = await hashPassword(password2);
2212
- await store.transaction(async (tx) => {
2213
- await Promise.all([
2214
- tx.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
2215
- passwordHash,
2216
- row.userId
2217
- ]),
2218
- tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
2219
- // Revoke every session: the reset exists because the account may
2220
- // be compromised — a stolen session must not survive it.
2221
- tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
2222
- ]);
2223
- });
2224
- return setApiResponse3(HTTP3.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2281
+ const [changed] = await store.query(
2282
+ `WITH changed AS (
2283
+ UPDATE fonderie_users SET password_hash = $1, updated_at = now()
2284
+ WHERE id = $2 AND deleted_at IS NULL
2285
+ RETURNING id
2286
+ ), spent AS (
2287
+ DELETE FROM fonderie_password_resets WHERE user_id = $2
2288
+ ), revoked AS (
2289
+ DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
2290
+ )
2291
+ SELECT id FROM changed`,
2292
+ [passwordHash, row.userId]
2293
+ );
2294
+ if (!changed) {
2295
+ return setApiResponse4(
2296
+ HTTP4.BAD_REQUEST,
2297
+ "PASSWORD_RESET_FAILED",
2298
+ "Invalid or expired reset credentials"
2299
+ );
2300
+ }
2301
+ return setApiResponse4(HTTP4.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2225
2302
  },
2226
2303
  verify: async (ctx) => {
2227
2304
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2228
- return setApiResponse3(HTTP3.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2305
+ return setApiResponse4(HTTP4.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2229
2306
  }
2230
2307
  if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
2231
- return setApiResponse3(HTTP3.OK, "VERIFIED", "Email verified successfully.", {
2308
+ return setApiResponse4(HTTP4.OK, "VERIFIED", "Email verified successfully.", {
2232
2309
  verified: true,
2233
2310
  email: ctx.user.email
2234
2311
  });
@@ -2236,8 +2313,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2236
2313
  const body = ctx.meta["body"];
2237
2314
  const raw = body?.["token"];
2238
2315
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2239
- return setApiResponse3(
2240
- HTTP3.UNPROCESSABLE,
2316
+ return setApiResponse4(
2317
+ HTTP4.UNPROCESSABLE,
2241
2318
  "INVALID_PARAMETER",
2242
2319
  "token must be a 6-digit code"
2243
2320
  );
@@ -2255,7 +2332,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2255
2332
  failureReason: "invalid_pin",
2256
2333
  ...phoneMeta
2257
2334
  });
2258
- return setApiResponse3(HTTP3.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2335
+ return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2259
2336
  }
2260
2337
  if (/* @__PURE__ */ new Date() > record.expiresAt) {
2261
2338
  await phoneVerif.deleteByUser(ctx.user.id);
@@ -2267,8 +2344,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2267
2344
  failureReason: "expired_pin",
2268
2345
  ...phoneMeta
2269
2346
  });
2270
- return setApiResponse3(
2271
- HTTP3.BAD_REQUEST,
2347
+ return setApiResponse4(
2348
+ HTTP4.BAD_REQUEST,
2272
2349
  "VERIFICATION_FAILED",
2273
2350
  "Verification code expired"
2274
2351
  );
@@ -2288,11 +2365,11 @@ function authController(store, config, bus, locales = defineLocales()) {
2288
2365
  });
2289
2366
  const verifiedUser = await users.findById(ctx.user.id);
2290
2367
  if (!verifiedUser) {
2291
- return setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "User not found");
2368
+ return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2292
2369
  }
2293
2370
  if (verifiedUser.suspended) {
2294
- return setApiResponse3(
2295
- HTTP3.FORBIDDEN,
2371
+ return setApiResponse4(
2372
+ HTTP4.FORBIDDEN,
2296
2373
  "ACCOUNT_SUSPENDED",
2297
2374
  "Account suspended. Please contact support."
2298
2375
  );
@@ -2314,10 +2391,10 @@ function authController(store, config, bus, locales = defineLocales()) {
2314
2391
  }
2315
2392
  const row = await emailVerif.findByUser(ctx.user.id, pin);
2316
2393
  if (!row) {
2317
- return setApiResponse3(HTTP3.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2394
+ return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2318
2395
  }
2319
2396
  if (/* @__PURE__ */ new Date() > row.expiresAt) {
2320
- return setApiResponse3(HTTP3.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2397
+ return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2321
2398
  }
2322
2399
  await store.transaction(async (tx) => {
2323
2400
  await Promise.all([
@@ -2331,30 +2408,30 @@ function authController(store, config, bus, locales = defineLocales()) {
2331
2408
  ])
2332
2409
  ]);
2333
2410
  });
2334
- return setApiResponse3(HTTP3.OK, "VERIFIED", "Email verified successfully.", {
2411
+ return setApiResponse4(HTTP4.OK, "VERIFIED", "Email verified successfully.", {
2335
2412
  verified: true,
2336
2413
  email: ctx.user.email
2337
2414
  });
2338
2415
  },
2339
2416
  sendVerification: async (ctx) => {
2340
2417
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2341
- return setApiResponse3(HTTP3.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2418
+ return setApiResponse4(HTTP4.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2342
2419
  }
2343
2420
  const resolved = { ...config, ...config.resolve?.(ctx) };
2344
2421
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2345
2422
  if (ctx.user.loginMethod === "phone") {
2346
2423
  const phone2 = ctx.user.phone;
2347
2424
  if (!phone2) {
2348
- return setApiResponse3(
2349
- HTTP3.BAD_REQUEST,
2425
+ return setApiResponse4(
2426
+ HTTP4.BAD_REQUEST,
2350
2427
  "NO_PHONE_ON_ACCOUNT",
2351
2428
  "No phone number associated with this account"
2352
2429
  );
2353
2430
  }
2354
2431
  const remaining2 = checkCooldown(await phoneVerif.findLastSentAt(ctx.user.id), cooldown);
2355
2432
  if (remaining2 > 0) {
2356
- return setApiResponse3(
2357
- HTTP3.TOO_MANY_REQUESTS,
2433
+ return setApiResponse4(
2434
+ HTTP4.TOO_MANY_REQUESTS,
2358
2435
  "VERIFICATION_COOLDOWN",
2359
2436
  "Please wait before requesting a new code.",
2360
2437
  {
@@ -2371,29 +2448,29 @@ function authController(store, config, bus, locales = defineLocales()) {
2371
2448
  data: { otp },
2372
2449
  recipient: { email: null, phone: phone2, deviceToken: null }
2373
2450
  }));
2374
- return setApiResponse3(
2375
- HTTP3.OK,
2451
+ return setApiResponse4(
2452
+ HTTP4.OK,
2376
2453
  "VERIFICATION_SENT",
2377
2454
  "A verification code has been sent to your phone."
2378
2455
  );
2379
2456
  }
2380
2457
  if (!ctx.user.email) {
2381
- return setApiResponse3(
2382
- HTTP3.BAD_REQUEST,
2458
+ return setApiResponse4(
2459
+ HTTP4.BAD_REQUEST,
2383
2460
  "NO_EMAIL_ON_ACCOUNT",
2384
2461
  "No email address associated with this account"
2385
2462
  );
2386
2463
  }
2387
2464
  if (ctx.user.emailVerifiedAt) {
2388
- return setApiResponse3(HTTP3.OK, "EMAIL_VERIFIED", "Email already verified.", {
2465
+ return setApiResponse4(HTTP4.OK, "EMAIL_VERIFIED", "Email already verified.", {
2389
2466
  verified: true,
2390
2467
  email: ctx.user.email
2391
2468
  });
2392
2469
  }
2393
2470
  const remaining = checkCooldown(await emailVerif.findLastSentAt(ctx.user.id), cooldown);
2394
2471
  if (remaining > 0) {
2395
- return setApiResponse3(
2396
- HTTP3.TOO_MANY_REQUESTS,
2472
+ return setApiResponse4(
2473
+ HTTP4.TOO_MANY_REQUESTS,
2397
2474
  "VERIFICATION_COOLDOWN",
2398
2475
  "Please wait before requesting a new code.",
2399
2476
  {
@@ -2410,7 +2487,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2410
2487
  recipient: { email: ctx.user.email, phone: null, deviceToken: null },
2411
2488
  data: { pin }
2412
2489
  }));
2413
- return setApiResponse3(HTTP3.OK, "VERIFICATION_SENT", "Verification email sent.", {
2490
+ return setApiResponse4(HTTP4.OK, "VERIFICATION_SENT", "Verification email sent.", {
2414
2491
  email: ctx.user.email
2415
2492
  });
2416
2493
  }
@@ -2419,7 +2496,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2419
2496
 
2420
2497
  // src/controllers/user.controller.ts
2421
2498
  import { randomInt as randomInt2 } from "crypto";
2422
- import { setApiResponse as setApiResponse4, HTTP as HTTP4, dateOrEmpty as dateOrEmpty2, background as background4 } from "@fonderie/core";
2499
+ import { setApiResponse as setApiResponse5, HTTP as HTTP5, dateOrEmpty as dateOrEmpty2, background as background4 } from "@fonderie/core";
2423
2500
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
2424
2501
 
2425
2502
  // src/dtos/login-activity.ts
@@ -2474,9 +2551,9 @@ function userController(store, config, bus) {
2474
2551
  me: async (ctx) => {
2475
2552
  const user = await users.findById(ctx.user.id);
2476
2553
  if (!user) {
2477
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2554
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2478
2555
  }
2479
- return setApiResponse4(HTTP4.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2556
+ return setApiResponse5(HTTP5.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2480
2557
  user: toUserDTO(user, ctx.user.phoneVerified)
2481
2558
  });
2482
2559
  },
@@ -2487,14 +2564,14 @@ function userController(store, config, bus) {
2487
2564
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
2488
2565
  const outcomeParam = params.get("outcome");
2489
2566
  if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
2490
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2567
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2491
2568
  }
2492
2569
  const cursorParam = params.get("cursor");
2493
2570
  let cursor = null;
2494
2571
  if (cursorParam) {
2495
2572
  cursor = decodeLoginCursor(cursorParam);
2496
2573
  if (!cursor) {
2497
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2574
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2498
2575
  }
2499
2576
  }
2500
2577
  const query = { userId: ctx.user.id, limit };
@@ -2505,8 +2582,8 @@ function userController(store, config, bus) {
2505
2582
  if (from) query.from = new Date(from);
2506
2583
  if (to) query.to = new Date(to);
2507
2584
  const page = await loginEvents.listByUser(query);
2508
- return setApiResponse4(
2509
- HTTP4.OK,
2585
+ return setApiResponse5(
2586
+ HTTP5.OK,
2510
2587
  "LOGIN_HISTORY_FETCHED",
2511
2588
  "Login history retrieved.",
2512
2589
  toLoginHistoryPageDTO(page)
@@ -2516,27 +2593,27 @@ function userController(store, config, bus) {
2516
2593
  listSessions: async (ctx) => {
2517
2594
  const rows = await sessions.listLiveByUser(ctx.user.id);
2518
2595
  const currentSid = ctx.user.sid ?? null;
2519
- return setApiResponse4(HTTP4.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2596
+ return setApiResponse5(HTTP5.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2520
2597
  sessions: rows.map((r) => toSessionDTO(r, currentSid))
2521
2598
  });
2522
2599
  },
2523
2600
  terminateSession: async (ctx) => {
2524
2601
  const id = ctx.meta["params"]?.["id"];
2525
2602
  if (!id) {
2526
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
2603
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Session not found");
2527
2604
  }
2528
2605
  const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
2529
2606
  if (removed === void 0) {
2530
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
2607
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Session not found");
2531
2608
  }
2532
2609
  await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2533
- return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2610
+ return setApiResponse5(HTTP5.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2534
2611
  },
2535
2612
  terminateOtherSessions: async (ctx) => {
2536
2613
  const currentSid = ctx.user.sid ?? null;
2537
2614
  if (!currentSid) {
2538
- return setApiResponse4(
2539
- HTTP4.UNPROCESSABLE,
2615
+ return setApiResponse5(
2616
+ HTTP5.UNPROCESSABLE,
2540
2617
  "NO_CURRENT_SESSION",
2541
2618
  "Cannot terminate others without a current session context"
2542
2619
  );
@@ -2544,7 +2621,7 @@ function userController(store, config, bus) {
2544
2621
  const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
2545
2622
  const count = sids.length;
2546
2623
  if (count) await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2547
- return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2624
+ return setApiResponse5(HTTP5.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2548
2625
  },
2549
2626
  updateProfile: async (ctx) => {
2550
2627
  const body = ctx.meta["body"];
@@ -2554,18 +2631,18 @@ function userController(store, config, bus) {
2554
2631
  if (body?.[key] !== void 0) fields[key] = body[key];
2555
2632
  }
2556
2633
  if (Object.keys(fields).length === 0) {
2557
- return setApiResponse4(
2558
- HTTP4.UNPROCESSABLE,
2634
+ return setApiResponse5(
2635
+ HTTP5.UNPROCESSABLE,
2559
2636
  "INVALID_PARAMETER",
2560
2637
  "Provide at least one of: firstName, lastName, avatarUrl"
2561
2638
  );
2562
2639
  }
2563
2640
  const row = await users.update(ctx.user.id, fields);
2564
2641
  if (!row) {
2565
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2642
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2566
2643
  }
2567
2644
  const updated = await users.findById(ctx.user.id);
2568
- return setApiResponse4(HTTP4.OK, "PROFILE_UPDATED", "Profile updated.", {
2645
+ return setApiResponse5(HTTP5.OK, "PROFILE_UPDATED", "Profile updated.", {
2569
2646
  user: toUserDTO(updated, ctx.user.phoneVerified)
2570
2647
  });
2571
2648
  },
@@ -2582,14 +2659,14 @@ function userController(store, config, bus) {
2582
2659
  if (Object.keys(patch).length > 0) fields.patch = patch;
2583
2660
  const row = await users.updatePreferences(ctx.user.id, fields);
2584
2661
  if (!row) {
2585
- return setApiResponse4(
2586
- HTTP4.UNPROCESSABLE,
2662
+ return setApiResponse5(
2663
+ HTTP5.UNPROCESSABLE,
2587
2664
  "INVALID_PARAMETER",
2588
2665
  "Provide at least one preference field"
2589
2666
  );
2590
2667
  }
2591
2668
  const updated = await users.findById(ctx.user.id);
2592
- return setApiResponse4(HTTP4.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2669
+ return setApiResponse5(HTTP5.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2593
2670
  user: toUserDTO(updated, ctx.user.phoneVerified)
2594
2671
  });
2595
2672
  },
@@ -2598,23 +2675,23 @@ function userController(store, config, bus) {
2598
2675
  const newEmail = body?.["email"];
2599
2676
  const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
2600
2677
  if (!normalised) {
2601
- return setApiResponse4(
2602
- HTTP4.UNPROCESSABLE,
2678
+ return setApiResponse5(
2679
+ HTTP5.UNPROCESSABLE,
2603
2680
  "INVALID_PARAMETER",
2604
2681
  "A valid email address is required"
2605
2682
  );
2606
2683
  }
2607
2684
  const oldEmail = ctx.user.email;
2608
2685
  if (normalised === oldEmail) {
2609
- return setApiResponse4(
2610
- HTTP4.UNPROCESSABLE,
2686
+ return setApiResponse5(
2687
+ HTTP5.UNPROCESSABLE,
2611
2688
  "INVALID_PARAMETER",
2612
2689
  "New email must differ from current email"
2613
2690
  );
2614
2691
  }
2615
2692
  const existing = await users.findByEmail(normalised);
2616
2693
  if (existing) {
2617
- return setApiResponse4(HTTP4.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2694
+ return setApiResponse5(HTTP5.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2618
2695
  }
2619
2696
  const pin = randomInt2(1e5, 1e6).toString();
2620
2697
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
@@ -2634,8 +2711,8 @@ function userController(store, config, bus) {
2634
2711
  recipient: { email: oldEmail, phone: null, deviceToken: null }
2635
2712
  }));
2636
2713
  }
2637
- return setApiResponse4(
2638
- HTTP4.OK,
2714
+ return setApiResponse5(
2715
+ HTTP5.OK,
2639
2716
  "EMAIL_UPDATED",
2640
2717
  "Email updated. A verification code has been sent to your new address.",
2641
2718
  {
@@ -2647,8 +2724,8 @@ function userController(store, config, bus) {
2647
2724
  const body = ctx.meta["body"];
2648
2725
  const newPhone = body?.["phone"];
2649
2726
  if (!isValidPhone2(newPhone)) {
2650
- return setApiResponse4(
2651
- HTTP4.UNPROCESSABLE,
2727
+ return setApiResponse5(
2728
+ HTTP5.UNPROCESSABLE,
2652
2729
  "INVALID_PARAMETER",
2653
2730
  "A valid phone number is required"
2654
2731
  );
@@ -2656,7 +2733,7 @@ function userController(store, config, bus) {
2656
2733
  const normalised = normalizePhone2(newPhone);
2657
2734
  const existing = await users.findByPhone(normalised);
2658
2735
  if (existing) {
2659
- return setApiResponse4(HTTP4.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2736
+ return setApiResponse5(HTTP5.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2660
2737
  }
2661
2738
  const otp = randomInt2(1e5, 1e6).toString();
2662
2739
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
@@ -2676,8 +2753,8 @@ function userController(store, config, bus) {
2676
2753
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2677
2754
  }));
2678
2755
  }
2679
- return setApiResponse4(
2680
- HTTP4.OK,
2756
+ return setApiResponse5(
2757
+ HTTP5.OK,
2681
2758
  "PHONE_UPDATED",
2682
2759
  "Phone number updated. A verification code has been sent to your new number.",
2683
2760
  {
@@ -2690,23 +2767,23 @@ function userController(store, config, bus) {
2690
2767
  const currentPassword = body?.["currentPassword"];
2691
2768
  const newPassword = body?.["newPassword"];
2692
2769
  if (typeof currentPassword !== "string" || !currentPassword) {
2693
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2770
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2694
2771
  }
2695
2772
  if (typeof newPassword !== "string" || newPassword.length < 8) {
2696
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2773
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2697
2774
  }
2698
2775
  const user = await users.findById(ctx.user.id);
2699
- if (!user) return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2776
+ if (!user) return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2700
2777
  const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
2701
2778
  const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
2702
2779
  if (!valid) {
2703
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2780
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2704
2781
  }
2705
2782
  const hash = await hashPassword2(newPassword);
2706
2783
  await users.updatePassword(ctx.user.id, hash);
2707
2784
  await sessions.deleteByUser(ctx.user.id);
2708
2785
  await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
2709
- return setApiResponse4(HTTP4.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2786
+ return setApiResponse5(HTTP5.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2710
2787
  },
2711
2788
  /**
2712
2789
  * Remove an OAuth provider from the account.
@@ -2725,19 +2802,19 @@ function userController(store, config, bus) {
2725
2802
  const provider = String(ctx.meta["params"]?.["provider"] ?? "");
2726
2803
  const current = await users.findById(ctx.user.id);
2727
2804
  if (!current?.provider) {
2728
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2805
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2729
2806
  }
2730
2807
  if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
2731
- return setApiResponse4(
2732
- HTTP4.NOT_FOUND,
2808
+ return setApiResponse5(
2809
+ HTTP5.NOT_FOUND,
2733
2810
  "NOT_LINKED",
2734
2811
  `This account is not linked to ${provider}.`
2735
2812
  );
2736
2813
  }
2737
2814
  const removed = await users.clearProvider(ctx.user.id);
2738
2815
  if (!removed) {
2739
- return setApiResponse4(
2740
- HTTP4.CONFLICT,
2816
+ return setApiResponse5(
2817
+ HTTP5.CONFLICT,
2741
2818
  "PASSWORD_REQUIRED",
2742
2819
  "Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
2743
2820
  );
@@ -2750,12 +2827,18 @@ function userController(store, config, bus) {
2750
2827
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2751
2828
  }));
2752
2829
  }
2753
- return setApiResponse4(HTTP4.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2830
+ return setApiResponse5(HTTP5.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2754
2831
  },
2755
2832
  deleteMe: async (ctx) => {
2756
2833
  const userId = ctx.user.id;
2757
2834
  await users.softDelete(userId);
2758
2835
  await sessions.deleteByUser(userId);
2836
+ await store.query(
2837
+ `WITH resets AS (DELETE FROM fonderie_password_resets WHERE user_id = $1),
2838
+ emails AS (DELETE FROM fonderie_email_verifications WHERE user_id = $1)
2839
+ DELETE FROM fonderie_phone_verifications WHERE user_id = $1`,
2840
+ [userId]
2841
+ );
2759
2842
  const reqId = ctx.meta["requestId"];
2760
2843
  await background4(bus?.emit(
2761
2844
  EVENT_KEYS.userDeleted,
@@ -2776,7 +2859,7 @@ function userController(store, config, bus) {
2776
2859
  // no MFA secret, no session tokens (session metadata only).
2777
2860
  exportMe: async (ctx) => {
2778
2861
  const user = await users.findById(ctx.user.id);
2779
- if (!user) return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2862
+ if (!user) return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2780
2863
  const sessionRows = await sessions.listByUser(ctx.user.id);
2781
2864
  const bundle = {
2782
2865
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2805,7 +2888,7 @@ function userController(store, config, bus) {
2805
2888
  }
2806
2889
  bundle["modules"] = modules;
2807
2890
  }
2808
- return setApiResponse4(HTTP4.OK, "DATA_EXPORT", "Your account data.", bundle);
2891
+ return setApiResponse5(HTTP5.OK, "DATA_EXPORT", "Your account data.", bundle);
2809
2892
  }
2810
2893
  };
2811
2894
  }
@@ -2813,7 +2896,7 @@ function userController(store, config, bus) {
2813
2896
  // src/controllers/oauth.controller.ts
2814
2897
  import { randomBytes as randomBytes4, createHash as createHash4 } from "crypto";
2815
2898
  import jwt2 from "jsonwebtoken";
2816
- import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2899
+ import { setApiResponse as setApiResponse6, HTTP as HTTP6, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2817
2900
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
2818
2901
 
2819
2902
  // src/services/jwks.ts
@@ -3023,14 +3106,29 @@ function oauthController(store, config, bus) {
3023
3106
  const method = provider === "apple" ? "oauth-apple" : "oauth-google";
3024
3107
  const meta = requestMeta(ctx);
3025
3108
  if (!claims.email) {
3026
- return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3109
+ return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3027
3110
  }
3028
3111
  if (!claims.emailVerified) {
3029
- return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3112
+ return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3030
3113
  }
3031
3114
  const normalizedEmail = normalizeEmailSafe(claims.email);
3032
3115
  if (!normalizedEmail) {
3033
- return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3116
+ return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3117
+ }
3118
+ const archived = await users.findArchivedByEmail(normalizedEmail);
3119
+ if (archived?.deletedAt) {
3120
+ await loginEvents.recordSafe({
3121
+ userId: archived.id,
3122
+ emailAttempted: normalizedEmail,
3123
+ method,
3124
+ outcome: "failed",
3125
+ failureReason: "pending_deletion",
3126
+ ...meta
3127
+ });
3128
+ const res = pendingDeletionResponse(archived.deletedAt, config);
3129
+ const headers = cookieHeaders(clearCookies);
3130
+ headers.set("content-type", res.headers.get("content-type") ?? "application/json");
3131
+ return new Response(res.body, { status: res.status, headers });
3034
3132
  }
3035
3133
  const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
3036
3134
  if (!upserted) {
@@ -3042,15 +3140,15 @@ function oauthController(store, config, bus) {
3042
3140
  failureReason: "provider_upsert_failed",
3043
3141
  ...meta
3044
3142
  });
3045
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3143
+ return setApiResponse6(HTTP6.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3046
3144
  }
3047
3145
  const fullUser = await users.findById(upserted.id);
3048
3146
  if (!fullUser) {
3049
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3147
+ return setApiResponse6(HTTP6.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3050
3148
  }
3051
3149
  await announceOAuthUpsert(ctx, upserted, provider, fullUser);
3052
3150
  if (fullUser.suspended) {
3053
- return setApiResponse5(HTTP5.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3151
+ return setApiResponse6(HTTP6.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3054
3152
  }
3055
3153
  if (fullUser.mfaEnabled) {
3056
3154
  const mfaToken = issueMfaPendingToken(upserted.id, config, provider);
@@ -3093,8 +3191,8 @@ function oauthController(store, config, bus) {
3093
3191
  googleInit: async (_ctx) => {
3094
3192
  const google = config.google;
3095
3193
  if (!google || !google.clientSecret || !google.redirectUri) {
3096
- return setApiResponse5(
3097
- HTTP5.NOT_IMPLEMENTED,
3194
+ return setApiResponse6(
3195
+ HTTP6.NOT_IMPLEMENTED,
3098
3196
  "NOT_CONFIGURED",
3099
3197
  "Google OAuth not configured"
3100
3198
  );
@@ -3128,8 +3226,8 @@ function oauthController(store, config, bus) {
3128
3226
  googleCallback: async (ctx) => {
3129
3227
  const google = config.google;
3130
3228
  if (!google || !google.clientSecret || !google.redirectUri) {
3131
- return setApiResponse5(
3132
- HTTP5.NOT_IMPLEMENTED,
3229
+ return setApiResponse6(
3230
+ HTTP6.NOT_IMPLEMENTED,
3133
3231
  "NOT_CONFIGURED",
3134
3232
  "Google OAuth not configured"
3135
3233
  );
@@ -3137,14 +3235,14 @@ function oauthController(store, config, bus) {
3137
3235
  const url = new URL(ctx.request.url);
3138
3236
  const code = url.searchParams.get("code");
3139
3237
  if (!code) {
3140
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3238
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3141
3239
  }
3142
3240
  const returnedState = url.searchParams.get("state") ?? "";
3143
3241
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3144
3242
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3145
3243
  if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
3146
- return setApiResponse5(
3147
- HTTP5.BAD_REQUEST,
3244
+ return setApiResponse6(
3245
+ HTTP6.BAD_REQUEST,
3148
3246
  "GOOGLE_AUTH_FAILED",
3149
3247
  "OAuth state mismatch \u2014 restart the sign-in flow"
3150
3248
  );
@@ -3162,8 +3260,8 @@ function oauthController(store, config, bus) {
3162
3260
  });
3163
3261
  const tokenData = await tokenRes.json();
3164
3262
  if (!tokenData.id_token) {
3165
- return setApiResponse5(
3166
- HTTP5.BAD_REQUEST,
3263
+ return setApiResponse6(
3264
+ HTTP6.BAD_REQUEST,
3167
3265
  "GOOGLE_AUTH_FAILED",
3168
3266
  "OAuth token exchange failed"
3169
3267
  );
@@ -3172,20 +3270,20 @@ function oauthController(store, config, bus) {
3172
3270
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
3173
3271
  );
3174
3272
  if (payload.aud !== google.clientId) {
3175
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3273
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3176
3274
  }
3177
3275
  if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
3178
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3276
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3179
3277
  }
3180
3278
  if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
3181
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3279
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3182
3280
  }
3183
3281
  if (!payload.email) {
3184
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3282
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3185
3283
  }
3186
3284
  if (payload.email_verified !== true) {
3187
- return setApiResponse5(
3188
- HTTP5.BAD_REQUEST,
3285
+ return setApiResponse6(
3286
+ HTTP6.BAD_REQUEST,
3189
3287
  "GOOGLE_AUTH_FAILED",
3190
3288
  "Google account email is not verified"
3191
3289
  );
@@ -3199,7 +3297,7 @@ function oauthController(store, config, bus) {
3199
3297
  appleInit: async (_ctx) => {
3200
3298
  const apple = config.apple;
3201
3299
  if (!apple) {
3202
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3300
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3203
3301
  }
3204
3302
  const state = randomBytes4(16).toString("hex");
3205
3303
  const params = new URLSearchParams({
@@ -3230,20 +3328,20 @@ function oauthController(store, config, bus) {
3230
3328
  appleCallback: async (ctx) => {
3231
3329
  const apple = config.apple;
3232
3330
  if (!apple) {
3233
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3331
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3234
3332
  }
3235
3333
  const pre = ctx.meta["body"];
3236
3334
  const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
3237
3335
  const code = form.get("code");
3238
3336
  if (!code) {
3239
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3337
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3240
3338
  }
3241
3339
  const returnedState = form.get("state") ?? "";
3242
3340
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3243
3341
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3244
3342
  if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
3245
- return setApiResponse5(
3246
- HTTP5.BAD_REQUEST,
3343
+ return setApiResponse6(
3344
+ HTTP6.BAD_REQUEST,
3247
3345
  "APPLE_AUTH_FAILED",
3248
3346
  "OAuth state mismatch \u2014 restart the sign-in flow"
3249
3347
  );
@@ -3261,11 +3359,11 @@ function oauthController(store, config, bus) {
3261
3359
  });
3262
3360
  const tokenData = await tokenRes.json();
3263
3361
  if (!tokenData.id_token) {
3264
- return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3362
+ return setApiResponse6(HTTP6.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3265
3363
  }
3266
3364
  const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
3267
3365
  if (!claims) {
3268
- return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3366
+ return setApiResponse6(HTTP6.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3269
3367
  }
3270
3368
  return completeOAuthLogin(ctx, "apple", {
3271
3369
  email: claims.email,
@@ -3276,12 +3374,12 @@ function oauthController(store, config, bus) {
3276
3374
  appleNative: async (ctx) => {
3277
3375
  const apple = config.apple;
3278
3376
  if (!apple) {
3279
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3377
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3280
3378
  }
3281
3379
  const body = ctx.meta["body"];
3282
3380
  const identityToken = body?.identityToken;
3283
3381
  if (!identityToken) {
3284
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3382
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3285
3383
  }
3286
3384
  const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
3287
3385
  const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
@@ -3295,7 +3393,7 @@ function oauthController(store, config, bus) {
3295
3393
  failureReason: "invalid_identity_token",
3296
3394
  ...meta
3297
3395
  });
3298
- return setApiResponse5(HTTP5.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3396
+ return setApiResponse6(HTTP6.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3299
3397
  }
3300
3398
  const tokenHash = createHash4("sha256").update(identityToken).digest("hex");
3301
3399
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
@@ -3304,14 +3402,14 @@ function oauthController(store, config, bus) {
3304
3402
  const meta = requestMeta(ctx);
3305
3403
  await loginEvents.recordSafe({
3306
3404
  userId: null,
3307
- emailAttempted: claims.email ?? null,
3405
+ emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
3308
3406
  method: "oauth-apple",
3309
3407
  outcome: "failed",
3310
3408
  failureReason: "token_replayed",
3311
3409
  ...meta
3312
3410
  });
3313
- return setApiResponse5(
3314
- HTTP5.UNAUTHORIZED,
3411
+ return setApiResponse6(
3412
+ HTTP6.UNAUTHORIZED,
3315
3413
  "APPLE_AUTH_FAILED",
3316
3414
  "This Apple identity token has already been used"
3317
3415
  );
@@ -3328,12 +3426,12 @@ function oauthController(store, config, bus) {
3328
3426
  googleNative: async (ctx) => {
3329
3427
  const audiences = config.google?.nativeClientIds ?? [];
3330
3428
  if (!config.google || audiences.length === 0) {
3331
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3429
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3332
3430
  }
3333
3431
  const body = ctx.meta["body"];
3334
3432
  const idToken = body?.idToken;
3335
3433
  if (!idToken) {
3336
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3434
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3337
3435
  }
3338
3436
  const claims = await verifyGoogleIdToken(idToken, { audiences, nonce: body?.nonce });
3339
3437
  if (!claims) {
@@ -3345,20 +3443,20 @@ function oauthController(store, config, bus) {
3345
3443
  failureReason: "invalid_identity_token",
3346
3444
  ...requestMeta(ctx)
3347
3445
  });
3348
- return setApiResponse5(HTTP5.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3446
+ return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3349
3447
  }
3350
3448
  const tokenHash = createHash4("sha256").update(idToken).digest("hex");
3351
3449
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3352
3450
  if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3353
3451
  await loginEvents.recordSafe({
3354
3452
  userId: null,
3355
- emailAttempted: claims.email ?? null,
3453
+ emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
3356
3454
  method: "oauth-google",
3357
3455
  outcome: "failed",
3358
3456
  failureReason: "token_replayed",
3359
3457
  ...requestMeta(ctx)
3360
3458
  });
3361
- return setApiResponse5(HTTP5.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3459
+ return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3362
3460
  }
3363
3461
  consumedTokens.sweepExpiredSafe();
3364
3462
  return completeOAuthLogin(ctx, "google", {
@@ -3404,7 +3502,7 @@ function buildAuthRoutes(store, config, bus, locales) {
3404
3502
  "providers",
3405
3503
  "GET",
3406
3504
  "/auth/providers",
3407
- async () => setApiResponse6(HTTP6.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3505
+ async () => setApiResponse7(HTTP7.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3408
3506
  providers: [...config.providers]
3409
3507
  })
3410
3508
  ),
@@ -3668,7 +3766,7 @@ function extractToken(request) {
3668
3766
  }
3669
3767
 
3670
3768
  // src/admin.ts
3671
- import { HTTP as HTTP7, encodeKeysetCursor as encodeKeysetCursor2, setApiResponse as setApiResponse7 } from "@fonderie/core";
3769
+ import { HTTP as HTTP8, encodeKeysetCursor as encodeKeysetCursor2, setApiResponse as setApiResponse8 } from "@fonderie/core";
3672
3770
  import { background as background6 } from "@fonderie/core";
3673
3771
  function toAdminUserDTO(user) {
3674
3772
  return {
@@ -3685,7 +3783,7 @@ function toAdminUserPageDTO(page) {
3685
3783
  }
3686
3784
  var ADMIN_PREFIX = "/_admin";
3687
3785
  var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
3688
- var NOT_FOUND = () => setApiResponse7(HTTP7.NOT_FOUND, "NOT_FOUND", "No such user");
3786
+ var NOT_FOUND = () => setApiResponse8(HTTP8.NOT_FOUND, "NOT_FOUND", "No such user");
3689
3787
  function adminRouteTable(store, bus) {
3690
3788
  const users = new UserModel(store);
3691
3789
  const sessions = new SessionModel(store);
@@ -3694,8 +3792,8 @@ function adminRouteTable(store, bus) {
3694
3792
  const ok = await users.setSuspended(idOf(ctx), suspended);
3695
3793
  if (!ok) return NOT_FOUND();
3696
3794
  const user = await users.findById(idOf(ctx));
3697
- return user ? setApiResponse7(
3698
- HTTP7.OK,
3795
+ return user ? setApiResponse8(
3796
+ HTTP8.OK,
3699
3797
  suspended ? "USER_SUSPENDED" : "USER_UNSUSPENDED",
3700
3798
  suspended ? "User suspended" : "User unsuspended",
3701
3799
  toAdminUserDTO(user)
@@ -3710,24 +3808,25 @@ function adminRouteTable(store, bus) {
3710
3808
  // see anything cannot discover who signed up this morning.
3711
3809
  async (ctx) => {
3712
3810
  const params = new URL(ctx.request.url).searchParams;
3713
- const email2 = params.get("email")?.trim().toLowerCase();
3811
+ const raw = params.get("email")?.trim();
3812
+ const email2 = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : void 0;
3714
3813
  if (email2) {
3715
3814
  const user = await users.findByEmail(email2);
3716
- return user ? setApiResponse7(HTTP7.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3815
+ return user ? setApiResponse8(HTTP8.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3717
3816
  }
3718
3817
  const rawLimit = Number(params.get("limit") ?? 50);
3719
3818
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
3720
3819
  const cursorParam = params.get("cursor");
3721
3820
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3722
3821
  if (cursorParam && !cursor)
3723
- return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3822
+ return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3724
3823
  const deleted = params.get("deleted") === "1" || params.get("deleted") === "true";
3725
3824
  const page = await users.list({
3726
3825
  limit,
3727
3826
  ...cursor ? { cursor } : {},
3728
3827
  ...deleted ? { deleted } : {}
3729
3828
  });
3730
- return setApiResponse7(HTTP7.OK, "USERS", "Users", toAdminUserPageDTO(page));
3829
+ return setApiResponse8(HTTP8.OK, "USERS", "Users", toAdminUserPageDTO(page));
3731
3830
  }
3732
3831
  ],
3733
3832
  [
@@ -3735,7 +3834,7 @@ function adminRouteTable(store, bus) {
3735
3834
  "/_admin/users/:id",
3736
3835
  async (ctx) => {
3737
3836
  const user = await users.findByIdIncludingDeleted(idOf(ctx));
3738
- return user ? setApiResponse7(HTTP7.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3837
+ return user ? setApiResponse8(HTTP8.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3739
3838
  }
3740
3839
  ],
3741
3840
  [
@@ -3744,8 +3843,8 @@ function adminRouteTable(store, bus) {
3744
3843
  async (ctx) => {
3745
3844
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3746
3845
  const rows = await sessions.listLiveByUser(idOf(ctx));
3747
- return setApiResponse7(
3748
- HTTP7.OK,
3846
+ return setApiResponse8(
3847
+ HTTP8.OK,
3749
3848
  "SESSIONS",
3750
3849
  "Live sessions",
3751
3850
  rows.map((r) => toSessionDTO(r, null))
@@ -3760,7 +3859,7 @@ function adminRouteTable(store, bus) {
3760
3859
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3761
3860
  await sessions.deleteByUser(idOf(ctx));
3762
3861
  await background6(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3763
- return setApiResponse7(HTTP7.OK, "SESSIONS_REVOKED", "All sessions revoked");
3862
+ return setApiResponse8(HTTP8.OK, "SESSIONS_REVOKED", "All sessions revoked");
3764
3863
  }
3765
3864
  ],
3766
3865
  [
@@ -3774,14 +3873,14 @@ function adminRouteTable(store, bus) {
3774
3873
  const cursorParam = params.get("cursor");
3775
3874
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3776
3875
  if (cursorParam && !cursor)
3777
- return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3876
+ return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3778
3877
  const page = await events.listByUser({
3779
3878
  userId: idOf(ctx),
3780
3879
  limit,
3781
3880
  ...cursor ? { cursor } : {}
3782
3881
  });
3783
- return setApiResponse7(
3784
- HTTP7.OK,
3882
+ return setApiResponse8(
3883
+ HTTP8.OK,
3785
3884
  "LOGIN_HISTORY",
3786
3885
  "Login history",
3787
3886
  toLoginHistoryPageDTO(page)
@@ -3814,7 +3913,7 @@ var AuthModule = class {
3814
3913
  name = "@fonderie/auth";
3815
3914
  // Baked in at build time by tsup.base, so the operator's Modules page can
3816
3915
  // say what is actually deployed rather than 'not reported'.
3817
- version = "7.22.0";
3916
+ version = "7.23.0";
3818
3917
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3819
3918
  describeAdmin() {
3820
3919
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };
@@ -4670,7 +4769,9 @@ async function importUser(store, user) {
4670
4769
  placeholders.push(`$${vals.length}`);
4671
4770
  };
4672
4771
  if (user.id !== void 0) add("id", user.id);
4673
- add("email", user.email.toLowerCase().trim());
4772
+ const email2 = normalizeEmailSafe(user.email);
4773
+ if (!email2) throw new Error(`[auth] importUser: '${user.email}' is not an email address`);
4774
+ add("email", email2);
4674
4775
  if (user.passwordHash !== void 0) add("password_hash", user.passwordHash);
4675
4776
  if (user.firstName !== void 0) add("first_name", user.firstName);
4676
4777
  if (user.lastName !== void 0) add("last_name", user.lastName);