@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.cjs CHANGED
@@ -100,7 +100,7 @@ __export(index_exports, {
100
100
  module.exports = __toCommonJS(index_exports);
101
101
 
102
102
  // src/routes.ts
103
- var import_core10 = require("@fonderie/core");
103
+ var import_core11 = require("@fonderie/core");
104
104
  var import_middlewares2 = require("@fonderie/core/middlewares");
105
105
 
106
106
  // src/middlewares/require-email-login.ts
@@ -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
 
@@ -352,6 +389,10 @@ var EVENT_KEYS = {
352
389
  // 'password-changed', 'admin', 'refresh-reuse' (theft signal).
353
390
  sessionRevoked: "fonderie.session.revoked"
354
391
  };
392
+ var deletionGracePeriodDays = (config) => {
393
+ const days = config.accountDeletion?.gracePeriodDays;
394
+ return typeof days === "number" && Number.isFinite(days) && days >= 0 ? days : 30;
395
+ };
355
396
 
356
397
  // src/services/jwt.ts
357
398
  var import_node_crypto = require("crypto");
@@ -768,6 +809,27 @@ var UserModel = class {
768
809
  );
769
810
  return row ?? null;
770
811
  }
812
+ /**
813
+ * An ARCHIVED (soft-deleted, not yet purged) account holding this address.
814
+ * Only for telling its owner — after they prove it is theirs — that it is
815
+ * scheduled for deletion, and for refusing to create a second account on
816
+ * the address meanwhile. Never for signing in.
817
+ */
818
+ async findArchivedByEmail(email2) {
819
+ const [row] = await this.store.query(
820
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NOT NULL`,
821
+ [email2]
822
+ );
823
+ return row ?? null;
824
+ }
825
+ /** As findArchivedByEmail, for a phone number. */
826
+ async findArchivedByPhone(phone2) {
827
+ const [row] = await this.store.query(
828
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NOT NULL`,
829
+ [phone2]
830
+ );
831
+ return row ?? null;
832
+ }
771
833
  async findByPhone(phone2) {
772
834
  const [row] = await this.store.query(
773
835
  `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,
@@ -1622,7 +1684,7 @@ function mfaController(store, config, issuer, bus) {
1622
1684
  // src/controllers/auth.controller.ts
1623
1685
  var import_node_crypto6 = require("crypto");
1624
1686
  var import_events2 = require("@fonderie/events");
1625
- var import_core6 = require("@fonderie/core");
1687
+ var import_core7 = require("@fonderie/core");
1626
1688
 
1627
1689
  // src/services/cooldown.ts
1628
1690
  function checkCooldown(lastSentAt, cooldownMs) {
@@ -1633,37 +1695,26 @@ function checkCooldown(lastSentAt, cooldownMs) {
1633
1695
  // src/controllers/auth.controller.ts
1634
1696
  init_password();
1635
1697
 
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}`;
1698
+ // src/services/pending-deletion.ts
1699
+ var import_core6 = require("@fonderie/core");
1700
+ function deletionDate(deletedAt, config) {
1701
+ return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
1660
1702
  }
1661
- function normalizeEmailSafe(email2) {
1662
- try {
1663
- return normalizeEmail(email2);
1664
- } catch {
1665
- return null;
1666
- }
1703
+ function pendingDeletionResponse(deletedAt, config) {
1704
+ const deleteOn = deletionDate(deletedAt, config);
1705
+ return (0, import_core6.setApiResponse)(
1706
+ import_core6.HTTP.FORBIDDEN,
1707
+ "ACCOUNT_PENDING_DELETION",
1708
+ `This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
1709
+ { requestedAt: deletedAt.toISOString(), deleteOn: deleteOn.toISOString() }
1710
+ );
1711
+ }
1712
+ function archivedAddressResponse() {
1713
+ return (0, import_core6.setApiResponse)(
1714
+ import_core6.HTTP.CONFLICT,
1715
+ "ACCOUNT_PENDING_DELETION",
1716
+ "An account using this address is scheduled for deletion. Sign in to keep it, or wait until it is deleted."
1717
+ );
1667
1718
  }
1668
1719
 
1669
1720
  // src/models/password-reset.model.ts
@@ -1822,7 +1873,7 @@ function extractRefreshToken(ctx) {
1822
1873
  const match = cookie.match(/(?:^|;\s*)refresh_token=([^;]+)/);
1823
1874
  return match?.[1] ?? null;
1824
1875
  }
1825
- function authController(store, config, bus, locales = (0, import_core6.defineLocales)()) {
1876
+ function authController(store, config, bus, locales = (0, import_core7.defineLocales)()) {
1826
1877
  const users = new UserModel(store);
1827
1878
  const sessions = new SessionModel(store, config.location);
1828
1879
  const loginEvents = new LoginEventModel(store, config.location);
@@ -1834,23 +1885,24 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1834
1885
  register: async (ctx) => {
1835
1886
  const body = ctx.meta["body"];
1836
1887
  const { email: email2, password: password2, phone: phone2, firstName = null, lastName = null } = body ?? {};
1837
- const locale = (0, import_core6.canonicalLocale)(body?.["locale"]) ?? locales.default;
1888
+ const locale = (0, import_core7.canonicalLocale)(body?.["locale"]) ?? locales.default;
1838
1889
  if (typeof email2 === "string" && typeof password2 === "string") {
1839
1890
  const normalizedEmail = normalizeEmailSafe(email2);
1840
1891
  if (!normalizedEmail) {
1841
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1892
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1842
1893
  }
1843
1894
  if (password2.length < 8) {
1844
- return (0, import_core6.setApiResponse)(
1845
- import_core6.HTTP.UNPROCESSABLE,
1895
+ return (0, import_core7.setApiResponse)(
1896
+ import_core7.HTTP.UNPROCESSABLE,
1846
1897
  "INVALID_PARAMETER",
1847
1898
  "password must be at least 8 characters"
1848
1899
  );
1849
1900
  }
1850
1901
  const existing = await users.findByEmail(normalizedEmail);
1851
1902
  if (existing) {
1852
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1903
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1853
1904
  }
1905
+ if (await users.findArchivedByEmail(normalizedEmail)) return archivedAddressResponse();
1854
1906
  const passwordHash = await hashPassword(password2);
1855
1907
  const row = await users.create(
1856
1908
  normalizedEmail,
@@ -1860,18 +1912,18 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1860
1912
  locale
1861
1913
  );
1862
1914
  if (!row) {
1863
- return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1915
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1864
1916
  }
1865
1917
  const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1866
1918
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1867
1919
  await emailVerif.create(row.id, pin, expiresAt);
1868
1920
  const user = await users.findById(row.id);
1869
1921
  if (!user) {
1870
- return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1922
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1871
1923
  }
1872
1924
  const reqId = ctx.meta["requestId"];
1873
1925
  const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
1874
- await (0, import_core6.background)(bus?.emit(
1926
+ await (0, import_core7.background)(bus?.emit(
1875
1927
  import_events2.NOTIFICATION_EVENT,
1876
1928
  {
1877
1929
  type: MESSAGE_KEYS.emailRegistration,
@@ -1881,7 +1933,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1881
1933
  },
1882
1934
  reqOpts
1883
1935
  ));
1884
- await (0, import_core6.background)(bus?.emit(
1936
+ await (0, import_core7.background)(bus?.emit(
1885
1937
  EVENT_KEYS.userRegistered,
1886
1938
  {
1887
1939
  userId: user.id,
@@ -1925,8 +1977,9 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1925
1977
  if (isValidPhone(phone2)) {
1926
1978
  const existing = await users.findByPhone(normalizePhone(phone2));
1927
1979
  if (existing) {
1928
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
1980
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
1929
1981
  }
1982
+ if (await users.findArchivedByPhone(normalizePhone(phone2))) return archivedAddressResponse();
1930
1983
  const { id } = await users.findOrCreateByPhone(
1931
1984
  normalizePhone(phone2),
1932
1985
  firstName ?? null,
@@ -1938,11 +1991,11 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1938
1991
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1939
1992
  const user = await users.findById(id);
1940
1993
  if (!user) {
1941
- return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1994
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1942
1995
  }
1943
1996
  const reqId2 = ctx.meta["requestId"];
1944
1997
  const reqOpts2 = reqId2 !== void 0 ? { requestId: reqId2 } : void 0;
1945
- await (0, import_core6.background)(bus?.emit(
1998
+ await (0, import_core7.background)(bus?.emit(
1946
1999
  import_events2.NOTIFICATION_EVENT,
1947
2000
  {
1948
2001
  type: MESSAGE_KEYS.phoneOtp,
@@ -1952,7 +2005,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1952
2005
  },
1953
2006
  reqOpts2
1954
2007
  ));
1955
- await (0, import_core6.background)(bus?.emit(
2008
+ await (0, import_core7.background)(bus?.emit(
1956
2009
  EVENT_KEYS.userRegistered,
1957
2010
  {
1958
2011
  userId: user.id,
@@ -1976,8 +2029,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1976
2029
  { status: 202 }
1977
2030
  );
1978
2031
  }
1979
- return (0, import_core6.setApiResponse)(
1980
- import_core6.HTTP.UNPROCESSABLE,
2032
+ return (0, import_core7.setApiResponse)(
2033
+ import_core7.HTTP.UNPROCESSABLE,
1981
2034
  "INVALID_PARAMETER",
1982
2035
  "Provide email + password or a valid phone number"
1983
2036
  );
@@ -1989,9 +2042,25 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1989
2042
  const { email: rawEmail, password: password2 } = body;
1990
2043
  const email2 = normalizeEmailSafe(rawEmail);
1991
2044
  if (!email2) {
1992
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2045
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1993
2046
  }
1994
2047
  const user = await users.findByEmail(email2);
2048
+ if (!user) {
2049
+ const archived = await users.findArchivedByEmail(email2);
2050
+ if (archived?.passwordHash && archived.deletedAt) {
2051
+ const { valid: valid2 } = await verifyPasswordForLogin(password2, archived.passwordHash, config.legacyVerify);
2052
+ await loginEvents.recordSafe({
2053
+ userId: archived.id,
2054
+ emailAttempted: email2,
2055
+ method: "password",
2056
+ outcome: "failed",
2057
+ failureReason: valid2 ? "pending_deletion" : "bad_password",
2058
+ ...meta
2059
+ });
2060
+ if (valid2) return pendingDeletionResponse(archived.deletedAt, config);
2061
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2062
+ }
2063
+ }
1995
2064
  if (!user || !user.passwordHash) {
1996
2065
  await loginEvents.recordSafe({
1997
2066
  userId: null,
@@ -2001,7 +2070,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2001
2070
  failureReason: "unknown_email",
2002
2071
  ...meta
2003
2072
  });
2004
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2073
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2005
2074
  }
2006
2075
  const { valid, needsRehash } = await verifyPasswordForLogin(
2007
2076
  password2,
@@ -2017,21 +2086,21 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2017
2086
  failureReason: "bad_password",
2018
2087
  ...meta
2019
2088
  });
2020
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2089
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2021
2090
  }
2022
2091
  if (needsRehash) {
2023
2092
  await users.updatePassword(user.id, await hashPassword(password2));
2024
2093
  }
2025
2094
  if (user.suspended) {
2026
- return (0, import_core6.setApiResponse)(
2027
- import_core6.HTTP.FORBIDDEN,
2095
+ return (0, import_core7.setApiResponse)(
2096
+ import_core7.HTTP.FORBIDDEN,
2028
2097
  "ACCOUNT_SUSPENDED",
2029
2098
  "Account suspended. Please contact support."
2030
2099
  );
2031
2100
  }
2032
2101
  if (user.mfaEnabled) {
2033
2102
  const mfaToken = issueMfaPendingToken(user.id, config, "email");
2034
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
2103
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
2035
2104
  mfaToken
2036
2105
  });
2037
2106
  }
@@ -2068,11 +2137,11 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2068
2137
  if (isValidPhone(phone2)) {
2069
2138
  const user = await users.findByPhone(normalizePhone(phone2));
2070
2139
  if (!user) {
2071
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2140
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2072
2141
  }
2073
2142
  if (user.suspended) {
2074
- return (0, import_core6.setApiResponse)(
2075
- import_core6.HTTP.FORBIDDEN,
2143
+ return (0, import_core7.setApiResponse)(
2144
+ import_core7.HTTP.FORBIDDEN,
2076
2145
  "ACCOUNT_SUSPENDED",
2077
2146
  "Account suspended. Please contact support."
2078
2147
  );
@@ -2080,7 +2149,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2080
2149
  const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2081
2150
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
2082
2151
  await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
2083
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2152
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2084
2153
  type: MESSAGE_KEYS.phoneOtp,
2085
2154
  locale: user.locale,
2086
2155
  data: { otp },
@@ -2096,8 +2165,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2096
2165
  { status: 202 }
2097
2166
  );
2098
2167
  }
2099
- return (0, import_core6.setApiResponse)(
2100
- import_core6.HTTP.UNPROCESSABLE,
2168
+ return (0, import_core7.setApiResponse)(
2169
+ import_core7.HTTP.UNPROCESSABLE,
2101
2170
  "INVALID_PARAMETER",
2102
2171
  "Provide email + password or a valid phone number"
2103
2172
  );
@@ -2122,35 +2191,35 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2122
2191
  refresh: async (ctx) => {
2123
2192
  const token = extractRefreshToken(ctx);
2124
2193
  if (!token) {
2125
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2194
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2126
2195
  }
2127
2196
  const payload = verifyToken(token, config);
2128
2197
  if (!payload || payload.type !== "refresh") {
2129
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2198
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2130
2199
  }
2131
2200
  const runtime = config.resolve?.(ctx);
2132
2201
  let issued = null;
2133
2202
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2134
2203
  const found = await sessions.match(token);
2135
2204
  if (found.kind === "unknown") {
2136
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2205
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2137
2206
  }
2138
2207
  if (found.kind === "reused") {
2139
2208
  await sessions.revokeById(found.row.id);
2140
- await (0, import_core6.background)(
2209
+ await (0, import_core7.background)(
2141
2210
  bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
2142
2211
  );
2143
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2212
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2144
2213
  }
2145
2214
  const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2146
2215
  const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2147
2216
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2148
2217
  await sessions.revokeById(found.row.id);
2149
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2218
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2150
2219
  }
2151
2220
  const user = await users.findById(found.row.userId);
2152
2221
  if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2153
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2222
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2154
2223
  }
2155
2224
  const pair = issueTokenPair(user.id, resolvedRefresh, {
2156
2225
  loginMethod: payload.loginMethod ?? "email",
@@ -2163,7 +2232,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2163
2232
  if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
2164
2233
  }
2165
2234
  if (!issued) {
2166
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2235
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2167
2236
  }
2168
2237
  const { accessToken, refreshToken } = issued;
2169
2238
  return Response.json(
@@ -2182,16 +2251,16 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2182
2251
  const body = ctx.meta["body"];
2183
2252
  const rawEmail = body?.["email"];
2184
2253
  if (typeof rawEmail !== "string") {
2185
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2254
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2186
2255
  }
2187
2256
  const email2 = normalizeEmailSafe(rawEmail);
2188
2257
  if (!email2) {
2189
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2258
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2190
2259
  }
2191
2260
  const user = await users.findByEmail(email2);
2192
2261
  if (!user) {
2193
- return (0, import_core6.setApiResponse)(
2194
- import_core6.HTTP.OK,
2262
+ return (0, import_core7.setApiResponse)(
2263
+ import_core7.HTTP.OK,
2195
2264
  "PASSWORD_RESET_EMAIL_SENT",
2196
2265
  "Password reset email sent (if account exists)."
2197
2266
  );
@@ -2200,8 +2269,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2200
2269
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2201
2270
  const remaining = checkCooldown(await passwordReset.findLastSentAt(user.id), cooldown);
2202
2271
  if (remaining > 0) {
2203
- return (0, import_core6.setApiResponse)(
2204
- import_core6.HTTP.OK,
2272
+ return (0, import_core7.setApiResponse)(
2273
+ import_core7.HTTP.OK,
2205
2274
  "PASSWORD_RESET_EMAIL_SENT",
2206
2275
  "Password reset email sent (if account exists)."
2207
2276
  );
@@ -2212,14 +2281,14 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2212
2281
  await passwordReset.create(user.id, pin, token, expiresAt);
2213
2282
  const base = resolved.passwordResetUrl;
2214
2283
  const resetUrl = base ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : "";
2215
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2284
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2216
2285
  type: MESSAGE_KEYS.passwordReset,
2217
2286
  locale: user.locale,
2218
2287
  recipient: { email: email2, phone: null, deviceToken: null },
2219
2288
  data: { pin, token, resetUrl }
2220
2289
  }));
2221
- return (0, import_core6.setApiResponse)(
2222
- import_core6.HTTP.OK,
2290
+ return (0, import_core7.setApiResponse)(
2291
+ import_core7.HTTP.OK,
2223
2292
  "PASSWORD_RESET_EMAIL_SENT",
2224
2293
  "Password reset email sent (if account exists)."
2225
2294
  );
@@ -2230,11 +2299,11 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2230
2299
  const password2 = body?.["password"];
2231
2300
  const rawToken = body?.["token"];
2232
2301
  if (typeof password2 !== "string") {
2233
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2302
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2234
2303
  }
2235
2304
  if (password2.length < 8) {
2236
- return (0, import_core6.setApiResponse)(
2237
- import_core6.HTTP.UNPROCESSABLE,
2305
+ return (0, import_core7.setApiResponse)(
2306
+ import_core7.HTTP.UNPROCESSABLE,
2238
2307
  "INVALID_PARAMETER",
2239
2308
  "password must be at least 8 characters"
2240
2309
  );
@@ -2244,8 +2313,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2244
2313
  row = await passwordReset.findByToken(rawToken.trim());
2245
2314
  } else {
2246
2315
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2247
- return (0, import_core6.setApiResponse)(
2248
- import_core6.HTTP.UNPROCESSABLE,
2316
+ return (0, import_core7.setApiResponse)(
2317
+ import_core7.HTTP.UNPROCESSABLE,
2249
2318
  "INVALID_PARAMETER",
2250
2319
  "a 6-digit pin or a reset token is required"
2251
2320
  );
@@ -2253,33 +2322,41 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2253
2322
  row = await passwordReset.findByPin(raw.trim());
2254
2323
  }
2255
2324
  if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
2256
- return (0, import_core6.setApiResponse)(
2257
- import_core6.HTTP.BAD_REQUEST,
2325
+ return (0, import_core7.setApiResponse)(
2326
+ import_core7.HTTP.BAD_REQUEST,
2258
2327
  "PASSWORD_RESET_FAILED",
2259
2328
  "Invalid or expired reset credentials"
2260
2329
  );
2261
2330
  }
2262
2331
  const passwordHash = await hashPassword(password2);
2263
- await store.transaction(async (tx) => {
2264
- await Promise.all([
2265
- tx.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
2266
- passwordHash,
2267
- row.userId
2268
- ]),
2269
- tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
2270
- // Revoke every session: the reset exists because the account may
2271
- // be compromised — a stolen session must not survive it.
2272
- tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
2273
- ]);
2274
- });
2275
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2332
+ const [changed] = await store.query(
2333
+ `WITH changed AS (
2334
+ UPDATE fonderie_users SET password_hash = $1, updated_at = now()
2335
+ WHERE id = $2 AND deleted_at IS NULL
2336
+ RETURNING id
2337
+ ), spent AS (
2338
+ DELETE FROM fonderie_password_resets WHERE user_id = $2
2339
+ ), revoked AS (
2340
+ DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
2341
+ )
2342
+ SELECT id FROM changed`,
2343
+ [passwordHash, row.userId]
2344
+ );
2345
+ if (!changed) {
2346
+ return (0, import_core7.setApiResponse)(
2347
+ import_core7.HTTP.BAD_REQUEST,
2348
+ "PASSWORD_RESET_FAILED",
2349
+ "Invalid or expired reset credentials"
2350
+ );
2351
+ }
2352
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2276
2353
  },
2277
2354
  verify: async (ctx) => {
2278
2355
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2279
- return (0, import_core6.setApiResponse)(import_core6.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2356
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2280
2357
  }
2281
2358
  if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
2282
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2359
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2283
2360
  verified: true,
2284
2361
  email: ctx.user.email
2285
2362
  });
@@ -2287,8 +2364,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2287
2364
  const body = ctx.meta["body"];
2288
2365
  const raw = body?.["token"];
2289
2366
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2290
- return (0, import_core6.setApiResponse)(
2291
- import_core6.HTTP.UNPROCESSABLE,
2367
+ return (0, import_core7.setApiResponse)(
2368
+ import_core7.HTTP.UNPROCESSABLE,
2292
2369
  "INVALID_PARAMETER",
2293
2370
  "token must be a 6-digit code"
2294
2371
  );
@@ -2306,7 +2383,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2306
2383
  failureReason: "invalid_pin",
2307
2384
  ...phoneMeta
2308
2385
  });
2309
- return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2386
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2310
2387
  }
2311
2388
  if (/* @__PURE__ */ new Date() > record.expiresAt) {
2312
2389
  await phoneVerif.deleteByUser(ctx.user.id);
@@ -2318,8 +2395,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2318
2395
  failureReason: "expired_pin",
2319
2396
  ...phoneMeta
2320
2397
  });
2321
- return (0, import_core6.setApiResponse)(
2322
- import_core6.HTTP.BAD_REQUEST,
2398
+ return (0, import_core7.setApiResponse)(
2399
+ import_core7.HTTP.BAD_REQUEST,
2323
2400
  "VERIFICATION_FAILED",
2324
2401
  "Verification code expired"
2325
2402
  );
@@ -2339,11 +2416,11 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2339
2416
  });
2340
2417
  const verifiedUser = await users.findById(ctx.user.id);
2341
2418
  if (!verifiedUser) {
2342
- return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2419
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2343
2420
  }
2344
2421
  if (verifiedUser.suspended) {
2345
- return (0, import_core6.setApiResponse)(
2346
- import_core6.HTTP.FORBIDDEN,
2422
+ return (0, import_core7.setApiResponse)(
2423
+ import_core7.HTTP.FORBIDDEN,
2347
2424
  "ACCOUNT_SUSPENDED",
2348
2425
  "Account suspended. Please contact support."
2349
2426
  );
@@ -2365,10 +2442,10 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2365
2442
  }
2366
2443
  const row = await emailVerif.findByUser(ctx.user.id, pin);
2367
2444
  if (!row) {
2368
- return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2445
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2369
2446
  }
2370
2447
  if (/* @__PURE__ */ new Date() > row.expiresAt) {
2371
- return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2448
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2372
2449
  }
2373
2450
  await store.transaction(async (tx) => {
2374
2451
  await Promise.all([
@@ -2382,30 +2459,30 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2382
2459
  ])
2383
2460
  ]);
2384
2461
  });
2385
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2462
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2386
2463
  verified: true,
2387
2464
  email: ctx.user.email
2388
2465
  });
2389
2466
  },
2390
2467
  sendVerification: async (ctx) => {
2391
2468
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2392
- return (0, import_core6.setApiResponse)(import_core6.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2469
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2393
2470
  }
2394
2471
  const resolved = { ...config, ...config.resolve?.(ctx) };
2395
2472
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2396
2473
  if (ctx.user.loginMethod === "phone") {
2397
2474
  const phone2 = ctx.user.phone;
2398
2475
  if (!phone2) {
2399
- return (0, import_core6.setApiResponse)(
2400
- import_core6.HTTP.BAD_REQUEST,
2476
+ return (0, import_core7.setApiResponse)(
2477
+ import_core7.HTTP.BAD_REQUEST,
2401
2478
  "NO_PHONE_ON_ACCOUNT",
2402
2479
  "No phone number associated with this account"
2403
2480
  );
2404
2481
  }
2405
2482
  const remaining2 = checkCooldown(await phoneVerif.findLastSentAt(ctx.user.id), cooldown);
2406
2483
  if (remaining2 > 0) {
2407
- return (0, import_core6.setApiResponse)(
2408
- import_core6.HTTP.TOO_MANY_REQUESTS,
2484
+ return (0, import_core7.setApiResponse)(
2485
+ import_core7.HTTP.TOO_MANY_REQUESTS,
2409
2486
  "VERIFICATION_COOLDOWN",
2410
2487
  "Please wait before requesting a new code.",
2411
2488
  {
@@ -2416,35 +2493,35 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2416
2493
  const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2417
2494
  const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
2418
2495
  await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
2419
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2496
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2420
2497
  type: MESSAGE_KEYS.phoneOtp,
2421
2498
  locale: ctx.user.locale,
2422
2499
  data: { otp },
2423
2500
  recipient: { email: null, phone: phone2, deviceToken: null }
2424
2501
  }));
2425
- return (0, import_core6.setApiResponse)(
2426
- import_core6.HTTP.OK,
2502
+ return (0, import_core7.setApiResponse)(
2503
+ import_core7.HTTP.OK,
2427
2504
  "VERIFICATION_SENT",
2428
2505
  "A verification code has been sent to your phone."
2429
2506
  );
2430
2507
  }
2431
2508
  if (!ctx.user.email) {
2432
- return (0, import_core6.setApiResponse)(
2433
- import_core6.HTTP.BAD_REQUEST,
2509
+ return (0, import_core7.setApiResponse)(
2510
+ import_core7.HTTP.BAD_REQUEST,
2434
2511
  "NO_EMAIL_ON_ACCOUNT",
2435
2512
  "No email address associated with this account"
2436
2513
  );
2437
2514
  }
2438
2515
  if (ctx.user.emailVerifiedAt) {
2439
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "EMAIL_VERIFIED", "Email already verified.", {
2516
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "EMAIL_VERIFIED", "Email already verified.", {
2440
2517
  verified: true,
2441
2518
  email: ctx.user.email
2442
2519
  });
2443
2520
  }
2444
2521
  const remaining = checkCooldown(await emailVerif.findLastSentAt(ctx.user.id), cooldown);
2445
2522
  if (remaining > 0) {
2446
- return (0, import_core6.setApiResponse)(
2447
- import_core6.HTTP.TOO_MANY_REQUESTS,
2523
+ return (0, import_core7.setApiResponse)(
2524
+ import_core7.HTTP.TOO_MANY_REQUESTS,
2448
2525
  "VERIFICATION_COOLDOWN",
2449
2526
  "Please wait before requesting a new code.",
2450
2527
  {
@@ -2455,13 +2532,13 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2455
2532
  const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2456
2533
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2457
2534
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2458
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2535
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2459
2536
  type: MESSAGE_KEYS.emailVerification,
2460
2537
  locale: ctx.user.locale,
2461
2538
  recipient: { email: ctx.user.email, phone: null, deviceToken: null },
2462
2539
  data: { pin }
2463
2540
  }));
2464
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "VERIFICATION_SENT", "Verification email sent.", {
2541
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "VERIFICATION_SENT", "Verification email sent.", {
2465
2542
  email: ctx.user.email
2466
2543
  });
2467
2544
  }
@@ -2470,11 +2547,11 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2470
2547
 
2471
2548
  // src/controllers/user.controller.ts
2472
2549
  var import_node_crypto7 = require("crypto");
2473
- var import_core8 = require("@fonderie/core");
2550
+ var import_core9 = require("@fonderie/core");
2474
2551
  var import_events3 = require("@fonderie/events");
2475
2552
 
2476
2553
  // src/dtos/login-activity.ts
2477
- var import_core7 = require("@fonderie/core");
2554
+ var import_core8 = require("@fonderie/core");
2478
2555
  function toLoginEventDTO(row) {
2479
2556
  return {
2480
2557
  id: row.id,
@@ -2491,10 +2568,10 @@ function toLoginEventDTO(row) {
2491
2568
  }
2492
2569
  function toLoginHistoryPageDTO(page) {
2493
2570
  const last = page.events[page.events.length - 1];
2494
- const nextCursor = page.hasMore && last ? (0, import_core7.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
2571
+ const nextCursor = page.hasMore && last ? (0, import_core8.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
2495
2572
  return { events: page.events.map(toLoginEventDTO), nextCursor };
2496
2573
  }
2497
- var decodeLoginCursor = import_core7.decodeKeysetCursor;
2574
+ var decodeLoginCursor = import_core8.decodeKeysetCursor;
2498
2575
  function toSessionDTO(row, currentSid) {
2499
2576
  return {
2500
2577
  id: row.id,
@@ -2525,9 +2602,9 @@ function userController(store, config, bus) {
2525
2602
  me: async (ctx) => {
2526
2603
  const user = await users.findById(ctx.user.id);
2527
2604
  if (!user) {
2528
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2605
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2529
2606
  }
2530
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2607
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2531
2608
  user: toUserDTO(user, ctx.user.phoneVerified)
2532
2609
  });
2533
2610
  },
@@ -2538,14 +2615,14 @@ function userController(store, config, bus) {
2538
2615
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
2539
2616
  const outcomeParam = params.get("outcome");
2540
2617
  if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
2541
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2618
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2542
2619
  }
2543
2620
  const cursorParam = params.get("cursor");
2544
2621
  let cursor = null;
2545
2622
  if (cursorParam) {
2546
2623
  cursor = decodeLoginCursor(cursorParam);
2547
2624
  if (!cursor) {
2548
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2625
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2549
2626
  }
2550
2627
  }
2551
2628
  const query = { userId: ctx.user.id, limit };
@@ -2556,8 +2633,8 @@ function userController(store, config, bus) {
2556
2633
  if (from) query.from = new Date(from);
2557
2634
  if (to) query.to = new Date(to);
2558
2635
  const page = await loginEvents.listByUser(query);
2559
- return (0, import_core8.setApiResponse)(
2560
- import_core8.HTTP.OK,
2636
+ return (0, import_core9.setApiResponse)(
2637
+ import_core9.HTTP.OK,
2561
2638
  "LOGIN_HISTORY_FETCHED",
2562
2639
  "Login history retrieved.",
2563
2640
  toLoginHistoryPageDTO(page)
@@ -2567,35 +2644,35 @@ function userController(store, config, bus) {
2567
2644
  listSessions: async (ctx) => {
2568
2645
  const rows = await sessions.listLiveByUser(ctx.user.id);
2569
2646
  const currentSid = ctx.user.sid ?? null;
2570
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2647
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2571
2648
  sessions: rows.map((r) => toSessionDTO(r, currentSid))
2572
2649
  });
2573
2650
  },
2574
2651
  terminateSession: async (ctx) => {
2575
2652
  const id = ctx.meta["params"]?.["id"];
2576
2653
  if (!id) {
2577
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2654
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2578
2655
  }
2579
2656
  const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
2580
2657
  if (removed === void 0) {
2581
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2658
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2582
2659
  }
2583
- await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2584
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2660
+ await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2661
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2585
2662
  },
2586
2663
  terminateOtherSessions: async (ctx) => {
2587
2664
  const currentSid = ctx.user.sid ?? null;
2588
2665
  if (!currentSid) {
2589
- return (0, import_core8.setApiResponse)(
2590
- import_core8.HTTP.UNPROCESSABLE,
2666
+ return (0, import_core9.setApiResponse)(
2667
+ import_core9.HTTP.UNPROCESSABLE,
2591
2668
  "NO_CURRENT_SESSION",
2592
2669
  "Cannot terminate others without a current session context"
2593
2670
  );
2594
2671
  }
2595
2672
  const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
2596
2673
  const count = sids.length;
2597
- if (count) await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2598
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2674
+ if (count) await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2675
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2599
2676
  },
2600
2677
  updateProfile: async (ctx) => {
2601
2678
  const body = ctx.meta["body"];
@@ -2605,18 +2682,18 @@ function userController(store, config, bus) {
2605
2682
  if (body?.[key] !== void 0) fields[key] = body[key];
2606
2683
  }
2607
2684
  if (Object.keys(fields).length === 0) {
2608
- return (0, import_core8.setApiResponse)(
2609
- import_core8.HTTP.UNPROCESSABLE,
2685
+ return (0, import_core9.setApiResponse)(
2686
+ import_core9.HTTP.UNPROCESSABLE,
2610
2687
  "INVALID_PARAMETER",
2611
2688
  "Provide at least one of: firstName, lastName, avatarUrl"
2612
2689
  );
2613
2690
  }
2614
2691
  const row = await users.update(ctx.user.id, fields);
2615
2692
  if (!row) {
2616
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2693
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2617
2694
  }
2618
2695
  const updated = await users.findById(ctx.user.id);
2619
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
2696
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
2620
2697
  user: toUserDTO(updated, ctx.user.phoneVerified)
2621
2698
  });
2622
2699
  },
@@ -2633,14 +2710,14 @@ function userController(store, config, bus) {
2633
2710
  if (Object.keys(patch).length > 0) fields.patch = patch;
2634
2711
  const row = await users.updatePreferences(ctx.user.id, fields);
2635
2712
  if (!row) {
2636
- return (0, import_core8.setApiResponse)(
2637
- import_core8.HTTP.UNPROCESSABLE,
2713
+ return (0, import_core9.setApiResponse)(
2714
+ import_core9.HTTP.UNPROCESSABLE,
2638
2715
  "INVALID_PARAMETER",
2639
2716
  "Provide at least one preference field"
2640
2717
  );
2641
2718
  }
2642
2719
  const updated = await users.findById(ctx.user.id);
2643
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2720
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2644
2721
  user: toUserDTO(updated, ctx.user.phoneVerified)
2645
2722
  });
2646
2723
  },
@@ -2649,44 +2726,44 @@ function userController(store, config, bus) {
2649
2726
  const newEmail = body?.["email"];
2650
2727
  const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
2651
2728
  if (!normalised) {
2652
- return (0, import_core8.setApiResponse)(
2653
- import_core8.HTTP.UNPROCESSABLE,
2729
+ return (0, import_core9.setApiResponse)(
2730
+ import_core9.HTTP.UNPROCESSABLE,
2654
2731
  "INVALID_PARAMETER",
2655
2732
  "A valid email address is required"
2656
2733
  );
2657
2734
  }
2658
2735
  const oldEmail = ctx.user.email;
2659
2736
  if (normalised === oldEmail) {
2660
- return (0, import_core8.setApiResponse)(
2661
- import_core8.HTTP.UNPROCESSABLE,
2737
+ return (0, import_core9.setApiResponse)(
2738
+ import_core9.HTTP.UNPROCESSABLE,
2662
2739
  "INVALID_PARAMETER",
2663
2740
  "New email must differ from current email"
2664
2741
  );
2665
2742
  }
2666
2743
  const existing = await users.findByEmail(normalised);
2667
2744
  if (existing) {
2668
- return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2745
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2669
2746
  }
2670
2747
  const pin = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2671
2748
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2672
2749
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2673
2750
  await users.updateEmail(ctx.user.id, normalised);
2674
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2751
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2675
2752
  type: MESSAGE_KEYS.emailVerification,
2676
2753
  locale: ctx.user.locale,
2677
2754
  data: { pin },
2678
2755
  recipient: { email: normalised, phone: null, deviceToken: null }
2679
2756
  }));
2680
2757
  if (oldEmail) {
2681
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2758
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2682
2759
  type: MESSAGE_KEYS.emailChanged,
2683
2760
  locale: ctx.user.locale,
2684
2761
  data: { newEmail: normalised },
2685
2762
  recipient: { email: oldEmail, phone: null, deviceToken: null }
2686
2763
  }));
2687
2764
  }
2688
- return (0, import_core8.setApiResponse)(
2689
- import_core8.HTTP.OK,
2765
+ return (0, import_core9.setApiResponse)(
2766
+ import_core9.HTTP.OK,
2690
2767
  "EMAIL_UPDATED",
2691
2768
  "Email updated. A verification code has been sent to your new address.",
2692
2769
  {
@@ -2698,8 +2775,8 @@ function userController(store, config, bus) {
2698
2775
  const body = ctx.meta["body"];
2699
2776
  const newPhone = body?.["phone"];
2700
2777
  if (!isValidPhone2(newPhone)) {
2701
- return (0, import_core8.setApiResponse)(
2702
- import_core8.HTTP.UNPROCESSABLE,
2778
+ return (0, import_core9.setApiResponse)(
2779
+ import_core9.HTTP.UNPROCESSABLE,
2703
2780
  "INVALID_PARAMETER",
2704
2781
  "A valid phone number is required"
2705
2782
  );
@@ -2707,28 +2784,28 @@ function userController(store, config, bus) {
2707
2784
  const normalised = normalizePhone2(newPhone);
2708
2785
  const existing = await users.findByPhone(normalised);
2709
2786
  if (existing) {
2710
- return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2787
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2711
2788
  }
2712
2789
  const otp = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
2713
2790
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2714
2791
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
2715
2792
  await users.updatePhone(ctx.user.id, normalised);
2716
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2793
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2717
2794
  type: MESSAGE_KEYS.phoneOtp,
2718
2795
  locale: ctx.user.locale,
2719
2796
  data: { otp },
2720
2797
  recipient: { email: null, phone: normalised, deviceToken: null }
2721
2798
  }));
2722
2799
  if (ctx.user.email) {
2723
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2800
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2724
2801
  type: MESSAGE_KEYS.phoneChanged,
2725
2802
  locale: ctx.user.locale,
2726
2803
  data: {},
2727
2804
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2728
2805
  }));
2729
2806
  }
2730
- return (0, import_core8.setApiResponse)(
2731
- import_core8.HTTP.OK,
2807
+ return (0, import_core9.setApiResponse)(
2808
+ import_core9.HTTP.OK,
2732
2809
  "PHONE_UPDATED",
2733
2810
  "Phone number updated. A verification code has been sent to your new number.",
2734
2811
  {
@@ -2741,23 +2818,23 @@ function userController(store, config, bus) {
2741
2818
  const currentPassword = body?.["currentPassword"];
2742
2819
  const newPassword = body?.["newPassword"];
2743
2820
  if (typeof currentPassword !== "string" || !currentPassword) {
2744
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2821
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2745
2822
  }
2746
2823
  if (typeof newPassword !== "string" || newPassword.length < 8) {
2747
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2824
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2748
2825
  }
2749
2826
  const user = await users.findById(ctx.user.id);
2750
- if (!user) return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2827
+ if (!user) return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2751
2828
  const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
2752
2829
  const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
2753
2830
  if (!valid) {
2754
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2831
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2755
2832
  }
2756
2833
  const hash = await hashPassword2(newPassword);
2757
2834
  await users.updatePassword(ctx.user.id, hash);
2758
2835
  await sessions.deleteByUser(ctx.user.id);
2759
- await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
2760
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2836
+ await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
2837
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2761
2838
  },
2762
2839
  /**
2763
2840
  * Remove an OAuth provider from the account.
@@ -2776,44 +2853,50 @@ function userController(store, config, bus) {
2776
2853
  const provider = String(ctx.meta["params"]?.["provider"] ?? "");
2777
2854
  const current = await users.findById(ctx.user.id);
2778
2855
  if (!current?.provider) {
2779
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2856
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2780
2857
  }
2781
2858
  if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
2782
- return (0, import_core8.setApiResponse)(
2783
- import_core8.HTTP.NOT_FOUND,
2859
+ return (0, import_core9.setApiResponse)(
2860
+ import_core9.HTTP.NOT_FOUND,
2784
2861
  "NOT_LINKED",
2785
2862
  `This account is not linked to ${provider}.`
2786
2863
  );
2787
2864
  }
2788
2865
  const removed = await users.clearProvider(ctx.user.id);
2789
2866
  if (!removed) {
2790
- return (0, import_core8.setApiResponse)(
2791
- import_core8.HTTP.CONFLICT,
2867
+ return (0, import_core9.setApiResponse)(
2868
+ import_core9.HTTP.CONFLICT,
2792
2869
  "PASSWORD_REQUIRED",
2793
2870
  "Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
2794
2871
  );
2795
2872
  }
2796
2873
  if (ctx.user.email) {
2797
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2874
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2798
2875
  type: MESSAGE_KEYS.oauthUnlinked,
2799
2876
  locale: ctx.user.locale,
2800
2877
  data: { provider: current.provider },
2801
2878
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2802
2879
  }));
2803
2880
  }
2804
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2881
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2805
2882
  },
2806
2883
  deleteMe: async (ctx) => {
2807
2884
  const userId = ctx.user.id;
2808
2885
  await users.softDelete(userId);
2809
2886
  await sessions.deleteByUser(userId);
2887
+ await store.query(
2888
+ `WITH resets AS (DELETE FROM fonderie_password_resets WHERE user_id = $1),
2889
+ emails AS (DELETE FROM fonderie_email_verifications WHERE user_id = $1)
2890
+ DELETE FROM fonderie_phone_verifications WHERE user_id = $1`,
2891
+ [userId]
2892
+ );
2810
2893
  const reqId = ctx.meta["requestId"];
2811
- await (0, import_core8.background)(bus?.emit(
2894
+ await (0, import_core9.background)(bus?.emit(
2812
2895
  EVENT_KEYS.userDeleted,
2813
2896
  { userId },
2814
2897
  reqId !== void 0 ? { requestId: reqId } : void 0
2815
2898
  ));
2816
- await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2899
+ await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2817
2900
  return Response.json(
2818
2901
  { reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
2819
2902
  {
@@ -2827,7 +2910,7 @@ function userController(store, config, bus) {
2827
2910
  // no MFA secret, no session tokens (session metadata only).
2828
2911
  exportMe: async (ctx) => {
2829
2912
  const user = await users.findById(ctx.user.id);
2830
- if (!user) return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2913
+ if (!user) return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2831
2914
  const sessionRows = await sessions.listByUser(ctx.user.id);
2832
2915
  const bundle = {
2833
2916
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2836,8 +2919,8 @@ function userController(store, config, bus) {
2836
2919
  id: s.id,
2837
2920
  userAgent: s.userAgent,
2838
2921
  ipAddress: s.ipAddress,
2839
- createdAt: (0, import_core8.dateOrEmpty)(s.createdAt),
2840
- expiresAt: (0, import_core8.dateOrEmpty)(s.expiresAt)
2922
+ createdAt: (0, import_core9.dateOrEmpty)(s.createdAt),
2923
+ expiresAt: (0, import_core9.dateOrEmpty)(s.expiresAt)
2841
2924
  })),
2842
2925
  security: {
2843
2926
  mfaEnabled: user.mfaEnabled === true,
@@ -2856,7 +2939,7 @@ function userController(store, config, bus) {
2856
2939
  }
2857
2940
  bundle["modules"] = modules;
2858
2941
  }
2859
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
2942
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
2860
2943
  }
2861
2944
  };
2862
2945
  }
@@ -2864,7 +2947,7 @@ function userController(store, config, bus) {
2864
2947
  // src/controllers/oauth.controller.ts
2865
2948
  var import_node_crypto9 = require("crypto");
2866
2949
  var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2867
- var import_core9 = require("@fonderie/core");
2950
+ var import_core10 = require("@fonderie/core");
2868
2951
  var import_events4 = require("@fonderie/events");
2869
2952
 
2870
2953
  // src/services/jwks.ts
@@ -3015,7 +3098,7 @@ function oauthController(store, config, bus) {
3015
3098
  const label = provider.charAt(0).toUpperCase() + provider.slice(1);
3016
3099
  const recipient = { email: user.email, phone: null, deviceToken: null };
3017
3100
  if (upserted.clearedUnverifiedPassword && user.email) {
3018
- await (0, import_core9.background)(bus.emit(
3101
+ await (0, import_core10.background)(bus.emit(
3019
3102
  import_events4.NOTIFICATION_EVENT,
3020
3103
  {
3021
3104
  type: MESSAGE_KEYS.passwordRevoked,
@@ -3027,7 +3110,7 @@ function oauthController(store, config, bus) {
3027
3110
  ));
3028
3111
  }
3029
3112
  if (upserted.inserted) {
3030
- await (0, import_core9.background)(bus.emit(
3113
+ await (0, import_core10.background)(bus.emit(
3031
3114
  EVENT_KEYS.userRegistered,
3032
3115
  {
3033
3116
  userId: upserted.id,
@@ -3039,7 +3122,7 @@ function oauthController(store, config, bus) {
3039
3122
  reqOpts
3040
3123
  ));
3041
3124
  if (user.email) {
3042
- await (0, import_core9.background)(bus.emit(
3125
+ await (0, import_core10.background)(bus.emit(
3043
3126
  import_events4.NOTIFICATION_EVENT,
3044
3127
  {
3045
3128
  type: MESSAGE_KEYS.oauthRegistration,
@@ -3053,7 +3136,7 @@ function oauthController(store, config, bus) {
3053
3136
  return;
3054
3137
  }
3055
3138
  if (upserted.previousProvider !== provider && user.email) {
3056
- await (0, import_core9.background)(bus.emit(
3139
+ await (0, import_core10.background)(bus.emit(
3057
3140
  import_events4.NOTIFICATION_EVENT,
3058
3141
  {
3059
3142
  type: MESSAGE_KEYS.oauthLinked,
@@ -3074,14 +3157,29 @@ function oauthController(store, config, bus) {
3074
3157
  const method = provider === "apple" ? "oauth-apple" : "oauth-google";
3075
3158
  const meta = requestMeta(ctx);
3076
3159
  if (!claims.email) {
3077
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3160
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3078
3161
  }
3079
3162
  if (!claims.emailVerified) {
3080
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3163
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3081
3164
  }
3082
3165
  const normalizedEmail = normalizeEmailSafe(claims.email);
3083
3166
  if (!normalizedEmail) {
3084
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3167
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3168
+ }
3169
+ const archived = await users.findArchivedByEmail(normalizedEmail);
3170
+ if (archived?.deletedAt) {
3171
+ await loginEvents.recordSafe({
3172
+ userId: archived.id,
3173
+ emailAttempted: normalizedEmail,
3174
+ method,
3175
+ outcome: "failed",
3176
+ failureReason: "pending_deletion",
3177
+ ...meta
3178
+ });
3179
+ const res = pendingDeletionResponse(archived.deletedAt, config);
3180
+ const headers = cookieHeaders(clearCookies);
3181
+ headers.set("content-type", res.headers.get("content-type") ?? "application/json");
3182
+ return new Response(res.body, { status: res.status, headers });
3085
3183
  }
3086
3184
  const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
3087
3185
  if (!upserted) {
@@ -3093,15 +3191,15 @@ function oauthController(store, config, bus) {
3093
3191
  failureReason: "provider_upsert_failed",
3094
3192
  ...meta
3095
3193
  });
3096
- return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3194
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3097
3195
  }
3098
3196
  const fullUser = await users.findById(upserted.id);
3099
3197
  if (!fullUser) {
3100
- return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3198
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3101
3199
  }
3102
3200
  await announceOAuthUpsert(ctx, upserted, provider, fullUser);
3103
3201
  if (fullUser.suspended) {
3104
- return (0, import_core9.setApiResponse)(import_core9.HTTP.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3202
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3105
3203
  }
3106
3204
  if (fullUser.mfaEnabled) {
3107
3205
  const mfaToken = issueMfaPendingToken(upserted.id, config, provider);
@@ -3144,8 +3242,8 @@ function oauthController(store, config, bus) {
3144
3242
  googleInit: async (_ctx) => {
3145
3243
  const google = config.google;
3146
3244
  if (!google || !google.clientSecret || !google.redirectUri) {
3147
- return (0, import_core9.setApiResponse)(
3148
- import_core9.HTTP.NOT_IMPLEMENTED,
3245
+ return (0, import_core10.setApiResponse)(
3246
+ import_core10.HTTP.NOT_IMPLEMENTED,
3149
3247
  "NOT_CONFIGURED",
3150
3248
  "Google OAuth not configured"
3151
3249
  );
@@ -3179,8 +3277,8 @@ function oauthController(store, config, bus) {
3179
3277
  googleCallback: async (ctx) => {
3180
3278
  const google = config.google;
3181
3279
  if (!google || !google.clientSecret || !google.redirectUri) {
3182
- return (0, import_core9.setApiResponse)(
3183
- import_core9.HTTP.NOT_IMPLEMENTED,
3280
+ return (0, import_core10.setApiResponse)(
3281
+ import_core10.HTTP.NOT_IMPLEMENTED,
3184
3282
  "NOT_CONFIGURED",
3185
3283
  "Google OAuth not configured"
3186
3284
  );
@@ -3188,14 +3286,14 @@ function oauthController(store, config, bus) {
3188
3286
  const url = new URL(ctx.request.url);
3189
3287
  const code = url.searchParams.get("code");
3190
3288
  if (!code) {
3191
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3289
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3192
3290
  }
3193
3291
  const returnedState = url.searchParams.get("state") ?? "";
3194
3292
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3195
3293
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3196
- if (!returnedState || !expectedState || !(0, import_core9.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3197
- return (0, import_core9.setApiResponse)(
3198
- import_core9.HTTP.BAD_REQUEST,
3294
+ if (!returnedState || !expectedState || !(0, import_core10.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3295
+ return (0, import_core10.setApiResponse)(
3296
+ import_core10.HTTP.BAD_REQUEST,
3199
3297
  "GOOGLE_AUTH_FAILED",
3200
3298
  "OAuth state mismatch \u2014 restart the sign-in flow"
3201
3299
  );
@@ -3213,8 +3311,8 @@ function oauthController(store, config, bus) {
3213
3311
  });
3214
3312
  const tokenData = await tokenRes.json();
3215
3313
  if (!tokenData.id_token) {
3216
- return (0, import_core9.setApiResponse)(
3217
- import_core9.HTTP.BAD_REQUEST,
3314
+ return (0, import_core10.setApiResponse)(
3315
+ import_core10.HTTP.BAD_REQUEST,
3218
3316
  "GOOGLE_AUTH_FAILED",
3219
3317
  "OAuth token exchange failed"
3220
3318
  );
@@ -3223,20 +3321,20 @@ function oauthController(store, config, bus) {
3223
3321
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
3224
3322
  );
3225
3323
  if (payload.aud !== google.clientId) {
3226
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3324
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3227
3325
  }
3228
3326
  if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
3229
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3327
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3230
3328
  }
3231
3329
  if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
3232
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3330
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3233
3331
  }
3234
3332
  if (!payload.email) {
3235
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3333
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3236
3334
  }
3237
3335
  if (payload.email_verified !== true) {
3238
- return (0, import_core9.setApiResponse)(
3239
- import_core9.HTTP.BAD_REQUEST,
3336
+ return (0, import_core10.setApiResponse)(
3337
+ import_core10.HTTP.BAD_REQUEST,
3240
3338
  "GOOGLE_AUTH_FAILED",
3241
3339
  "Google account email is not verified"
3242
3340
  );
@@ -3250,7 +3348,7 @@ function oauthController(store, config, bus) {
3250
3348
  appleInit: async (_ctx) => {
3251
3349
  const apple = config.apple;
3252
3350
  if (!apple) {
3253
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3351
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3254
3352
  }
3255
3353
  const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
3256
3354
  const params = new URLSearchParams({
@@ -3281,20 +3379,20 @@ function oauthController(store, config, bus) {
3281
3379
  appleCallback: async (ctx) => {
3282
3380
  const apple = config.apple;
3283
3381
  if (!apple) {
3284
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3382
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3285
3383
  }
3286
3384
  const pre = ctx.meta["body"];
3287
3385
  const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
3288
3386
  const code = form.get("code");
3289
3387
  if (!code) {
3290
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3388
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3291
3389
  }
3292
3390
  const returnedState = form.get("state") ?? "";
3293
3391
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3294
3392
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3295
- if (!returnedState || !expectedState || !(0, import_core9.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3296
- return (0, import_core9.setApiResponse)(
3297
- import_core9.HTTP.BAD_REQUEST,
3393
+ if (!returnedState || !expectedState || !(0, import_core10.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3394
+ return (0, import_core10.setApiResponse)(
3395
+ import_core10.HTTP.BAD_REQUEST,
3298
3396
  "APPLE_AUTH_FAILED",
3299
3397
  "OAuth state mismatch \u2014 restart the sign-in flow"
3300
3398
  );
@@ -3312,11 +3410,11 @@ function oauthController(store, config, bus) {
3312
3410
  });
3313
3411
  const tokenData = await tokenRes.json();
3314
3412
  if (!tokenData.id_token) {
3315
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3413
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3316
3414
  }
3317
3415
  const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
3318
3416
  if (!claims) {
3319
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3417
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3320
3418
  }
3321
3419
  return completeOAuthLogin(ctx, "apple", {
3322
3420
  email: claims.email,
@@ -3327,12 +3425,12 @@ function oauthController(store, config, bus) {
3327
3425
  appleNative: async (ctx) => {
3328
3426
  const apple = config.apple;
3329
3427
  if (!apple) {
3330
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3428
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3331
3429
  }
3332
3430
  const body = ctx.meta["body"];
3333
3431
  const identityToken = body?.identityToken;
3334
3432
  if (!identityToken) {
3335
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3433
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3336
3434
  }
3337
3435
  const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
3338
3436
  const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
@@ -3346,7 +3444,7 @@ function oauthController(store, config, bus) {
3346
3444
  failureReason: "invalid_identity_token",
3347
3445
  ...meta
3348
3446
  });
3349
- return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3447
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3350
3448
  }
3351
3449
  const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(identityToken).digest("hex");
3352
3450
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
@@ -3355,14 +3453,14 @@ function oauthController(store, config, bus) {
3355
3453
  const meta = requestMeta(ctx);
3356
3454
  await loginEvents.recordSafe({
3357
3455
  userId: null,
3358
- emailAttempted: claims.email ?? null,
3456
+ emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
3359
3457
  method: "oauth-apple",
3360
3458
  outcome: "failed",
3361
3459
  failureReason: "token_replayed",
3362
3460
  ...meta
3363
3461
  });
3364
- return (0, import_core9.setApiResponse)(
3365
- import_core9.HTTP.UNAUTHORIZED,
3462
+ return (0, import_core10.setApiResponse)(
3463
+ import_core10.HTTP.UNAUTHORIZED,
3366
3464
  "APPLE_AUTH_FAILED",
3367
3465
  "This Apple identity token has already been used"
3368
3466
  );
@@ -3379,12 +3477,12 @@ function oauthController(store, config, bus) {
3379
3477
  googleNative: async (ctx) => {
3380
3478
  const audiences = config.google?.nativeClientIds ?? [];
3381
3479
  if (!config.google || audiences.length === 0) {
3382
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3480
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3383
3481
  }
3384
3482
  const body = ctx.meta["body"];
3385
3483
  const idToken = body?.idToken;
3386
3484
  if (!idToken) {
3387
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3485
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3388
3486
  }
3389
3487
  const claims = await verifyGoogleIdToken(idToken, { audiences, nonce: body?.nonce });
3390
3488
  if (!claims) {
@@ -3396,20 +3494,20 @@ function oauthController(store, config, bus) {
3396
3494
  failureReason: "invalid_identity_token",
3397
3495
  ...requestMeta(ctx)
3398
3496
  });
3399
- return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3497
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3400
3498
  }
3401
3499
  const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(idToken).digest("hex");
3402
3500
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3403
3501
  if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3404
3502
  await loginEvents.recordSafe({
3405
3503
  userId: null,
3406
- emailAttempted: claims.email ?? null,
3504
+ emailAttempted: claims.email ? normalizeEmailSafe(claims.email) ?? claims.email : null,
3407
3505
  method: "oauth-google",
3408
3506
  outcome: "failed",
3409
3507
  failureReason: "token_replayed",
3410
3508
  ...requestMeta(ctx)
3411
3509
  });
3412
- return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3510
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3413
3511
  }
3414
3512
  consumedTokens.sweepExpiredSafe();
3415
3513
  return completeOAuthLogin(ctx, "google", {
@@ -3455,7 +3553,7 @@ function buildAuthRoutes(store, config, bus, locales) {
3455
3553
  "providers",
3456
3554
  "GET",
3457
3555
  "/auth/providers",
3458
- async () => (0, import_core10.setApiResponse)(import_core10.HTTP.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3556
+ async () => (0, import_core11.setApiResponse)(import_core11.HTTP.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3459
3557
  providers: [...config.providers]
3460
3558
  })
3461
3559
  ),
@@ -3530,20 +3628,20 @@ function buildAuthRoutes(store, config, bus, locales) {
3530
3628
  }
3531
3629
 
3532
3630
  // src/services/config-guard.ts
3533
- var import_core11 = require("@fonderie/core");
3631
+ var import_core12 = require("@fonderie/core");
3534
3632
  var MODULE = "@fonderie/auth";
3535
3633
  function collectAuthConfigProblems(config) {
3536
3634
  const problems = [];
3537
3635
  const secret = config.jwtSecret ?? "";
3538
- const secretProblem = (0, import_core11.secretStrengthProblem)(secret);
3636
+ const secretProblem = (0, import_core12.secretStrengthProblem)(secret);
3539
3637
  if (secretProblem === "too-short") {
3540
3638
  problems.push({
3541
3639
  module: MODULE,
3542
3640
  severity: "error",
3543
- message: `jwtSecret must be at least ${import_core11.MIN_SECRET_LENGTH} characters (got ${secret.length})`,
3641
+ message: `jwtSecret must be at least ${import_core12.MIN_SECRET_LENGTH} characters (got ${secret.length})`,
3544
3642
  domain: "auth",
3545
3643
  reason: "JWT_SECRET_TOO_SHORT",
3546
- metadata: { min: import_core11.MIN_SECRET_LENGTH, got: secret.length }
3644
+ metadata: { min: import_core12.MIN_SECRET_LENGTH, got: secret.length }
3547
3645
  });
3548
3646
  } else if (secretProblem === "placeholder") {
3549
3647
  problems.push({
@@ -3555,7 +3653,7 @@ function collectAuthConfigProblems(config) {
3555
3653
  });
3556
3654
  }
3557
3655
  (config.jwtPreviousSecrets ?? []).forEach((previous, index) => {
3558
- if ((0, import_core11.secretStrengthProblem)(previous ?? "")) {
3656
+ if ((0, import_core12.secretStrengthProblem)(previous ?? "")) {
3559
3657
  problems.push({
3560
3658
  module: MODULE,
3561
3659
  severity: "error",
@@ -3588,7 +3686,7 @@ function collectAuthConfigProblems(config) {
3588
3686
  domain: "auth",
3589
3687
  reason: "GOOGLE_INCOMPLETE"
3590
3688
  });
3591
- } else if (clientSecret && import_core11.PLACEHOLDER_SECRET.test(clientSecret)) {
3689
+ } else if (clientSecret && import_core12.PLACEHOLDER_SECRET.test(clientSecret)) {
3592
3690
  problems.push({
3593
3691
  module: MODULE,
3594
3692
  severity: "error",
@@ -3719,8 +3817,8 @@ function extractToken(request) {
3719
3817
  }
3720
3818
 
3721
3819
  // src/admin.ts
3722
- var import_core12 = require("@fonderie/core");
3723
3820
  var import_core13 = require("@fonderie/core");
3821
+ var import_core14 = require("@fonderie/core");
3724
3822
  function toAdminUserDTO(user) {
3725
3823
  return {
3726
3824
  ...toUserDTO(user),
@@ -3731,12 +3829,12 @@ function toAdminUserDTO(user) {
3731
3829
  }
3732
3830
  function toAdminUserPageDTO(page) {
3733
3831
  const last = page.users[page.users.length - 1];
3734
- const nextCursor = page.hasMore && last ? (0, import_core12.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
3832
+ const nextCursor = page.hasMore && last ? (0, import_core13.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
3735
3833
  return { users: page.users.map(toAdminUserDTO), nextCursor };
3736
3834
  }
3737
3835
  var ADMIN_PREFIX = "/_admin";
3738
3836
  var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
3739
- var NOT_FOUND = () => (0, import_core12.setApiResponse)(import_core12.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
3837
+ var NOT_FOUND = () => (0, import_core13.setApiResponse)(import_core13.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
3740
3838
  function adminRouteTable(store, bus) {
3741
3839
  const users = new UserModel(store);
3742
3840
  const sessions = new SessionModel(store);
@@ -3745,8 +3843,8 @@ function adminRouteTable(store, bus) {
3745
3843
  const ok = await users.setSuspended(idOf(ctx), suspended);
3746
3844
  if (!ok) return NOT_FOUND();
3747
3845
  const user = await users.findById(idOf(ctx));
3748
- return user ? (0, import_core12.setApiResponse)(
3749
- import_core12.HTTP.OK,
3846
+ return user ? (0, import_core13.setApiResponse)(
3847
+ import_core13.HTTP.OK,
3750
3848
  suspended ? "USER_SUSPENDED" : "USER_UNSUSPENDED",
3751
3849
  suspended ? "User suspended" : "User unsuspended",
3752
3850
  toAdminUserDTO(user)
@@ -3761,24 +3859,25 @@ function adminRouteTable(store, bus) {
3761
3859
  // see anything cannot discover who signed up this morning.
3762
3860
  async (ctx) => {
3763
3861
  const params = new URL(ctx.request.url).searchParams;
3764
- const email2 = params.get("email")?.trim().toLowerCase();
3862
+ const raw = params.get("email")?.trim();
3863
+ const email2 = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : void 0;
3765
3864
  if (email2) {
3766
3865
  const user = await users.findByEmail(email2);
3767
- return user ? (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3866
+ return user ? (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3768
3867
  }
3769
3868
  const rawLimit = Number(params.get("limit") ?? 50);
3770
3869
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
3771
3870
  const cursorParam = params.get("cursor");
3772
3871
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3773
3872
  if (cursorParam && !cursor)
3774
- return (0, import_core12.setApiResponse)(import_core12.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3873
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3775
3874
  const deleted = params.get("deleted") === "1" || params.get("deleted") === "true";
3776
3875
  const page = await users.list({
3777
3876
  limit,
3778
3877
  ...cursor ? { cursor } : {},
3779
3878
  ...deleted ? { deleted } : {}
3780
3879
  });
3781
- return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USERS", "Users", toAdminUserPageDTO(page));
3880
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "USERS", "Users", toAdminUserPageDTO(page));
3782
3881
  }
3783
3882
  ],
3784
3883
  [
@@ -3786,7 +3885,7 @@ function adminRouteTable(store, bus) {
3786
3885
  "/_admin/users/:id",
3787
3886
  async (ctx) => {
3788
3887
  const user = await users.findByIdIncludingDeleted(idOf(ctx));
3789
- return user ? (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3888
+ return user ? (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3790
3889
  }
3791
3890
  ],
3792
3891
  [
@@ -3795,8 +3894,8 @@ function adminRouteTable(store, bus) {
3795
3894
  async (ctx) => {
3796
3895
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3797
3896
  const rows = await sessions.listLiveByUser(idOf(ctx));
3798
- return (0, import_core12.setApiResponse)(
3799
- import_core12.HTTP.OK,
3897
+ return (0, import_core13.setApiResponse)(
3898
+ import_core13.HTTP.OK,
3800
3899
  "SESSIONS",
3801
3900
  "Live sessions",
3802
3901
  rows.map((r) => toSessionDTO(r, null))
@@ -3810,8 +3909,8 @@ function adminRouteTable(store, bus) {
3810
3909
  async (ctx) => {
3811
3910
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3812
3911
  await sessions.deleteByUser(idOf(ctx));
3813
- await (0, import_core13.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3814
- return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
3912
+ await (0, import_core14.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3913
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
3815
3914
  }
3816
3915
  ],
3817
3916
  [
@@ -3825,14 +3924,14 @@ function adminRouteTable(store, bus) {
3825
3924
  const cursorParam = params.get("cursor");
3826
3925
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3827
3926
  if (cursorParam && !cursor)
3828
- return (0, import_core12.setApiResponse)(import_core12.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3927
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3829
3928
  const page = await events.listByUser({
3830
3929
  userId: idOf(ctx),
3831
3930
  limit,
3832
3931
  ...cursor ? { cursor } : {}
3833
3932
  });
3834
- return (0, import_core12.setApiResponse)(
3835
- import_core12.HTTP.OK,
3933
+ return (0, import_core13.setApiResponse)(
3934
+ import_core13.HTTP.OK,
3836
3935
  "LOGIN_HISTORY",
3837
3936
  "Login history",
3838
3937
  toLoginHistoryPageDTO(page)
@@ -3865,7 +3964,7 @@ var AuthModule = class {
3865
3964
  name = "@fonderie/auth";
3866
3965
  // Baked in at build time by tsup.base, so the operator's Modules page can
3867
3966
  // say what is actually deployed rather than 'not reported'.
3868
- version = "7.22.0";
3967
+ version = "7.23.0";
3869
3968
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3870
3969
  describeAdmin() {
3871
3970
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };
@@ -3896,7 +3995,7 @@ var AuthModule = class {
3896
3995
  };
3897
3996
 
3898
3997
  // src/templates.ts
3899
- var import_core14 = require("@fonderie/core");
3998
+ var import_core15 = require("@fonderie/core");
3900
3999
 
3901
4000
  // src/templates.es.ts
3902
4001
  var ES_TEMPLATES = {
@@ -4681,7 +4780,7 @@ change your password \u2014 someone else may be able to sign in as you.`
4681
4780
  If you did this, you're all set. If not, contact support right away.`
4682
4781
  }
4683
4782
  };
4684
- var DEFAULT_TEMPLATES = (0, import_core14.withTranslations)(EN_TEMPLATES, {
4783
+ var DEFAULT_TEMPLATES = (0, import_core15.withTranslations)(EN_TEMPLATES, {
4685
4784
  fr: FR_TEMPLATES,
4686
4785
  es: ES_TEMPLATES,
4687
4786
  "zh-Hans": ZH_HANS_TEMPLATES,
@@ -4721,7 +4820,9 @@ async function importUser(store, user) {
4721
4820
  placeholders.push(`$${vals.length}`);
4722
4821
  };
4723
4822
  if (user.id !== void 0) add("id", user.id);
4724
- add("email", user.email.toLowerCase().trim());
4823
+ const email2 = normalizeEmailSafe(user.email);
4824
+ if (!email2) throw new Error(`[auth] importUser: '${user.email}' is not an email address`);
4825
+ add("email", email2);
4725
4826
  if (user.passwordHash !== void 0) add("password_hash", user.passwordHash);
4726
4827
  if (user.firstName !== void 0) add("first_name", user.firstName);
4727
4828
  if (user.lastName !== void 0) add("last_name", user.lastName);