@fonderie/auth 7.22.1 → 7.24.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
@@ -223,13 +223,16 @@ var schemas_exports = {};
223
223
  __export(schemas_exports, {
224
224
  appleNativeSchema: () => appleNativeSchema,
225
225
  changePasswordSchema: () => changePasswordSchema,
226
+ confirmDeletionSchema: () => confirmDeletionSchema,
226
227
  forgotPasswordSchema: () => forgotPasswordSchema,
227
228
  googleNativeSchema: () => googleNativeSchema,
228
229
  loginSchema: () => loginSchema,
229
230
  mfaTokenSchema: () => mfaTokenSchema,
230
231
  refreshSchema: () => refreshSchema,
231
232
  registerSchema: () => registerSchema,
233
+ requestDeletionSchema: () => requestDeletionSchema,
232
234
  resetPasswordSchema: () => resetPasswordSchema,
235
+ restoreAccountSchema: () => restoreAccountSchema,
233
236
  updateEmailSchema: () => updateEmailSchema,
234
237
  updatePhoneSchema: () => updatePhoneSchema,
235
238
  updatePreferencesSchema: () => updatePreferencesSchema,
@@ -309,6 +312,10 @@ var changePasswordSchema = import_zod.z.object({
309
312
  newPassword: password
310
313
  });
311
314
  var mfaTokenSchema = import_zod.z.object({ token: import_zod.z.string().trim().min(6).max(64) });
315
+ var secondFactor = import_zod.z.string().trim().min(6).max(16).optional();
316
+ var requestDeletionSchema = import_zod.z.object({ channel: import_zod.z.enum(["email", "sms"]) });
317
+ var confirmDeletionSchema = import_zod.z.object({ code: sixDigitPin, mfaCode: secondFactor });
318
+ var restoreAccountSchema = import_zod.z.object({ restoreToken: import_zod.z.string().min(20).max(4096), mfaCode: secondFactor });
312
319
 
313
320
  // src/services/cookies.ts
314
321
  function secureAttr(config) {
@@ -374,13 +381,23 @@ var MESSAGE_KEYS = {
374
381
  // arrives already verified by the provider and has nothing to confirm.
375
382
  oauthRegistration: "oauth-registration",
376
383
  oauthLinked: "oauth-linked",
377
- oauthUnlinked: "oauth-unlinked"
384
+ oauthUnlinked: "oauth-unlinked",
385
+ // Account deletion (docs/ACCOUNT-DELETION-DESIGN.md). Each goes to the ONE
386
+ // verified channel the person chose — route these types to both 'email'
387
+ // and 'sms' in courier: auth fills in only the chosen address, and the
388
+ // other channel skips. SMS sends each template's text.
389
+ accountDeletionCode: "account-deletion-code",
390
+ accountDeletionScheduled: "account-deletion-scheduled",
391
+ accountRestored: "account-restored"
378
392
  };
379
393
  var EVENT_KEYS = {
380
394
  userRegistered: "fonderie.user.registered",
381
395
  userDeleted: "fonderie.user.deleted",
382
396
  // Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
383
397
  userPurged: "fonderie.user.purged",
398
+ // An archived account was kept: { userId }. Undo what deletion paused
399
+ // (billing resumes a subscription set to end at the period's end).
400
+ userRestored: "fonderie.user.restored",
384
401
  emailVerified: "fonderie.user.email_verified",
385
402
  passwordChanged: "fonderie.user.password_changed",
386
403
  // Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
@@ -389,6 +406,10 @@ var EVENT_KEYS = {
389
406
  // 'password-changed', 'admin', 'refresh-reuse' (theft signal).
390
407
  sessionRevoked: "fonderie.session.revoked"
391
408
  };
409
+ var deletionGracePeriodDays = (config) => {
410
+ const days = config.accountDeletion?.gracePeriodDays;
411
+ return typeof days === "number" && Number.isFinite(days) && days >= 0 ? days : 30;
412
+ };
392
413
 
393
414
  // src/services/jwt.ts
394
415
  var import_node_crypto = require("crypto");
@@ -805,6 +826,35 @@ var UserModel = class {
805
826
  );
806
827
  return row ?? null;
807
828
  }
829
+ /**
830
+ * An ARCHIVED (soft-deleted, not yet purged) account holding this address.
831
+ * Only for telling its owner — after they prove it is theirs — that it is
832
+ * scheduled for deletion, and for refusing to create a second account on
833
+ * the address meanwhile. Never for signing in.
834
+ */
835
+ async findArchivedByEmail(email2) {
836
+ const [row] = await this.store.query(
837
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NOT NULL`,
838
+ [email2]
839
+ );
840
+ return row ?? null;
841
+ }
842
+ /** An archived account by id (restore). */
843
+ async findArchivedById(id) {
844
+ const [row] = await this.store.query(
845
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NOT NULL`,
846
+ [id]
847
+ );
848
+ return row ?? null;
849
+ }
850
+ /** As findArchivedByEmail, for a phone number. */
851
+ async findArchivedByPhone(phone2) {
852
+ const [row] = await this.store.query(
853
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NOT NULL`,
854
+ [phone2]
855
+ );
856
+ return row ?? null;
857
+ }
808
858
  async findByPhone(phone2) {
809
859
  const [row] = await this.store.query(
810
860
  `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,
@@ -1348,10 +1398,17 @@ var BackupCodeModel = class {
1348
1398
  );
1349
1399
  return rows.map((r) => ({ id: r.id, codeHash: r.code_hash }));
1350
1400
  }
1401
+ /**
1402
+ * Spend a backup code — once. Conditional on still being unused, so two
1403
+ * requests racing with the same code cannot both sign in: the second finds
1404
+ * it spent (false).
1405
+ */
1351
1406
  async consume(id) {
1352
- await this.store.query(`UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1`, [
1353
- id
1354
- ]);
1407
+ const rows = await this.store.query(
1408
+ `UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`,
1409
+ [id]
1410
+ );
1411
+ return rows.length > 0;
1355
1412
  }
1356
1413
  async deleteByUser(userId) {
1357
1414
  await this.store.query(`DELETE FROM fonderie_mfa_backup_codes WHERE user_id = $1`, [userId]);
@@ -1491,6 +1548,7 @@ function mfaController(store, config, issuer, bus) {
1491
1548
  },
1492
1549
  // ── 2. Verify (setup confirm · TOTP login · backup code login) ──
1493
1550
  verify: async (ctx) => {
1551
+ if (ctx.user.deletedAt) return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1494
1552
  const body = ctx.meta["body"];
1495
1553
  const token = body?.["token"];
1496
1554
  const isLoginCompletion = ctx.user.mfaPending === true;
@@ -1545,7 +1603,10 @@ function mfaController(store, config, issuer, bus) {
1545
1603
  if (isLoginCompletion) await failedMfa();
1546
1604
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
1547
1605
  }
1548
- await backupCodes.consume(matched.id);
1606
+ if (!await backupCodes.consume(matched.id)) {
1607
+ if (isLoginCompletion) await failedMfa();
1608
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
1609
+ }
1549
1610
  } else {
1550
1611
  if (!ctx.user.mfaPending) {
1551
1612
  return (0, import_core5.setApiResponse)(
@@ -1659,7 +1720,7 @@ function mfaController(store, config, issuer, bus) {
1659
1720
  // src/controllers/auth.controller.ts
1660
1721
  var import_node_crypto6 = require("crypto");
1661
1722
  var import_events2 = require("@fonderie/events");
1662
- var import_core6 = require("@fonderie/core");
1723
+ var import_core7 = require("@fonderie/core");
1663
1724
 
1664
1725
  // src/services/cooldown.ts
1665
1726
  function checkCooldown(lastSentAt, cooldownMs) {
@@ -1670,6 +1731,72 @@ function checkCooldown(lastSentAt, cooldownMs) {
1670
1731
  // src/controllers/auth.controller.ts
1671
1732
  init_password();
1672
1733
 
1734
+ // src/services/pending-deletion.ts
1735
+ var import_core6 = require("@fonderie/core");
1736
+
1737
+ // src/services/restore-token.ts
1738
+ var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
1739
+ var RESTORE_TTL = "10m";
1740
+ function issueRestoreToken(userId, deletedAt, loginMethod, config) {
1741
+ return import_jsonwebtoken2.default.sign(
1742
+ { sub: userId, type: "restore", loginMethod, archivedAt: deletedAt.toISOString() },
1743
+ config.jwtSecret,
1744
+ { expiresIn: RESTORE_TTL, keyid: keyIdOf(config.jwtSecret) }
1745
+ );
1746
+ }
1747
+ function verifyRestoreToken(token, config) {
1748
+ const payload = verifyToken(token, config);
1749
+ if (!payload || payload.type !== "restore" || typeof payload.sub !== "string" || typeof payload.archivedAt !== "string") return null;
1750
+ return payload;
1751
+ }
1752
+
1753
+ // src/services/pending-deletion.ts
1754
+ function deletionDate(deletedAt, config) {
1755
+ return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
1756
+ }
1757
+ function pendingDeletionResponse(user, loginMethod, config) {
1758
+ const deleteOn = deletionDate(user.deletedAt, config);
1759
+ return (0, import_core6.setApiResponse)(
1760
+ import_core6.HTTP.FORBIDDEN,
1761
+ "ACCOUNT_PENDING_DELETION",
1762
+ `This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
1763
+ {
1764
+ requestedAt: user.deletedAt.toISOString(),
1765
+ deleteOn: deleteOn.toISOString(),
1766
+ restoreToken: issueRestoreToken(user.id, user.deletedAt, loginMethod, config),
1767
+ mfaRequired: user.mfaEnabled === true
1768
+ }
1769
+ );
1770
+ }
1771
+ function archivedAddressResponse() {
1772
+ return (0, import_core6.setApiResponse)(
1773
+ import_core6.HTTP.CONFLICT,
1774
+ "ACCOUNT_PENDING_DELETION",
1775
+ "An account using this address is scheduled for deletion. Sign in to keep it, or wait until it is deleted."
1776
+ );
1777
+ }
1778
+
1779
+ // src/services/second-factor.ts
1780
+ init_password();
1781
+ async function verifySecondFactor(store, config, userId, code) {
1782
+ if (typeof code !== "string") return false;
1783
+ const value = code.trim();
1784
+ if (/^\d{6}$/.test(value)) {
1785
+ const stored = await new UserModel(store).getMfaSecret(userId);
1786
+ if (!stored) return false;
1787
+ return verifyTotpToken(value, makeMfaCipher(config.mfaSecretKey).decrypt(stored));
1788
+ }
1789
+ if (/^[A-Z0-9]{8}$/i.test(value)) {
1790
+ const backupCodes = new BackupCodeModel(store);
1791
+ for (const row of await backupCodes.findUnused(userId)) {
1792
+ if (await verifyPassword(value.toUpperCase(), row.codeHash)) {
1793
+ return backupCodes.consume(row.id);
1794
+ }
1795
+ }
1796
+ }
1797
+ return false;
1798
+ }
1799
+
1673
1800
  // src/models/password-reset.model.ts
1674
1801
  var import_node_crypto5 = require("crypto");
1675
1802
  var hashSecret = (value) => (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
@@ -1826,7 +1953,7 @@ function extractRefreshToken(ctx) {
1826
1953
  const match = cookie.match(/(?:^|;\s*)refresh_token=([^;]+)/);
1827
1954
  return match?.[1] ?? null;
1828
1955
  }
1829
- function authController(store, config, bus, locales = (0, import_core6.defineLocales)()) {
1956
+ function authController(store, config, bus, locales = (0, import_core7.defineLocales)()) {
1830
1957
  const users = new UserModel(store);
1831
1958
  const sessions = new SessionModel(store, config.location);
1832
1959
  const loginEvents = new LoginEventModel(store, config.location);
@@ -1838,23 +1965,24 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1838
1965
  register: async (ctx) => {
1839
1966
  const body = ctx.meta["body"];
1840
1967
  const { email: email2, password: password2, phone: phone2, firstName = null, lastName = null } = body ?? {};
1841
- const locale = (0, import_core6.canonicalLocale)(body?.["locale"]) ?? locales.default;
1968
+ const locale = (0, import_core7.canonicalLocale)(body?.["locale"]) ?? locales.default;
1842
1969
  if (typeof email2 === "string" && typeof password2 === "string") {
1843
1970
  const normalizedEmail = normalizeEmailSafe(email2);
1844
1971
  if (!normalizedEmail) {
1845
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1972
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1846
1973
  }
1847
1974
  if (password2.length < 8) {
1848
- return (0, import_core6.setApiResponse)(
1849
- import_core6.HTTP.UNPROCESSABLE,
1975
+ return (0, import_core7.setApiResponse)(
1976
+ import_core7.HTTP.UNPROCESSABLE,
1850
1977
  "INVALID_PARAMETER",
1851
1978
  "password must be at least 8 characters"
1852
1979
  );
1853
1980
  }
1854
1981
  const existing = await users.findByEmail(normalizedEmail);
1855
1982
  if (existing) {
1856
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1983
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1857
1984
  }
1985
+ if (await users.findArchivedByEmail(normalizedEmail)) return archivedAddressResponse();
1858
1986
  const passwordHash = await hashPassword(password2);
1859
1987
  const row = await users.create(
1860
1988
  normalizedEmail,
@@ -1864,18 +1992,18 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1864
1992
  locale
1865
1993
  );
1866
1994
  if (!row) {
1867
- return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1995
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1868
1996
  }
1869
1997
  const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
1870
1998
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1871
1999
  await emailVerif.create(row.id, pin, expiresAt);
1872
2000
  const user = await users.findById(row.id);
1873
2001
  if (!user) {
1874
- return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
2002
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1875
2003
  }
1876
2004
  const reqId = ctx.meta["requestId"];
1877
2005
  const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
1878
- await (0, import_core6.background)(bus?.emit(
2006
+ await (0, import_core7.background)(bus?.emit(
1879
2007
  import_events2.NOTIFICATION_EVENT,
1880
2008
  {
1881
2009
  type: MESSAGE_KEYS.emailRegistration,
@@ -1885,7 +2013,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1885
2013
  },
1886
2014
  reqOpts
1887
2015
  ));
1888
- await (0, import_core6.background)(bus?.emit(
2016
+ await (0, import_core7.background)(bus?.emit(
1889
2017
  EVENT_KEYS.userRegistered,
1890
2018
  {
1891
2019
  userId: user.id,
@@ -1929,8 +2057,9 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1929
2057
  if (isValidPhone(phone2)) {
1930
2058
  const existing = await users.findByPhone(normalizePhone(phone2));
1931
2059
  if (existing) {
1932
- return (0, import_core6.setApiResponse)(import_core6.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
2060
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
1933
2061
  }
2062
+ if (await users.findArchivedByPhone(normalizePhone(phone2))) return archivedAddressResponse();
1934
2063
  const { id } = await users.findOrCreateByPhone(
1935
2064
  normalizePhone(phone2),
1936
2065
  firstName ?? null,
@@ -1942,11 +2071,11 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1942
2071
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1943
2072
  const user = await users.findById(id);
1944
2073
  if (!user) {
1945
- return (0, import_core6.setApiResponse)(import_core6.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
2074
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1946
2075
  }
1947
2076
  const reqId2 = ctx.meta["requestId"];
1948
2077
  const reqOpts2 = reqId2 !== void 0 ? { requestId: reqId2 } : void 0;
1949
- await (0, import_core6.background)(bus?.emit(
2078
+ await (0, import_core7.background)(bus?.emit(
1950
2079
  import_events2.NOTIFICATION_EVENT,
1951
2080
  {
1952
2081
  type: MESSAGE_KEYS.phoneOtp,
@@ -1956,7 +2085,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1956
2085
  },
1957
2086
  reqOpts2
1958
2087
  ));
1959
- await (0, import_core6.background)(bus?.emit(
2088
+ await (0, import_core7.background)(bus?.emit(
1960
2089
  EVENT_KEYS.userRegistered,
1961
2090
  {
1962
2091
  userId: user.id,
@@ -1980,8 +2109,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1980
2109
  { status: 202 }
1981
2110
  );
1982
2111
  }
1983
- return (0, import_core6.setApiResponse)(
1984
- import_core6.HTTP.UNPROCESSABLE,
2112
+ return (0, import_core7.setApiResponse)(
2113
+ import_core7.HTTP.UNPROCESSABLE,
1985
2114
  "INVALID_PARAMETER",
1986
2115
  "Provide email + password or a valid phone number"
1987
2116
  );
@@ -1993,9 +2122,25 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
1993
2122
  const { email: rawEmail, password: password2 } = body;
1994
2123
  const email2 = normalizeEmailSafe(rawEmail);
1995
2124
  if (!email2) {
1996
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2125
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1997
2126
  }
1998
2127
  const user = await users.findByEmail(email2);
2128
+ if (!user) {
2129
+ const archived = await users.findArchivedByEmail(email2);
2130
+ if (archived?.passwordHash && archived.deletedAt) {
2131
+ const { valid: valid2 } = await verifyPasswordForLogin(password2, archived.passwordHash, config.legacyVerify);
2132
+ await loginEvents.recordSafe({
2133
+ userId: archived.id,
2134
+ emailAttempted: email2,
2135
+ method: "password",
2136
+ outcome: "failed",
2137
+ failureReason: valid2 ? "pending_deletion" : "bad_password",
2138
+ ...meta
2139
+ });
2140
+ if (valid2) return pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, "email", config);
2141
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2142
+ }
2143
+ }
1999
2144
  if (!user || !user.passwordHash) {
2000
2145
  await loginEvents.recordSafe({
2001
2146
  userId: null,
@@ -2005,7 +2150,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2005
2150
  failureReason: "unknown_email",
2006
2151
  ...meta
2007
2152
  });
2008
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2153
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2009
2154
  }
2010
2155
  const { valid, needsRehash } = await verifyPasswordForLogin(
2011
2156
  password2,
@@ -2021,21 +2166,21 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2021
2166
  failureReason: "bad_password",
2022
2167
  ...meta
2023
2168
  });
2024
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2169
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2025
2170
  }
2026
2171
  if (needsRehash) {
2027
2172
  await users.updatePassword(user.id, await hashPassword(password2));
2028
2173
  }
2029
2174
  if (user.suspended) {
2030
- return (0, import_core6.setApiResponse)(
2031
- import_core6.HTTP.FORBIDDEN,
2175
+ return (0, import_core7.setApiResponse)(
2176
+ import_core7.HTTP.FORBIDDEN,
2032
2177
  "ACCOUNT_SUSPENDED",
2033
2178
  "Account suspended. Please contact support."
2034
2179
  );
2035
2180
  }
2036
2181
  if (user.mfaEnabled) {
2037
2182
  const mfaToken = issueMfaPendingToken(user.id, config, "email");
2038
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
2183
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
2039
2184
  mfaToken
2040
2185
  });
2041
2186
  }
@@ -2070,13 +2215,13 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2070
2215
  }
2071
2216
  const phone2 = body?.["phone"];
2072
2217
  if (isValidPhone(phone2)) {
2073
- const user = await users.findByPhone(normalizePhone(phone2));
2218
+ const user = await users.findByPhone(normalizePhone(phone2)) ?? await users.findArchivedByPhone(normalizePhone(phone2));
2074
2219
  if (!user) {
2075
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2220
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2076
2221
  }
2077
2222
  if (user.suspended) {
2078
- return (0, import_core6.setApiResponse)(
2079
- import_core6.HTTP.FORBIDDEN,
2223
+ return (0, import_core7.setApiResponse)(
2224
+ import_core7.HTTP.FORBIDDEN,
2080
2225
  "ACCOUNT_SUSPENDED",
2081
2226
  "Account suspended. Please contact support."
2082
2227
  );
@@ -2084,7 +2229,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2084
2229
  const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2085
2230
  const expiresAt = new Date(Date.now() + OTP_TTL_MS);
2086
2231
  await phoneVerif.upsert(user.id, normalizePhone(phone2), otp, expiresAt);
2087
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2232
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2088
2233
  type: MESSAGE_KEYS.phoneOtp,
2089
2234
  locale: user.locale,
2090
2235
  data: { otp },
@@ -2100,12 +2245,71 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2100
2245
  { status: 202 }
2101
2246
  );
2102
2247
  }
2103
- return (0, import_core6.setApiResponse)(
2104
- import_core6.HTTP.UNPROCESSABLE,
2248
+ return (0, import_core7.setApiResponse)(
2249
+ import_core7.HTTP.UNPROCESSABLE,
2105
2250
  "INVALID_PARAMETER",
2106
2251
  "Provide email + password or a valid phone number"
2107
2252
  );
2108
2253
  },
2254
+ /**
2255
+ * "Keep my account": the restoreToken from a sign-in to an archived account
2256
+ * (proof already given: password, Google / Apple, phone code), plus a
2257
+ * second factor when two-factor is on. Un-archives in one statement —
2258
+ * only the SAME archive the proof was for — then signs the person in.
2259
+ */
2260
+ restoreAccount: async (ctx) => {
2261
+ const body = ctx.meta["body"];
2262
+ const proof = verifyRestoreToken(String(body?.["restoreToken"] ?? ""), config);
2263
+ const invalid = () => (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "RESTORE_TOKEN_INVALID", "Sign in again to keep your account.");
2264
+ if (!proof) return invalid();
2265
+ const archived = await users.findArchivedById(proof.sub);
2266
+ if (!archived?.deletedAt || archived.deletedAt.toISOString() !== proof.archivedAt) return invalid();
2267
+ if (archived.suspended) {
2268
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
2269
+ }
2270
+ if (archived.mfaEnabled && !await verifySecondFactor(store, config, archived.id, body?.["mfaCode"])) {
2271
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to keep your account.");
2272
+ }
2273
+ const [restored] = await store.query(
2274
+ `UPDATE fonderie_users
2275
+ SET deleted_at = NULL, deletion_channel = NULL, deletion_reminded_at = NULL, updated_at = now()
2276
+ -- The SAME archive the proof was for. Postgres keeps microseconds,
2277
+ -- the proof (a JS date) milliseconds: compare at that precision.
2278
+ WHERE id = $1 AND date_trunc('milliseconds', deleted_at) = date_trunc('milliseconds', $2::timestamptz)
2279
+ RETURNING (SELECT deletion_channel FROM fonderie_users WHERE id = $1) AS channel`,
2280
+ [archived.id, proof.archivedAt]
2281
+ );
2282
+ if (!restored) return invalid();
2283
+ const meta = requestMeta(ctx);
2284
+ const method = proof.loginMethod === "google" ? "oauth-google" : proof.loginMethod === "apple" ? "oauth-apple" : proof.loginMethod === "phone" ? "phone" : "password";
2285
+ const { accessToken, refreshToken, sid } = issueTokenPair(archived.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2286
+ loginMethod: proof.loginMethod,
2287
+ phoneVerified: proof.loginMethod === "phone"
2288
+ });
2289
+ await sessions.create(archived.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2290
+ await loginEvents.recordSafe({ userId: archived.id, emailAttempted: archived.email, method, outcome: "success", ...meta });
2291
+ const reqId = ctx.meta["requestId"];
2292
+ await (0, import_core7.background)(bus?.emit(EVENT_KEYS.userRestored, { userId: archived.id }, reqId !== void 0 ? { requestId: reqId } : void 0));
2293
+ const viaSms = restored.channel === "sms" || !archived.email && !!archived.phone;
2294
+ const address = viaSms ? archived.phone : archived.email;
2295
+ if (address) {
2296
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2297
+ type: MESSAGE_KEYS.accountRestored,
2298
+ locale: archived.locale,
2299
+ data: {},
2300
+ recipient: viaSms ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
2301
+ }));
2302
+ }
2303
+ const user = await users.findById(archived.id);
2304
+ return Response.json(
2305
+ {
2306
+ reason: "ACCOUNT_RESTORED",
2307
+ explanation: "Your account is active again.",
2308
+ result: { tokens: { access: accessToken, refresh: refreshToken }, user: user ? toUserDTO(user) : null }
2309
+ },
2310
+ { status: 200, headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config)) }
2311
+ );
2312
+ },
2109
2313
  logout: async (ctx) => {
2110
2314
  const sid = ctx.user?.sid;
2111
2315
  if (sid) {
@@ -2126,35 +2330,35 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2126
2330
  refresh: async (ctx) => {
2127
2331
  const token = extractRefreshToken(ctx);
2128
2332
  if (!token) {
2129
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2333
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2130
2334
  }
2131
2335
  const payload = verifyToken(token, config);
2132
2336
  if (!payload || payload.type !== "refresh") {
2133
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2337
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2134
2338
  }
2135
2339
  const runtime = config.resolve?.(ctx);
2136
2340
  let issued = null;
2137
2341
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2138
2342
  const found = await sessions.match(token);
2139
2343
  if (found.kind === "unknown") {
2140
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2344
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2141
2345
  }
2142
2346
  if (found.kind === "reused") {
2143
2347
  await sessions.revokeById(found.row.id);
2144
- await (0, import_core6.background)(
2348
+ await (0, import_core7.background)(
2145
2349
  bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
2146
2350
  );
2147
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2351
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2148
2352
  }
2149
2353
  const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2150
2354
  const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2151
2355
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2152
2356
  await sessions.revokeById(found.row.id);
2153
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2357
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2154
2358
  }
2155
2359
  const user = await users.findById(found.row.userId);
2156
2360
  if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2157
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2361
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2158
2362
  }
2159
2363
  const pair = issueTokenPair(user.id, resolvedRefresh, {
2160
2364
  loginMethod: payload.loginMethod ?? "email",
@@ -2167,7 +2371,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2167
2371
  if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
2168
2372
  }
2169
2373
  if (!issued) {
2170
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2374
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2171
2375
  }
2172
2376
  const { accessToken, refreshToken } = issued;
2173
2377
  return Response.json(
@@ -2186,16 +2390,16 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2186
2390
  const body = ctx.meta["body"];
2187
2391
  const rawEmail = body?.["email"];
2188
2392
  if (typeof rawEmail !== "string") {
2189
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2393
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2190
2394
  }
2191
2395
  const email2 = normalizeEmailSafe(rawEmail);
2192
2396
  if (!email2) {
2193
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2397
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2194
2398
  }
2195
2399
  const user = await users.findByEmail(email2);
2196
2400
  if (!user) {
2197
- return (0, import_core6.setApiResponse)(
2198
- import_core6.HTTP.OK,
2401
+ return (0, import_core7.setApiResponse)(
2402
+ import_core7.HTTP.OK,
2199
2403
  "PASSWORD_RESET_EMAIL_SENT",
2200
2404
  "Password reset email sent (if account exists)."
2201
2405
  );
@@ -2204,8 +2408,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2204
2408
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2205
2409
  const remaining = checkCooldown(await passwordReset.findLastSentAt(user.id), cooldown);
2206
2410
  if (remaining > 0) {
2207
- return (0, import_core6.setApiResponse)(
2208
- import_core6.HTTP.OK,
2411
+ return (0, import_core7.setApiResponse)(
2412
+ import_core7.HTTP.OK,
2209
2413
  "PASSWORD_RESET_EMAIL_SENT",
2210
2414
  "Password reset email sent (if account exists)."
2211
2415
  );
@@ -2216,14 +2420,14 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2216
2420
  await passwordReset.create(user.id, pin, token, expiresAt);
2217
2421
  const base = resolved.passwordResetUrl;
2218
2422
  const resetUrl = base ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : "";
2219
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2423
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2220
2424
  type: MESSAGE_KEYS.passwordReset,
2221
2425
  locale: user.locale,
2222
2426
  recipient: { email: email2, phone: null, deviceToken: null },
2223
2427
  data: { pin, token, resetUrl }
2224
2428
  }));
2225
- return (0, import_core6.setApiResponse)(
2226
- import_core6.HTTP.OK,
2429
+ return (0, import_core7.setApiResponse)(
2430
+ import_core7.HTTP.OK,
2227
2431
  "PASSWORD_RESET_EMAIL_SENT",
2228
2432
  "Password reset email sent (if account exists)."
2229
2433
  );
@@ -2234,56 +2438,72 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2234
2438
  const password2 = body?.["password"];
2235
2439
  const rawToken = body?.["token"];
2236
2440
  if (typeof password2 !== "string") {
2237
- return (0, import_core6.setApiResponse)(import_core6.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2441
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2238
2442
  }
2239
2443
  if (password2.length < 8) {
2240
- return (0, import_core6.setApiResponse)(
2241
- import_core6.HTTP.UNPROCESSABLE,
2444
+ return (0, import_core7.setApiResponse)(
2445
+ import_core7.HTTP.UNPROCESSABLE,
2242
2446
  "INVALID_PARAMETER",
2243
2447
  "password must be at least 8 characters"
2244
2448
  );
2245
2449
  }
2246
2450
  let row;
2451
+ let presented;
2247
2452
  if (typeof rawToken === "string" && rawToken.trim().length >= 32) {
2248
- row = await passwordReset.findByToken(rawToken.trim());
2453
+ presented = rawToken.trim();
2454
+ row = await passwordReset.findByToken(presented);
2249
2455
  } else {
2250
2456
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2251
- return (0, import_core6.setApiResponse)(
2252
- import_core6.HTTP.UNPROCESSABLE,
2457
+ return (0, import_core7.setApiResponse)(
2458
+ import_core7.HTTP.UNPROCESSABLE,
2253
2459
  "INVALID_PARAMETER",
2254
2460
  "a 6-digit pin or a reset token is required"
2255
2461
  );
2256
2462
  }
2257
- row = await passwordReset.findByPin(raw.trim());
2463
+ presented = raw.trim();
2464
+ row = await passwordReset.findByPin(presented);
2258
2465
  }
2259
2466
  if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
2260
- return (0, import_core6.setApiResponse)(
2261
- import_core6.HTTP.BAD_REQUEST,
2467
+ return (0, import_core7.setApiResponse)(
2468
+ import_core7.HTTP.BAD_REQUEST,
2262
2469
  "PASSWORD_RESET_FAILED",
2263
2470
  "Invalid or expired reset credentials"
2264
2471
  );
2265
2472
  }
2266
2473
  const passwordHash = await hashPassword(password2);
2267
- await store.transaction(async (tx) => {
2268
- await Promise.all([
2269
- tx.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
2270
- passwordHash,
2271
- row.userId
2272
- ]),
2273
- tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
2274
- // Revoke every session: the reset exists because the account may
2275
- // be compromised — a stolen session must not survive it.
2276
- tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
2277
- ]);
2278
- });
2279
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2474
+ const [changed] = await store.query(
2475
+ `WITH spent AS (
2476
+ -- The code is spent HERE, and only what this statement spent
2477
+ -- changes a password: two resets racing with one code \u2014 the
2478
+ -- second finds it gone and changes nothing.
2479
+ DELETE FROM fonderie_password_resets
2480
+ WHERE user_id = $2 AND (pin = $3 OR token = $3) AND expires_at > now()
2481
+ RETURNING user_id
2482
+ ), changed AS (
2483
+ UPDATE fonderie_users SET password_hash = $1, updated_at = now()
2484
+ WHERE id IN (SELECT user_id FROM spent) AND deleted_at IS NULL
2485
+ RETURNING id
2486
+ ), revoked AS (
2487
+ DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
2488
+ )
2489
+ SELECT id FROM changed`,
2490
+ [passwordHash, row.userId, hashSecret(presented)]
2491
+ );
2492
+ if (!changed) {
2493
+ return (0, import_core7.setApiResponse)(
2494
+ import_core7.HTTP.BAD_REQUEST,
2495
+ "PASSWORD_RESET_FAILED",
2496
+ "Invalid or expired reset credentials"
2497
+ );
2498
+ }
2499
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2280
2500
  },
2281
2501
  verify: async (ctx) => {
2282
2502
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2283
- return (0, import_core6.setApiResponse)(import_core6.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2503
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2284
2504
  }
2285
2505
  if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
2286
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2506
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2287
2507
  verified: true,
2288
2508
  email: ctx.user.email
2289
2509
  });
@@ -2291,8 +2511,8 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2291
2511
  const body = ctx.meta["body"];
2292
2512
  const raw = body?.["token"];
2293
2513
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2294
- return (0, import_core6.setApiResponse)(
2295
- import_core6.HTTP.UNPROCESSABLE,
2514
+ return (0, import_core7.setApiResponse)(
2515
+ import_core7.HTTP.UNPROCESSABLE,
2296
2516
  "INVALID_PARAMETER",
2297
2517
  "token must be a 6-digit code"
2298
2518
  );
@@ -2310,7 +2530,7 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2310
2530
  failureReason: "invalid_pin",
2311
2531
  ...phoneMeta
2312
2532
  });
2313
- return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2533
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2314
2534
  }
2315
2535
  if (/* @__PURE__ */ new Date() > record.expiresAt) {
2316
2536
  await phoneVerif.deleteByUser(ctx.user.id);
@@ -2322,13 +2542,24 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2322
2542
  failureReason: "expired_pin",
2323
2543
  ...phoneMeta
2324
2544
  });
2325
- return (0, import_core6.setApiResponse)(
2326
- import_core6.HTTP.BAD_REQUEST,
2545
+ return (0, import_core7.setApiResponse)(
2546
+ import_core7.HTTP.BAD_REQUEST,
2327
2547
  "VERIFICATION_FAILED",
2328
2548
  "Verification code expired"
2329
2549
  );
2330
2550
  }
2331
2551
  await phoneVerif.deleteByUser(ctx.user.id);
2552
+ if (ctx.user.deletedAt) {
2553
+ await loginEvents.recordSafe({
2554
+ userId: ctx.user.id,
2555
+ emailAttempted: null,
2556
+ method: "phone",
2557
+ outcome: "failed",
2558
+ failureReason: "pending_deletion",
2559
+ ...phoneMeta
2560
+ });
2561
+ return pendingDeletionResponse({ ...ctx.user, deletedAt: ctx.user.deletedAt }, "phone", config);
2562
+ }
2332
2563
  const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2333
2564
  loginMethod: "phone",
2334
2565
  phoneVerified: true
@@ -2343,11 +2574,11 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2343
2574
  });
2344
2575
  const verifiedUser = await users.findById(ctx.user.id);
2345
2576
  if (!verifiedUser) {
2346
- return (0, import_core6.setApiResponse)(import_core6.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2577
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2347
2578
  }
2348
2579
  if (verifiedUser.suspended) {
2349
- return (0, import_core6.setApiResponse)(
2350
- import_core6.HTTP.FORBIDDEN,
2580
+ return (0, import_core7.setApiResponse)(
2581
+ import_core7.HTTP.FORBIDDEN,
2351
2582
  "ACCOUNT_SUSPENDED",
2352
2583
  "Account suspended. Please contact support."
2353
2584
  );
@@ -2369,10 +2600,10 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2369
2600
  }
2370
2601
  const row = await emailVerif.findByUser(ctx.user.id, pin);
2371
2602
  if (!row) {
2372
- return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2603
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2373
2604
  }
2374
2605
  if (/* @__PURE__ */ new Date() > row.expiresAt) {
2375
- return (0, import_core6.setApiResponse)(import_core6.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2606
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2376
2607
  }
2377
2608
  await store.transaction(async (tx) => {
2378
2609
  await Promise.all([
@@ -2386,30 +2617,30 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2386
2617
  ])
2387
2618
  ]);
2388
2619
  });
2389
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2620
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "VERIFIED", "Email verified successfully.", {
2390
2621
  verified: true,
2391
2622
  email: ctx.user.email
2392
2623
  });
2393
2624
  },
2394
2625
  sendVerification: async (ctx) => {
2395
2626
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2396
- return (0, import_core6.setApiResponse)(import_core6.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2627
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2397
2628
  }
2398
2629
  const resolved = { ...config, ...config.resolve?.(ctx) };
2399
2630
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2400
2631
  if (ctx.user.loginMethod === "phone") {
2401
2632
  const phone2 = ctx.user.phone;
2402
2633
  if (!phone2) {
2403
- return (0, import_core6.setApiResponse)(
2404
- import_core6.HTTP.BAD_REQUEST,
2634
+ return (0, import_core7.setApiResponse)(
2635
+ import_core7.HTTP.BAD_REQUEST,
2405
2636
  "NO_PHONE_ON_ACCOUNT",
2406
2637
  "No phone number associated with this account"
2407
2638
  );
2408
2639
  }
2409
2640
  const remaining2 = checkCooldown(await phoneVerif.findLastSentAt(ctx.user.id), cooldown);
2410
2641
  if (remaining2 > 0) {
2411
- return (0, import_core6.setApiResponse)(
2412
- import_core6.HTTP.TOO_MANY_REQUESTS,
2642
+ return (0, import_core7.setApiResponse)(
2643
+ import_core7.HTTP.TOO_MANY_REQUESTS,
2413
2644
  "VERIFICATION_COOLDOWN",
2414
2645
  "Please wait before requesting a new code.",
2415
2646
  {
@@ -2420,35 +2651,35 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2420
2651
  const otp = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2421
2652
  const expiresAt2 = new Date(Date.now() + OTP_TTL_MS);
2422
2653
  await phoneVerif.upsert(ctx.user.id, phone2, otp, expiresAt2);
2423
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2654
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2424
2655
  type: MESSAGE_KEYS.phoneOtp,
2425
2656
  locale: ctx.user.locale,
2426
2657
  data: { otp },
2427
2658
  recipient: { email: null, phone: phone2, deviceToken: null }
2428
2659
  }));
2429
- return (0, import_core6.setApiResponse)(
2430
- import_core6.HTTP.OK,
2660
+ return (0, import_core7.setApiResponse)(
2661
+ import_core7.HTTP.OK,
2431
2662
  "VERIFICATION_SENT",
2432
2663
  "A verification code has been sent to your phone."
2433
2664
  );
2434
2665
  }
2435
2666
  if (!ctx.user.email) {
2436
- return (0, import_core6.setApiResponse)(
2437
- import_core6.HTTP.BAD_REQUEST,
2667
+ return (0, import_core7.setApiResponse)(
2668
+ import_core7.HTTP.BAD_REQUEST,
2438
2669
  "NO_EMAIL_ON_ACCOUNT",
2439
2670
  "No email address associated with this account"
2440
2671
  );
2441
2672
  }
2442
2673
  if (ctx.user.emailVerifiedAt) {
2443
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "EMAIL_VERIFIED", "Email already verified.", {
2674
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "EMAIL_VERIFIED", "Email already verified.", {
2444
2675
  verified: true,
2445
2676
  email: ctx.user.email
2446
2677
  });
2447
2678
  }
2448
2679
  const remaining = checkCooldown(await emailVerif.findLastSentAt(ctx.user.id), cooldown);
2449
2680
  if (remaining > 0) {
2450
- return (0, import_core6.setApiResponse)(
2451
- import_core6.HTTP.TOO_MANY_REQUESTS,
2681
+ return (0, import_core7.setApiResponse)(
2682
+ import_core7.HTTP.TOO_MANY_REQUESTS,
2452
2683
  "VERIFICATION_COOLDOWN",
2453
2684
  "Please wait before requesting a new code.",
2454
2685
  {
@@ -2459,13 +2690,13 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2459
2690
  const pin = (0, import_node_crypto6.randomInt)(1e5, 1e6).toString();
2460
2691
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2461
2692
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2462
- await (0, import_core6.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2693
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2463
2694
  type: MESSAGE_KEYS.emailVerification,
2464
2695
  locale: ctx.user.locale,
2465
2696
  recipient: { email: ctx.user.email, phone: null, deviceToken: null },
2466
2697
  data: { pin }
2467
2698
  }));
2468
- return (0, import_core6.setApiResponse)(import_core6.HTTP.OK, "VERIFICATION_SENT", "Verification email sent.", {
2699
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.OK, "VERIFICATION_SENT", "Verification email sent.", {
2469
2700
  email: ctx.user.email
2470
2701
  });
2471
2702
  }
@@ -2473,12 +2704,12 @@ function authController(store, config, bus, locales = (0, import_core6.defineLoc
2473
2704
  }
2474
2705
 
2475
2706
  // src/controllers/user.controller.ts
2476
- var import_node_crypto7 = require("crypto");
2477
- var import_core8 = require("@fonderie/core");
2707
+ var import_node_crypto8 = require("crypto");
2708
+ var import_core9 = require("@fonderie/core");
2478
2709
  var import_events3 = require("@fonderie/events");
2479
2710
 
2480
2711
  // src/dtos/login-activity.ts
2481
- var import_core7 = require("@fonderie/core");
2712
+ var import_core8 = require("@fonderie/core");
2482
2713
  function toLoginEventDTO(row) {
2483
2714
  return {
2484
2715
  id: row.id,
@@ -2495,10 +2726,10 @@ function toLoginEventDTO(row) {
2495
2726
  }
2496
2727
  function toLoginHistoryPageDTO(page) {
2497
2728
  const last = page.events[page.events.length - 1];
2498
- const nextCursor = page.hasMore && last ? (0, import_core7.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
2729
+ const nextCursor = page.hasMore && last ? (0, import_core8.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
2499
2730
  return { events: page.events.map(toLoginEventDTO), nextCursor };
2500
2731
  }
2501
- var decodeLoginCursor = import_core7.decodeKeysetCursor;
2732
+ var decodeLoginCursor = import_core8.decodeKeysetCursor;
2502
2733
  function toSessionDTO(row, currentSid) {
2503
2734
  return {
2504
2735
  id: row.id,
@@ -2512,7 +2743,64 @@ function toSessionDTO(row, currentSid) {
2512
2743
  };
2513
2744
  }
2514
2745
 
2746
+ // src/models/account-deletion.model.ts
2747
+ var import_node_crypto7 = require("crypto");
2748
+ var hashCode = (code) => (0, import_node_crypto7.createHash)("sha256").update(code).digest("hex");
2749
+ var DELETION_CODE_MAX_ATTEMPTS = 5;
2750
+ var AccountDeletionModel = class {
2751
+ constructor(store) {
2752
+ this.store = store;
2753
+ }
2754
+ store;
2755
+ /** Replace any pending code (a new request supersedes the old one). */
2756
+ async saveCode(userId, code, channel, expiresAt) {
2757
+ await this.store.query(
2758
+ `INSERT INTO fonderie_account_deletion_codes (user_id, code_hash, channel, attempts, expires_at, created_at)
2759
+ VALUES ($1, $2, $3, 0, $4, now())
2760
+ ON CONFLICT (user_id) DO UPDATE
2761
+ SET code_hash = $2, channel = $3, attempts = 0, expires_at = $4, created_at = now()`,
2762
+ [userId, hashCode(code), channel, expiresAt]
2763
+ );
2764
+ }
2765
+ async lastSentAt(userId) {
2766
+ const [row] = await this.store.query(
2767
+ `SELECT created_at AS "createdAt" FROM fonderie_account_deletion_codes WHERE user_id = $1`,
2768
+ [userId]
2769
+ );
2770
+ return row ? new Date(row.createdAt) : null;
2771
+ }
2772
+ /**
2773
+ * Check a code in ONE statement: a wrong code spends an attempt, a right
2774
+ * one is consumed (deleted) — so a code is good once, and a guesser gets
2775
+ * five tries in total however many requests they race.
2776
+ */
2777
+ async checkCode(userId, code) {
2778
+ const [row] = await this.store.query(
2779
+ `WITH current AS (
2780
+ SELECT user_id, code_hash = $2 AS matched, expires_at <= now() AS expired, attempts, channel
2781
+ FROM fonderie_account_deletion_codes WHERE user_id = $1
2782
+ FOR UPDATE
2783
+ ), spent AS (
2784
+ DELETE FROM fonderie_account_deletion_codes d USING current c
2785
+ WHERE d.user_id = c.user_id AND c.matched AND NOT c.expired AND c.attempts < $3
2786
+ ), missed AS (
2787
+ UPDATE fonderie_account_deletion_codes d SET attempts = d.attempts + 1
2788
+ FROM current c
2789
+ WHERE d.user_id = c.user_id AND NOT (c.matched AND NOT c.expired AND c.attempts < $3)
2790
+ )
2791
+ SELECT matched, expired, attempts, channel FROM current`,
2792
+ [userId, hashCode(code), DELETION_CODE_MAX_ATTEMPTS]
2793
+ );
2794
+ if (!row) return { result: "none" };
2795
+ if (row.attempts >= DELETION_CODE_MAX_ATTEMPTS) return { result: "exhausted" };
2796
+ if (row.expired) return { result: "expired" };
2797
+ return { result: row.matched ? "ok" : "invalid", channel: row.channel };
2798
+ }
2799
+ };
2800
+
2515
2801
  // src/controllers/user.controller.ts
2802
+ var DELETION_CODE_TTL_MS = 15 * 60 * 1e3;
2803
+ var DELETION_CODE_COOLDOWN_MS = 60 * 1e3;
2516
2804
  function normalizePhone2(phone2) {
2517
2805
  return phone2.trim().replace(/[\s()\-\.]/g, "");
2518
2806
  }
@@ -2525,13 +2813,62 @@ function userController(store, config, bus) {
2525
2813
  const loginEvents = new LoginEventModel(store, config.location);
2526
2814
  const emailVerif = new EmailVerificationModel(store);
2527
2815
  const phoneVerif = new PhoneVerificationModel(store);
2816
+ const deletions = new AccountDeletionModel(store);
2817
+ const deletionBlocked = async (userId) => {
2818
+ for (const blocker of config.accountDeletion?.blockers ?? []) {
2819
+ const refusal = await blocker(userId);
2820
+ if (refusal) return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, refusal.reason, refusal.explanation, refusal.details);
2821
+ }
2822
+ return null;
2823
+ };
2824
+ const archive = async (userId, channel) => {
2825
+ const [row] = await store.query(
2826
+ `WITH archived AS (
2827
+ UPDATE fonderie_users
2828
+ SET deleted_at = now(), updated_at = now(), deletion_channel = $2, deletion_reminded_at = NULL
2829
+ WHERE id = $1 AND deleted_at IS NULL
2830
+ RETURNING id, deleted_at
2831
+ ), sessions_ended AS (
2832
+ DELETE FROM fonderie_sessions WHERE user_id = $1
2833
+ ), resets AS (
2834
+ DELETE FROM fonderie_password_resets WHERE user_id = $1
2835
+ ), emails AS (
2836
+ DELETE FROM fonderie_email_verifications WHERE user_id = $1
2837
+ ), phones AS (
2838
+ DELETE FROM fonderie_phone_verifications WHERE user_id = $1
2839
+ ), codes AS (
2840
+ DELETE FROM fonderie_account_deletion_codes WHERE user_id = $1
2841
+ )
2842
+ SELECT deleted_at AS "deletedAt" FROM archived`,
2843
+ [userId, channel]
2844
+ );
2845
+ return row ? new Date(row.deletedAt) : /* @__PURE__ */ new Date();
2846
+ };
2847
+ const announceArchived = async (ctx, deletedAt) => {
2848
+ const userId = ctx.user.id;
2849
+ const reqId = ctx.meta["requestId"];
2850
+ await (0, import_core9.background)(bus?.emit(
2851
+ EVENT_KEYS.userDeleted,
2852
+ { userId, deletedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() },
2853
+ reqId !== void 0 ? { requestId: reqId } : void 0
2854
+ ));
2855
+ await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2856
+ };
2857
+ const archivedResponse = (deletedAt) => Response.json(
2858
+ {
2859
+ reason: "ACCOUNT_DELETED",
2860
+ explanation: "Account closed. It will be permanently deleted on the date shown; sign in before then to keep it.",
2861
+ result: { requestedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() }
2862
+ },
2863
+ { status: 200, headers: cookieHeaders(clearedTokenCookies(config)) }
2864
+ );
2528
2865
  return {
2529
2866
  me: async (ctx) => {
2530
2867
  const user = await users.findById(ctx.user.id);
2531
2868
  if (!user) {
2532
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2869
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2533
2870
  }
2534
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2871
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2535
2872
  user: toUserDTO(user, ctx.user.phoneVerified)
2536
2873
  });
2537
2874
  },
@@ -2542,14 +2879,14 @@ function userController(store, config, bus) {
2542
2879
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
2543
2880
  const outcomeParam = params.get("outcome");
2544
2881
  if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
2545
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2882
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2546
2883
  }
2547
2884
  const cursorParam = params.get("cursor");
2548
2885
  let cursor = null;
2549
2886
  if (cursorParam) {
2550
2887
  cursor = decodeLoginCursor(cursorParam);
2551
2888
  if (!cursor) {
2552
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2889
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2553
2890
  }
2554
2891
  }
2555
2892
  const query = { userId: ctx.user.id, limit };
@@ -2560,8 +2897,8 @@ function userController(store, config, bus) {
2560
2897
  if (from) query.from = new Date(from);
2561
2898
  if (to) query.to = new Date(to);
2562
2899
  const page = await loginEvents.listByUser(query);
2563
- return (0, import_core8.setApiResponse)(
2564
- import_core8.HTTP.OK,
2900
+ return (0, import_core9.setApiResponse)(
2901
+ import_core9.HTTP.OK,
2565
2902
  "LOGIN_HISTORY_FETCHED",
2566
2903
  "Login history retrieved.",
2567
2904
  toLoginHistoryPageDTO(page)
@@ -2571,35 +2908,35 @@ function userController(store, config, bus) {
2571
2908
  listSessions: async (ctx) => {
2572
2909
  const rows = await sessions.listLiveByUser(ctx.user.id);
2573
2910
  const currentSid = ctx.user.sid ?? null;
2574
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2911
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2575
2912
  sessions: rows.map((r) => toSessionDTO(r, currentSid))
2576
2913
  });
2577
2914
  },
2578
2915
  terminateSession: async (ctx) => {
2579
2916
  const id = ctx.meta["params"]?.["id"];
2580
2917
  if (!id) {
2581
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2918
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2582
2919
  }
2583
2920
  const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
2584
2921
  if (removed === void 0) {
2585
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2922
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "Session not found");
2586
2923
  }
2587
- await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2588
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2924
+ await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2925
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2589
2926
  },
2590
2927
  terminateOtherSessions: async (ctx) => {
2591
2928
  const currentSid = ctx.user.sid ?? null;
2592
2929
  if (!currentSid) {
2593
- return (0, import_core8.setApiResponse)(
2594
- import_core8.HTTP.UNPROCESSABLE,
2930
+ return (0, import_core9.setApiResponse)(
2931
+ import_core9.HTTP.UNPROCESSABLE,
2595
2932
  "NO_CURRENT_SESSION",
2596
2933
  "Cannot terminate others without a current session context"
2597
2934
  );
2598
2935
  }
2599
2936
  const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
2600
2937
  const count = sids.length;
2601
- if (count) await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2602
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2938
+ if (count) await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2939
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2603
2940
  },
2604
2941
  updateProfile: async (ctx) => {
2605
2942
  const body = ctx.meta["body"];
@@ -2609,18 +2946,18 @@ function userController(store, config, bus) {
2609
2946
  if (body?.[key] !== void 0) fields[key] = body[key];
2610
2947
  }
2611
2948
  if (Object.keys(fields).length === 0) {
2612
- return (0, import_core8.setApiResponse)(
2613
- import_core8.HTTP.UNPROCESSABLE,
2949
+ return (0, import_core9.setApiResponse)(
2950
+ import_core9.HTTP.UNPROCESSABLE,
2614
2951
  "INVALID_PARAMETER",
2615
2952
  "Provide at least one of: firstName, lastName, avatarUrl"
2616
2953
  );
2617
2954
  }
2618
2955
  const row = await users.update(ctx.user.id, fields);
2619
2956
  if (!row) {
2620
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2957
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2621
2958
  }
2622
2959
  const updated = await users.findById(ctx.user.id);
2623
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
2960
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "PROFILE_UPDATED", "Profile updated.", {
2624
2961
  user: toUserDTO(updated, ctx.user.phoneVerified)
2625
2962
  });
2626
2963
  },
@@ -2637,14 +2974,14 @@ function userController(store, config, bus) {
2637
2974
  if (Object.keys(patch).length > 0) fields.patch = patch;
2638
2975
  const row = await users.updatePreferences(ctx.user.id, fields);
2639
2976
  if (!row) {
2640
- return (0, import_core8.setApiResponse)(
2641
- import_core8.HTTP.UNPROCESSABLE,
2977
+ return (0, import_core9.setApiResponse)(
2978
+ import_core9.HTTP.UNPROCESSABLE,
2642
2979
  "INVALID_PARAMETER",
2643
2980
  "Provide at least one preference field"
2644
2981
  );
2645
2982
  }
2646
2983
  const updated = await users.findById(ctx.user.id);
2647
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2984
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2648
2985
  user: toUserDTO(updated, ctx.user.phoneVerified)
2649
2986
  });
2650
2987
  },
@@ -2653,44 +2990,44 @@ function userController(store, config, bus) {
2653
2990
  const newEmail = body?.["email"];
2654
2991
  const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
2655
2992
  if (!normalised) {
2656
- return (0, import_core8.setApiResponse)(
2657
- import_core8.HTTP.UNPROCESSABLE,
2993
+ return (0, import_core9.setApiResponse)(
2994
+ import_core9.HTTP.UNPROCESSABLE,
2658
2995
  "INVALID_PARAMETER",
2659
2996
  "A valid email address is required"
2660
2997
  );
2661
2998
  }
2662
2999
  const oldEmail = ctx.user.email;
2663
3000
  if (normalised === oldEmail) {
2664
- return (0, import_core8.setApiResponse)(
2665
- import_core8.HTTP.UNPROCESSABLE,
3001
+ return (0, import_core9.setApiResponse)(
3002
+ import_core9.HTTP.UNPROCESSABLE,
2666
3003
  "INVALID_PARAMETER",
2667
3004
  "New email must differ from current email"
2668
3005
  );
2669
3006
  }
2670
3007
  const existing = await users.findByEmail(normalised);
2671
3008
  if (existing) {
2672
- return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
3009
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2673
3010
  }
2674
- const pin = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
3011
+ const pin = (0, import_node_crypto8.randomInt)(1e5, 1e6).toString();
2675
3012
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2676
3013
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2677
3014
  await users.updateEmail(ctx.user.id, normalised);
2678
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3015
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2679
3016
  type: MESSAGE_KEYS.emailVerification,
2680
3017
  locale: ctx.user.locale,
2681
3018
  data: { pin },
2682
3019
  recipient: { email: normalised, phone: null, deviceToken: null }
2683
3020
  }));
2684
3021
  if (oldEmail) {
2685
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3022
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2686
3023
  type: MESSAGE_KEYS.emailChanged,
2687
3024
  locale: ctx.user.locale,
2688
3025
  data: { newEmail: normalised },
2689
3026
  recipient: { email: oldEmail, phone: null, deviceToken: null }
2690
3027
  }));
2691
3028
  }
2692
- return (0, import_core8.setApiResponse)(
2693
- import_core8.HTTP.OK,
3029
+ return (0, import_core9.setApiResponse)(
3030
+ import_core9.HTTP.OK,
2694
3031
  "EMAIL_UPDATED",
2695
3032
  "Email updated. A verification code has been sent to your new address.",
2696
3033
  {
@@ -2702,8 +3039,8 @@ function userController(store, config, bus) {
2702
3039
  const body = ctx.meta["body"];
2703
3040
  const newPhone = body?.["phone"];
2704
3041
  if (!isValidPhone2(newPhone)) {
2705
- return (0, import_core8.setApiResponse)(
2706
- import_core8.HTTP.UNPROCESSABLE,
3042
+ return (0, import_core9.setApiResponse)(
3043
+ import_core9.HTTP.UNPROCESSABLE,
2707
3044
  "INVALID_PARAMETER",
2708
3045
  "A valid phone number is required"
2709
3046
  );
@@ -2711,28 +3048,28 @@ function userController(store, config, bus) {
2711
3048
  const normalised = normalizePhone2(newPhone);
2712
3049
  const existing = await users.findByPhone(normalised);
2713
3050
  if (existing) {
2714
- return (0, import_core8.setApiResponse)(import_core8.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
3051
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2715
3052
  }
2716
- const otp = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
3053
+ const otp = (0, import_node_crypto8.randomInt)(1e5, 1e6).toString();
2717
3054
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2718
3055
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
2719
3056
  await users.updatePhone(ctx.user.id, normalised);
2720
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3057
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2721
3058
  type: MESSAGE_KEYS.phoneOtp,
2722
3059
  locale: ctx.user.locale,
2723
3060
  data: { otp },
2724
3061
  recipient: { email: null, phone: normalised, deviceToken: null }
2725
3062
  }));
2726
3063
  if (ctx.user.email) {
2727
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3064
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2728
3065
  type: MESSAGE_KEYS.phoneChanged,
2729
3066
  locale: ctx.user.locale,
2730
3067
  data: {},
2731
3068
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2732
3069
  }));
2733
3070
  }
2734
- return (0, import_core8.setApiResponse)(
2735
- import_core8.HTTP.OK,
3071
+ return (0, import_core9.setApiResponse)(
3072
+ import_core9.HTTP.OK,
2736
3073
  "PHONE_UPDATED",
2737
3074
  "Phone number updated. A verification code has been sent to your new number.",
2738
3075
  {
@@ -2745,23 +3082,23 @@ function userController(store, config, bus) {
2745
3082
  const currentPassword = body?.["currentPassword"];
2746
3083
  const newPassword = body?.["newPassword"];
2747
3084
  if (typeof currentPassword !== "string" || !currentPassword) {
2748
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
3085
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2749
3086
  }
2750
3087
  if (typeof newPassword !== "string" || newPassword.length < 8) {
2751
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
3088
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2752
3089
  }
2753
3090
  const user = await users.findById(ctx.user.id);
2754
- if (!user) return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
3091
+ if (!user) return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2755
3092
  const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
2756
3093
  const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
2757
3094
  if (!valid) {
2758
- return (0, import_core8.setApiResponse)(import_core8.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
3095
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2759
3096
  }
2760
3097
  const hash = await hashPassword2(newPassword);
2761
3098
  await users.updatePassword(ctx.user.id, hash);
2762
3099
  await sessions.deleteByUser(ctx.user.id);
2763
- await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
2764
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
3100
+ await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
3101
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2765
3102
  },
2766
3103
  /**
2767
3104
  * Remove an OAuth provider from the account.
@@ -2780,58 +3117,129 @@ function userController(store, config, bus) {
2780
3117
  const provider = String(ctx.meta["params"]?.["provider"] ?? "");
2781
3118
  const current = await users.findById(ctx.user.id);
2782
3119
  if (!current?.provider) {
2783
- return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
3120
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2784
3121
  }
2785
3122
  if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
2786
- return (0, import_core8.setApiResponse)(
2787
- import_core8.HTTP.NOT_FOUND,
3123
+ return (0, import_core9.setApiResponse)(
3124
+ import_core9.HTTP.NOT_FOUND,
2788
3125
  "NOT_LINKED",
2789
3126
  `This account is not linked to ${provider}.`
2790
3127
  );
2791
3128
  }
2792
3129
  const removed = await users.clearProvider(ctx.user.id);
2793
3130
  if (!removed) {
2794
- return (0, import_core8.setApiResponse)(
2795
- import_core8.HTTP.CONFLICT,
3131
+ return (0, import_core9.setApiResponse)(
3132
+ import_core9.HTTP.CONFLICT,
2796
3133
  "PASSWORD_REQUIRED",
2797
3134
  "Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
2798
3135
  );
2799
3136
  }
2800
3137
  if (ctx.user.email) {
2801
- await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3138
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2802
3139
  type: MESSAGE_KEYS.oauthUnlinked,
2803
3140
  locale: ctx.user.locale,
2804
3141
  data: { provider: current.provider },
2805
3142
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2806
3143
  }));
2807
3144
  }
2808
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
3145
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2809
3146
  },
3147
+ // Legacy one-call deletion (no code). Kept so existing apps keep working;
3148
+ // apps move to POST /users/me/deletion + /confirm (proof + notices).
2810
3149
  deleteMe: async (ctx) => {
2811
- const userId = ctx.user.id;
2812
- await users.softDelete(userId);
2813
- await sessions.deleteByUser(userId);
2814
- const reqId = ctx.meta["requestId"];
2815
- await (0, import_core8.background)(bus?.emit(
2816
- EVENT_KEYS.userDeleted,
2817
- { userId },
2818
- reqId !== void 0 ? { requestId: reqId } : void 0
2819
- ));
2820
- await (0, import_core8.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2821
- return Response.json(
2822
- { reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
2823
- {
2824
- status: 200,
2825
- headers: cookieHeaders(clearedTokenCookies(config))
2826
- }
2827
- );
3150
+ const deletedAt = await archive(ctx.user.id, null);
3151
+ await announceArchived(ctx, deletedAt);
3152
+ return archivedResponse(deletedAt);
3153
+ },
3154
+ /**
3155
+ * Step 1 of deleting an account: a 6-digit code to the channel the person
3156
+ * picks ('email' | 'sms' — an address on the account; receiving the code
3157
+ * proves they hold it). Refused while a blocker objects (e.g. they own a
3158
+ * team with other members — transfer it first).
3159
+ */
3160
+ requestDeletion: async (ctx) => {
3161
+ const user = ctx.user;
3162
+ const body = ctx.meta["body"];
3163
+ const channel = body?.["channel"] === "sms" ? "sms" : body?.["channel"] === "email" ? "email" : null;
3164
+ if (!channel) {
3165
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "channel must be 'email' or 'sms'");
3166
+ }
3167
+ const address = channel === "email" ? user.email : user.phone;
3168
+ if (!address) {
3169
+ return (0, import_core9.setApiResponse)(
3170
+ import_core9.HTTP.UNPROCESSABLE,
3171
+ channel === "email" ? "NO_EMAIL_ON_ACCOUNT" : "NO_PHONE_ON_ACCOUNT",
3172
+ `There is no ${channel === "email" ? "email address" : "phone number"} on this account to send the code to.`
3173
+ );
3174
+ }
3175
+ const refused = await deletionBlocked(user.id);
3176
+ if (refused) return refused;
3177
+ const remainingMs = checkCooldown(await deletions.lastSentAt(user.id), DELETION_CODE_COOLDOWN_MS);
3178
+ if (remainingMs > 0) {
3179
+ const retryAfter = Math.ceil(remainingMs / 1e3);
3180
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.TOO_MANY_REQUESTS, "VERIFICATION_COOLDOWN", `Wait ${retryAfter}s before requesting a new code.`, { retryAfter });
3181
+ }
3182
+ const code = (0, import_node_crypto8.randomInt)(1e5, 1e6).toString();
3183
+ await deletions.saveCode(user.id, code, channel, new Date(Date.now() + DELETION_CODE_TTL_MS));
3184
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3185
+ type: MESSAGE_KEYS.accountDeletionCode,
3186
+ locale: user.locale,
3187
+ data: { code },
3188
+ recipient: channel === "email" ? { email: address, phone: null, deviceToken: null } : { email: null, phone: address, deviceToken: null }
3189
+ }));
3190
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.ACCEPTED, "ACCOUNT_DELETION_CODE_SENT", "A code to confirm the deletion was sent.", {
3191
+ channel,
3192
+ expiresInSeconds: DELETION_CODE_TTL_MS / 1e3,
3193
+ mfaRequired: user.mfaEnabled === true
3194
+ });
3195
+ },
3196
+ /**
3197
+ * Step 2: the code (and, with two-factor on, a second factor) archives the
3198
+ * account — closed at once, restorable at sign-in until the purge date.
3199
+ */
3200
+ confirmDeletion: async (ctx) => {
3201
+ const user = ctx.user;
3202
+ const body = ctx.meta["body"];
3203
+ const code = typeof body?.["code"] === "string" ? body["code"].trim() : "";
3204
+ if (!/^\d{6}$/.test(code)) {
3205
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "code must be the 6-digit code that was sent");
3206
+ }
3207
+ const refused = await deletionBlocked(user.id);
3208
+ if (refused) return refused;
3209
+ if (user.mfaEnabled && !await verifySecondFactor(store, config, user.id, body?.["mfaCode"])) {
3210
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to confirm.");
3211
+ }
3212
+ const checked = await deletions.checkCode(user.id, code);
3213
+ if (checked.result !== "ok") {
3214
+ return (0, import_core9.setApiResponse)(
3215
+ import_core9.HTTP.BAD_REQUEST,
3216
+ "VERIFICATION_FAILED",
3217
+ checked.result === "exhausted" || checked.result === "none" || checked.result === "expired" ? "This code can no longer be used. Request a new one." : "That code is incorrect."
3218
+ );
3219
+ }
3220
+ const deletedAt = await archive(user.id, checked.channel ?? null);
3221
+ await announceArchived(ctx, deletedAt);
3222
+ const deleteOn = deletionDate(deletedAt, config);
3223
+ const address = checked.channel === "sms" ? user.phone : user.email;
3224
+ if (address) {
3225
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3226
+ type: MESSAGE_KEYS.accountDeletionScheduled,
3227
+ locale: user.locale,
3228
+ data: {
3229
+ deleteOn: deleteOn.toISOString().slice(0, 10),
3230
+ [import_core9.COURIER_FORMAT_KEY]: { deleteOn: { date: deleteOn.toISOString(), style: "long" } }
3231
+ },
3232
+ recipient: checked.channel === "sms" ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
3233
+ }));
3234
+ }
3235
+ return archivedResponse(deletedAt);
2828
3236
  },
2829
3237
  // Subject Access Request — the authenticated user's own data as a portable
2830
3238
  // JSON bundle. Only auth-owned data, and only safe fields: no password hash,
2831
3239
  // no MFA secret, no session tokens (session metadata only).
2832
3240
  exportMe: async (ctx) => {
2833
3241
  const user = await users.findById(ctx.user.id);
2834
- if (!user) return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
3242
+ if (!user) return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_FOUND, "NOT_FOUND", "User not found");
2835
3243
  const sessionRows = await sessions.listByUser(ctx.user.id);
2836
3244
  const bundle = {
2837
3245
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2840,8 +3248,8 @@ function userController(store, config, bus) {
2840
3248
  id: s.id,
2841
3249
  userAgent: s.userAgent,
2842
3250
  ipAddress: s.ipAddress,
2843
- createdAt: (0, import_core8.dateOrEmpty)(s.createdAt),
2844
- expiresAt: (0, import_core8.dateOrEmpty)(s.expiresAt)
3251
+ createdAt: (0, import_core9.dateOrEmpty)(s.createdAt),
3252
+ expiresAt: (0, import_core9.dateOrEmpty)(s.expiresAt)
2845
3253
  })),
2846
3254
  security: {
2847
3255
  mfaEnabled: user.mfaEnabled === true,
@@ -2860,19 +3268,19 @@ function userController(store, config, bus) {
2860
3268
  }
2861
3269
  bundle["modules"] = modules;
2862
3270
  }
2863
- return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
3271
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "DATA_EXPORT", "Your account data.", bundle);
2864
3272
  }
2865
3273
  };
2866
3274
  }
2867
3275
 
2868
3276
  // src/controllers/oauth.controller.ts
2869
- var import_node_crypto9 = require("crypto");
2870
- var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2871
- var import_core9 = require("@fonderie/core");
3277
+ var import_node_crypto10 = require("crypto");
3278
+ var import_jsonwebtoken3 = __toESM(require("jsonwebtoken"), 1);
3279
+ var import_core10 = require("@fonderie/core");
2872
3280
  var import_events4 = require("@fonderie/events");
2873
3281
 
2874
3282
  // src/services/jwks.ts
2875
- var import_node_crypto8 = require("crypto");
3283
+ var import_node_crypto9 = require("crypto");
2876
3284
  var TTL_MS = 60 * 60 * 1e3;
2877
3285
  var MIN_REFETCH_MS = 60 * 1e3;
2878
3286
  var FETCH_TIMEOUT_MS = 5e3;
@@ -2906,7 +3314,7 @@ function createJwksSource(url) {
2906
3314
  }
2907
3315
  if (!jwk) return null;
2908
3316
  try {
2909
- return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
3317
+ return (0, import_node_crypto9.createPublicKey)({ key: jwk, format: "jwk" });
2910
3318
  } catch {
2911
3319
  return null;
2912
3320
  }
@@ -2952,7 +3360,7 @@ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
2952
3360
  var APPLE_ISSUER = "https://appleid.apple.com";
2953
3361
  function mintAppleClientSecret(apple) {
2954
3362
  const now = Math.floor(Date.now() / 1e3);
2955
- return import_jsonwebtoken2.default.sign(
3363
+ return import_jsonwebtoken3.default.sign(
2956
3364
  { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
2957
3365
  apple.privateKey,
2958
3366
  { algorithm: "ES256", keyid: apple.keyId }
@@ -2961,14 +3369,14 @@ function mintAppleClientSecret(apple) {
2961
3369
  var appleKeys = createJwksSource(APPLE_KEYS_URL);
2962
3370
  async function verifyAppleIdToken(idToken, opts) {
2963
3371
  if (opts.audiences.length === 0) return null;
2964
- const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
3372
+ const decoded = import_jsonwebtoken3.default.decode(idToken, { complete: true });
2965
3373
  if (!decoded || typeof decoded === "string") return null;
2966
3374
  const kid = decoded.header.kid;
2967
3375
  if (!kid) return null;
2968
3376
  const key = await appleKeys.getKey(kid);
2969
3377
  if (!key) return null;
2970
3378
  try {
2971
- const payload = import_jsonwebtoken2.default.verify(idToken, key, {
3379
+ const payload = import_jsonwebtoken3.default.verify(idToken, key, {
2972
3380
  algorithms: ["RS256"],
2973
3381
  issuer: APPLE_ISSUER,
2974
3382
  // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
@@ -2988,14 +3396,14 @@ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
2988
3396
  var googleKeys = createJwksSource(GOOGLE_KEYS_URL);
2989
3397
  async function verifyGoogleIdToken(idToken, opts) {
2990
3398
  if (opts.audiences.length === 0) return null;
2991
- const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
3399
+ const decoded = import_jsonwebtoken3.default.decode(idToken, { complete: true });
2992
3400
  if (!decoded || typeof decoded === "string") return null;
2993
3401
  const kid = decoded.header.kid;
2994
3402
  if (!kid) return null;
2995
3403
  const key = await googleKeys.getKey(kid);
2996
3404
  if (!key) return null;
2997
3405
  try {
2998
- const payload = import_jsonwebtoken2.default.verify(idToken, key, {
3406
+ const payload = import_jsonwebtoken3.default.verify(idToken, key, {
2999
3407
  algorithms: ["RS256"],
3000
3408
  issuer: [...GOOGLE_ISSUERS],
3001
3409
  audience: opts.audiences
@@ -3019,7 +3427,7 @@ function oauthController(store, config, bus) {
3019
3427
  const label = provider.charAt(0).toUpperCase() + provider.slice(1);
3020
3428
  const recipient = { email: user.email, phone: null, deviceToken: null };
3021
3429
  if (upserted.clearedUnverifiedPassword && user.email) {
3022
- await (0, import_core9.background)(bus.emit(
3430
+ await (0, import_core10.background)(bus.emit(
3023
3431
  import_events4.NOTIFICATION_EVENT,
3024
3432
  {
3025
3433
  type: MESSAGE_KEYS.passwordRevoked,
@@ -3031,7 +3439,7 @@ function oauthController(store, config, bus) {
3031
3439
  ));
3032
3440
  }
3033
3441
  if (upserted.inserted) {
3034
- await (0, import_core9.background)(bus.emit(
3442
+ await (0, import_core10.background)(bus.emit(
3035
3443
  EVENT_KEYS.userRegistered,
3036
3444
  {
3037
3445
  userId: upserted.id,
@@ -3043,7 +3451,7 @@ function oauthController(store, config, bus) {
3043
3451
  reqOpts
3044
3452
  ));
3045
3453
  if (user.email) {
3046
- await (0, import_core9.background)(bus.emit(
3454
+ await (0, import_core10.background)(bus.emit(
3047
3455
  import_events4.NOTIFICATION_EVENT,
3048
3456
  {
3049
3457
  type: MESSAGE_KEYS.oauthRegistration,
@@ -3057,7 +3465,7 @@ function oauthController(store, config, bus) {
3057
3465
  return;
3058
3466
  }
3059
3467
  if (upserted.previousProvider !== provider && user.email) {
3060
- await (0, import_core9.background)(bus.emit(
3468
+ await (0, import_core10.background)(bus.emit(
3061
3469
  import_events4.NOTIFICATION_EVENT,
3062
3470
  {
3063
3471
  type: MESSAGE_KEYS.oauthLinked,
@@ -3078,14 +3486,29 @@ function oauthController(store, config, bus) {
3078
3486
  const method = provider === "apple" ? "oauth-apple" : "oauth-google";
3079
3487
  const meta = requestMeta(ctx);
3080
3488
  if (!claims.email) {
3081
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3489
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3082
3490
  }
3083
3491
  if (!claims.emailVerified) {
3084
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3492
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3085
3493
  }
3086
3494
  const normalizedEmail = normalizeEmailSafe(claims.email);
3087
3495
  if (!normalizedEmail) {
3088
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3496
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3497
+ }
3498
+ const archived = await users.findArchivedByEmail(normalizedEmail);
3499
+ if (archived?.deletedAt) {
3500
+ await loginEvents.recordSafe({
3501
+ userId: archived.id,
3502
+ emailAttempted: normalizedEmail,
3503
+ method,
3504
+ outcome: "failed",
3505
+ failureReason: "pending_deletion",
3506
+ ...meta
3507
+ });
3508
+ const res = pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, provider, config);
3509
+ const headers = cookieHeaders(clearCookies);
3510
+ headers.set("content-type", res.headers.get("content-type") ?? "application/json");
3511
+ return new Response(res.body, { status: res.status, headers });
3089
3512
  }
3090
3513
  const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
3091
3514
  if (!upserted) {
@@ -3097,15 +3520,15 @@ function oauthController(store, config, bus) {
3097
3520
  failureReason: "provider_upsert_failed",
3098
3521
  ...meta
3099
3522
  });
3100
- return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3523
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3101
3524
  }
3102
3525
  const fullUser = await users.findById(upserted.id);
3103
3526
  if (!fullUser) {
3104
- return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3527
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3105
3528
  }
3106
3529
  await announceOAuthUpsert(ctx, upserted, provider, fullUser);
3107
3530
  if (fullUser.suspended) {
3108
- return (0, import_core9.setApiResponse)(import_core9.HTTP.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3531
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3109
3532
  }
3110
3533
  if (fullUser.mfaEnabled) {
3111
3534
  const mfaToken = issueMfaPendingToken(upserted.id, config, provider);
@@ -3148,13 +3571,13 @@ function oauthController(store, config, bus) {
3148
3571
  googleInit: async (_ctx) => {
3149
3572
  const google = config.google;
3150
3573
  if (!google || !google.clientSecret || !google.redirectUri) {
3151
- return (0, import_core9.setApiResponse)(
3152
- import_core9.HTTP.NOT_IMPLEMENTED,
3574
+ return (0, import_core10.setApiResponse)(
3575
+ import_core10.HTTP.NOT_IMPLEMENTED,
3153
3576
  "NOT_CONFIGURED",
3154
3577
  "Google OAuth not configured"
3155
3578
  );
3156
3579
  }
3157
- const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
3580
+ const state = (0, import_node_crypto10.randomBytes)(16).toString("hex");
3158
3581
  const params = new URLSearchParams({
3159
3582
  client_id: google.clientId,
3160
3583
  redirect_uri: google.redirectUri,
@@ -3183,8 +3606,8 @@ function oauthController(store, config, bus) {
3183
3606
  googleCallback: async (ctx) => {
3184
3607
  const google = config.google;
3185
3608
  if (!google || !google.clientSecret || !google.redirectUri) {
3186
- return (0, import_core9.setApiResponse)(
3187
- import_core9.HTTP.NOT_IMPLEMENTED,
3609
+ return (0, import_core10.setApiResponse)(
3610
+ import_core10.HTTP.NOT_IMPLEMENTED,
3188
3611
  "NOT_CONFIGURED",
3189
3612
  "Google OAuth not configured"
3190
3613
  );
@@ -3192,14 +3615,14 @@ function oauthController(store, config, bus) {
3192
3615
  const url = new URL(ctx.request.url);
3193
3616
  const code = url.searchParams.get("code");
3194
3617
  if (!code) {
3195
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3618
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3196
3619
  }
3197
3620
  const returnedState = url.searchParams.get("state") ?? "";
3198
3621
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3199
3622
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3200
- if (!returnedState || !expectedState || !(0, import_core9.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3201
- return (0, import_core9.setApiResponse)(
3202
- import_core9.HTTP.BAD_REQUEST,
3623
+ if (!returnedState || !expectedState || !(0, import_core10.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3624
+ return (0, import_core10.setApiResponse)(
3625
+ import_core10.HTTP.BAD_REQUEST,
3203
3626
  "GOOGLE_AUTH_FAILED",
3204
3627
  "OAuth state mismatch \u2014 restart the sign-in flow"
3205
3628
  );
@@ -3217,8 +3640,8 @@ function oauthController(store, config, bus) {
3217
3640
  });
3218
3641
  const tokenData = await tokenRes.json();
3219
3642
  if (!tokenData.id_token) {
3220
- return (0, import_core9.setApiResponse)(
3221
- import_core9.HTTP.BAD_REQUEST,
3643
+ return (0, import_core10.setApiResponse)(
3644
+ import_core10.HTTP.BAD_REQUEST,
3222
3645
  "GOOGLE_AUTH_FAILED",
3223
3646
  "OAuth token exchange failed"
3224
3647
  );
@@ -3227,20 +3650,20 @@ function oauthController(store, config, bus) {
3227
3650
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
3228
3651
  );
3229
3652
  if (payload.aud !== google.clientId) {
3230
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3653
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3231
3654
  }
3232
3655
  if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
3233
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3656
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3234
3657
  }
3235
3658
  if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
3236
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3659
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3237
3660
  }
3238
3661
  if (!payload.email) {
3239
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3662
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3240
3663
  }
3241
3664
  if (payload.email_verified !== true) {
3242
- return (0, import_core9.setApiResponse)(
3243
- import_core9.HTTP.BAD_REQUEST,
3665
+ return (0, import_core10.setApiResponse)(
3666
+ import_core10.HTTP.BAD_REQUEST,
3244
3667
  "GOOGLE_AUTH_FAILED",
3245
3668
  "Google account email is not verified"
3246
3669
  );
@@ -3254,9 +3677,9 @@ function oauthController(store, config, bus) {
3254
3677
  appleInit: async (_ctx) => {
3255
3678
  const apple = config.apple;
3256
3679
  if (!apple) {
3257
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3680
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3258
3681
  }
3259
- const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
3682
+ const state = (0, import_node_crypto10.randomBytes)(16).toString("hex");
3260
3683
  const params = new URLSearchParams({
3261
3684
  client_id: apple.clientId,
3262
3685
  redirect_uri: apple.redirectUri,
@@ -3285,20 +3708,20 @@ function oauthController(store, config, bus) {
3285
3708
  appleCallback: async (ctx) => {
3286
3709
  const apple = config.apple;
3287
3710
  if (!apple) {
3288
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3711
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3289
3712
  }
3290
3713
  const pre = ctx.meta["body"];
3291
3714
  const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
3292
3715
  const code = form.get("code");
3293
3716
  if (!code) {
3294
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3717
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3295
3718
  }
3296
3719
  const returnedState = form.get("state") ?? "";
3297
3720
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3298
3721
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3299
- if (!returnedState || !expectedState || !(0, import_core9.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3300
- return (0, import_core9.setApiResponse)(
3301
- import_core9.HTTP.BAD_REQUEST,
3722
+ if (!returnedState || !expectedState || !(0, import_core10.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
3723
+ return (0, import_core10.setApiResponse)(
3724
+ import_core10.HTTP.BAD_REQUEST,
3302
3725
  "APPLE_AUTH_FAILED",
3303
3726
  "OAuth state mismatch \u2014 restart the sign-in flow"
3304
3727
  );
@@ -3316,11 +3739,11 @@ function oauthController(store, config, bus) {
3316
3739
  });
3317
3740
  const tokenData = await tokenRes.json();
3318
3741
  if (!tokenData.id_token) {
3319
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3742
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3320
3743
  }
3321
3744
  const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
3322
3745
  if (!claims) {
3323
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3746
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3324
3747
  }
3325
3748
  return completeOAuthLogin(ctx, "apple", {
3326
3749
  email: claims.email,
@@ -3331,12 +3754,12 @@ function oauthController(store, config, bus) {
3331
3754
  appleNative: async (ctx) => {
3332
3755
  const apple = config.apple;
3333
3756
  if (!apple) {
3334
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3757
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3335
3758
  }
3336
3759
  const body = ctx.meta["body"];
3337
3760
  const identityToken = body?.identityToken;
3338
3761
  if (!identityToken) {
3339
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3762
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3340
3763
  }
3341
3764
  const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
3342
3765
  const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
@@ -3350,9 +3773,9 @@ function oauthController(store, config, bus) {
3350
3773
  failureReason: "invalid_identity_token",
3351
3774
  ...meta
3352
3775
  });
3353
- return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3776
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3354
3777
  }
3355
- const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(identityToken).digest("hex");
3778
+ const tokenHash = (0, import_node_crypto10.createHash)("sha256").update(identityToken).digest("hex");
3356
3779
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3357
3780
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3358
3781
  if (!firstUse) {
@@ -3365,8 +3788,8 @@ function oauthController(store, config, bus) {
3365
3788
  failureReason: "token_replayed",
3366
3789
  ...meta
3367
3790
  });
3368
- return (0, import_core9.setApiResponse)(
3369
- import_core9.HTTP.UNAUTHORIZED,
3791
+ return (0, import_core10.setApiResponse)(
3792
+ import_core10.HTTP.UNAUTHORIZED,
3370
3793
  "APPLE_AUTH_FAILED",
3371
3794
  "This Apple identity token has already been used"
3372
3795
  );
@@ -3383,12 +3806,12 @@ function oauthController(store, config, bus) {
3383
3806
  googleNative: async (ctx) => {
3384
3807
  const audiences = config.google?.nativeClientIds ?? [];
3385
3808
  if (!config.google || audiences.length === 0) {
3386
- return (0, import_core9.setApiResponse)(import_core9.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3809
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3387
3810
  }
3388
3811
  const body = ctx.meta["body"];
3389
3812
  const idToken = body?.idToken;
3390
3813
  if (!idToken) {
3391
- return (0, import_core9.setApiResponse)(import_core9.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3814
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3392
3815
  }
3393
3816
  const claims = await verifyGoogleIdToken(idToken, { audiences, nonce: body?.nonce });
3394
3817
  if (!claims) {
@@ -3400,9 +3823,9 @@ function oauthController(store, config, bus) {
3400
3823
  failureReason: "invalid_identity_token",
3401
3824
  ...requestMeta(ctx)
3402
3825
  });
3403
- return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3826
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3404
3827
  }
3405
- const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(idToken).digest("hex");
3828
+ const tokenHash = (0, import_node_crypto10.createHash)("sha256").update(idToken).digest("hex");
3406
3829
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3407
3830
  if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3408
3831
  await loginEvents.recordSafe({
@@ -3413,7 +3836,7 @@ function oauthController(store, config, bus) {
3413
3836
  failureReason: "token_replayed",
3414
3837
  ...requestMeta(ctx)
3415
3838
  });
3416
- return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3839
+ return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3417
3840
  }
3418
3841
  consumedTokens.sweepExpiredSafe();
3419
3842
  return completeOAuthLogin(ctx, "google", {
@@ -3459,7 +3882,7 @@ function buildAuthRoutes(store, config, bus, locales) {
3459
3882
  "providers",
3460
3883
  "GET",
3461
3884
  "/auth/providers",
3462
- async () => (0, import_core10.setApiResponse)(import_core10.HTTP.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3885
+ async () => (0, import_core11.setApiResponse)(import_core11.HTTP.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3463
3886
  providers: [...config.providers]
3464
3887
  })
3465
3888
  ),
@@ -3491,6 +3914,11 @@ function buildAuthRoutes(store, config, bus, locales) {
3491
3914
  // refuses when it would leave the account with no way to sign in.
3492
3915
  R("unlinkOauth", "DELETE", "/auth/oauth/:provider", import_middlewares2.requireAuth, user.unlinkOauth),
3493
3916
  R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
3917
+ // Deleting with proof: a code to a chosen channel, then confirm. The
3918
+ // account is archived until the purge; signing in offers to keep it.
3919
+ R("requestDeletion", "POST", "/users/me/deletion", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(requestDeletionSchema), user.requestDeletion),
3920
+ R("confirmDeletion", "POST", "/users/me/deletion/confirm", ipLimit("verify"), import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(confirmDeletionSchema), user.confirmDeletion),
3921
+ R("restoreAccount", "POST", "/auth/account/restore", ipLimit("login"), (0, import_middlewares.validate)(restoreAccountSchema), auth.restoreAccount),
3494
3922
  R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
3495
3923
  // Security surfaces (Protected; the caller's own login history + sessions).
3496
3924
  // The literal /others route is registered before /:id so it isn't
@@ -3534,20 +3962,20 @@ function buildAuthRoutes(store, config, bus, locales) {
3534
3962
  }
3535
3963
 
3536
3964
  // src/services/config-guard.ts
3537
- var import_core11 = require("@fonderie/core");
3965
+ var import_core12 = require("@fonderie/core");
3538
3966
  var MODULE = "@fonderie/auth";
3539
3967
  function collectAuthConfigProblems(config) {
3540
3968
  const problems = [];
3541
3969
  const secret = config.jwtSecret ?? "";
3542
- const secretProblem = (0, import_core11.secretStrengthProblem)(secret);
3970
+ const secretProblem = (0, import_core12.secretStrengthProblem)(secret);
3543
3971
  if (secretProblem === "too-short") {
3544
3972
  problems.push({
3545
3973
  module: MODULE,
3546
3974
  severity: "error",
3547
- message: `jwtSecret must be at least ${import_core11.MIN_SECRET_LENGTH} characters (got ${secret.length})`,
3975
+ message: `jwtSecret must be at least ${import_core12.MIN_SECRET_LENGTH} characters (got ${secret.length})`,
3548
3976
  domain: "auth",
3549
3977
  reason: "JWT_SECRET_TOO_SHORT",
3550
- metadata: { min: import_core11.MIN_SECRET_LENGTH, got: secret.length }
3978
+ metadata: { min: import_core12.MIN_SECRET_LENGTH, got: secret.length }
3551
3979
  });
3552
3980
  } else if (secretProblem === "placeholder") {
3553
3981
  problems.push({
@@ -3559,7 +3987,7 @@ function collectAuthConfigProblems(config) {
3559
3987
  });
3560
3988
  }
3561
3989
  (config.jwtPreviousSecrets ?? []).forEach((previous, index) => {
3562
- if ((0, import_core11.secretStrengthProblem)(previous ?? "")) {
3990
+ if ((0, import_core12.secretStrengthProblem)(previous ?? "")) {
3563
3991
  problems.push({
3564
3992
  module: MODULE,
3565
3993
  severity: "error",
@@ -3592,7 +4020,7 @@ function collectAuthConfigProblems(config) {
3592
4020
  domain: "auth",
3593
4021
  reason: "GOOGLE_INCOMPLETE"
3594
4022
  });
3595
- } else if (clientSecret && import_core11.PLACEHOLDER_SECRET.test(clientSecret)) {
4023
+ } else if (clientSecret && import_core12.PLACEHOLDER_SECRET.test(clientSecret)) {
3596
4024
  problems.push({
3597
4025
  module: MODULE,
3598
4026
  severity: "error",
@@ -3694,8 +4122,11 @@ function withSession(store, config) {
3694
4122
  if (payload.sid && !await sessions.aliveBySid(payload.sid)) {
3695
4123
  return next();
3696
4124
  }
3697
- const user = await users.findById(payload.sub);
3698
- if (!user || user.suspended || user.deletedAt) {
4125
+ let user = await users.findById(payload.sub);
4126
+ if (!user && payload.mfaPending && payload.loginMethod === "phone") {
4127
+ user = await users.findArchivedById(payload.sub);
4128
+ }
4129
+ if (!user || user.suspended || user.deletedAt && !payload.mfaPending) {
3699
4130
  return next();
3700
4131
  }
3701
4132
  Object.assign(ctx, {
@@ -3723,8 +4154,8 @@ function extractToken(request) {
3723
4154
  }
3724
4155
 
3725
4156
  // src/admin.ts
3726
- var import_core12 = require("@fonderie/core");
3727
4157
  var import_core13 = require("@fonderie/core");
4158
+ var import_core14 = require("@fonderie/core");
3728
4159
  function toAdminUserDTO(user) {
3729
4160
  return {
3730
4161
  ...toUserDTO(user),
@@ -3735,12 +4166,12 @@ function toAdminUserDTO(user) {
3735
4166
  }
3736
4167
  function toAdminUserPageDTO(page) {
3737
4168
  const last = page.users[page.users.length - 1];
3738
- const nextCursor = page.hasMore && last ? (0, import_core12.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
4169
+ const nextCursor = page.hasMore && last ? (0, import_core13.encodeKeysetCursor)(last.createdAtRaw ?? last.createdAt.toISOString(), last.id) : null;
3739
4170
  return { users: page.users.map(toAdminUserDTO), nextCursor };
3740
4171
  }
3741
4172
  var ADMIN_PREFIX = "/_admin";
3742
4173
  var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
3743
- var NOT_FOUND = () => (0, import_core12.setApiResponse)(import_core12.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
4174
+ var NOT_FOUND = () => (0, import_core13.setApiResponse)(import_core13.HTTP.NOT_FOUND, "NOT_FOUND", "No such user");
3744
4175
  function adminRouteTable(store, bus) {
3745
4176
  const users = new UserModel(store);
3746
4177
  const sessions = new SessionModel(store);
@@ -3749,8 +4180,8 @@ function adminRouteTable(store, bus) {
3749
4180
  const ok = await users.setSuspended(idOf(ctx), suspended);
3750
4181
  if (!ok) return NOT_FOUND();
3751
4182
  const user = await users.findById(idOf(ctx));
3752
- return user ? (0, import_core12.setApiResponse)(
3753
- import_core12.HTTP.OK,
4183
+ return user ? (0, import_core13.setApiResponse)(
4184
+ import_core13.HTTP.OK,
3754
4185
  suspended ? "USER_SUSPENDED" : "USER_UNSUSPENDED",
3755
4186
  suspended ? "User suspended" : "User unsuspended",
3756
4187
  toAdminUserDTO(user)
@@ -3769,21 +4200,21 @@ function adminRouteTable(store, bus) {
3769
4200
  const email2 = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : void 0;
3770
4201
  if (email2) {
3771
4202
  const user = await users.findByEmail(email2);
3772
- return user ? (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
4203
+ return user ? (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3773
4204
  }
3774
4205
  const rawLimit = Number(params.get("limit") ?? 50);
3775
4206
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
3776
4207
  const cursorParam = params.get("cursor");
3777
4208
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3778
4209
  if (cursorParam && !cursor)
3779
- return (0, import_core12.setApiResponse)(import_core12.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
4210
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3780
4211
  const deleted = params.get("deleted") === "1" || params.get("deleted") === "true";
3781
4212
  const page = await users.list({
3782
4213
  limit,
3783
4214
  ...cursor ? { cursor } : {},
3784
4215
  ...deleted ? { deleted } : {}
3785
4216
  });
3786
- return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USERS", "Users", toAdminUserPageDTO(page));
4217
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "USERS", "Users", toAdminUserPageDTO(page));
3787
4218
  }
3788
4219
  ],
3789
4220
  [
@@ -3791,7 +4222,7 @@ function adminRouteTable(store, bus) {
3791
4222
  "/_admin/users/:id",
3792
4223
  async (ctx) => {
3793
4224
  const user = await users.findByIdIncludingDeleted(idOf(ctx));
3794
- return user ? (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
4225
+ return user ? (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3795
4226
  }
3796
4227
  ],
3797
4228
  [
@@ -3800,8 +4231,8 @@ function adminRouteTable(store, bus) {
3800
4231
  async (ctx) => {
3801
4232
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3802
4233
  const rows = await sessions.listLiveByUser(idOf(ctx));
3803
- return (0, import_core12.setApiResponse)(
3804
- import_core12.HTTP.OK,
4234
+ return (0, import_core13.setApiResponse)(
4235
+ import_core13.HTTP.OK,
3805
4236
  "SESSIONS",
3806
4237
  "Live sessions",
3807
4238
  rows.map((r) => toSessionDTO(r, null))
@@ -3815,8 +4246,8 @@ function adminRouteTable(store, bus) {
3815
4246
  async (ctx) => {
3816
4247
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3817
4248
  await sessions.deleteByUser(idOf(ctx));
3818
- await (0, import_core13.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3819
- return (0, import_core12.setApiResponse)(import_core12.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
4249
+ await (0, import_core14.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
4250
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.OK, "SESSIONS_REVOKED", "All sessions revoked");
3820
4251
  }
3821
4252
  ],
3822
4253
  [
@@ -3830,14 +4261,14 @@ function adminRouteTable(store, bus) {
3830
4261
  const cursorParam = params.get("cursor");
3831
4262
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3832
4263
  if (cursorParam && !cursor)
3833
- return (0, import_core12.setApiResponse)(import_core12.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
4264
+ return (0, import_core13.setApiResponse)(import_core13.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3834
4265
  const page = await events.listByUser({
3835
4266
  userId: idOf(ctx),
3836
4267
  limit,
3837
4268
  ...cursor ? { cursor } : {}
3838
4269
  });
3839
- return (0, import_core12.setApiResponse)(
3840
- import_core12.HTTP.OK,
4270
+ return (0, import_core13.setApiResponse)(
4271
+ import_core13.HTTP.OK,
3841
4272
  "LOGIN_HISTORY",
3842
4273
  "Login history",
3843
4274
  toLoginHistoryPageDTO(page)
@@ -3870,7 +4301,7 @@ var AuthModule = class {
3870
4301
  name = "@fonderie/auth";
3871
4302
  // Baked in at build time by tsup.base, so the operator's Modules page can
3872
4303
  // say what is actually deployed rather than 'not reported'.
3873
- version = "7.22.1";
4304
+ version = "7.24.0";
3874
4305
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3875
4306
  describeAdmin() {
3876
4307
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };
@@ -3901,7 +4332,7 @@ var AuthModule = class {
3901
4332
  };
3902
4333
 
3903
4334
  // src/templates.ts
3904
- var import_core14 = require("@fonderie/core");
4335
+ var import_core15 = require("@fonderie/core");
3905
4336
 
3906
4337
  // src/templates.es.ts
3907
4338
  var ES_TEMPLATES = {
@@ -4058,6 +4489,29 @@ inmediato y cambia tu contrase\xF1a \u2014 otra persona podr\xEDa iniciar sesi\x
4058
4489
  {{provider}} ya no se puede usar para iniciar sesi\xF3n. Tu correo electr\xF3nico y tu contrase\xF1a siguen funcionando.
4059
4490
 
4060
4491
  Si fuiste t\xFA, todo est\xE1 en orden. Si no, contacta con soporte de inmediato.`
4492
+ },
4493
+ [MESSAGE_KEYS.accountDeletionCode]: {
4494
+ subject: `Confirma la eliminaci\xF3n de tu cuenta`,
4495
+ html: `<h1>Confirmar la eliminaci\xF3n de la cuenta</h1>
4496
+ <p>Usa este c\xF3digo para confirmar que quieres eliminar tu cuenta:</p>
4497
+ <p><span class="pin-code">{{code}}</span></p>
4498
+ <p class="muted">Este c\xF3digo vence en 15 minutos. Si no pediste eliminar tu cuenta, ignora este mensaje y cambia tu contrase\xF1a: tu cuenta seguir\xE1 igual.</p>`,
4499
+ text: `Tu c\xF3digo para confirmar la eliminaci\xF3n de tu cuenta: {{code}}. Vence en 15 minutos. \xBFNo fuiste t\xFA? Ignora esto y cambia tu contrase\xF1a.`
4500
+ },
4501
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4502
+ subject: `Tu cuenta se eliminar\xE1 el {{deleteOn}}`,
4503
+ html: `<h1>Tu cuenta est\xE1 programada para eliminarse</h1>
4504
+ <p>Recibimos tu solicitud. Tu cuenta est\xE1 cerrada y se eliminar\xE1 de forma permanente el <strong>{{deleteOn}}</strong>.</p>
4505
+ <p>\xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes de esa fecha y elige <strong>Conservar mi cuenta</strong>.</p>
4506
+ <p class="muted">Si no lo pediste, inicia sesi\xF3n ahora para conservar tu cuenta y cambia tu contrase\xF1a.</p>`,
4507
+ text: `Tu cuenta se eliminar\xE1 de forma permanente el {{deleteOn}}. \xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes y elige Conservar mi cuenta.`
4508
+ },
4509
+ [MESSAGE_KEYS.accountRestored]: {
4510
+ subject: `Tu cuenta fue restablecida`,
4511
+ html: `<h1>Bienvenido de nuevo</h1>
4512
+ <p>Se cancel\xF3 la eliminaci\xF3n de tu cuenta y tu cuenta vuelve a estar activa.</p>
4513
+ <p class="muted">Si no fuiste t\xFA, cambia tu contrase\xF1a de inmediato.</p>`,
4514
+ text: `Se cancel\xF3 la eliminaci\xF3n de tu cuenta; tu cuenta vuelve a estar activa. \xBFNo fuiste t\xFA? Cambia tu contrase\xF1a ahora.`
4061
4515
  }
4062
4516
  };
4063
4517
 
@@ -4218,6 +4672,29 @@ pourrait se connecter \xE0 votre place.`
4218
4672
  {{provider}} ne peut plus \xEAtre utilis\xE9 pour vous connecter. Votre adresse e-mail et votre mot de passe fonctionnent toujours.
4219
4673
 
4220
4674
  Si c'est bien vous, tout est en ordre. Sinon, contactez imm\xE9diatement le support.`
4675
+ },
4676
+ [MESSAGE_KEYS.accountDeletionCode]: {
4677
+ subject: `Confirmez la suppression de votre compte`,
4678
+ html: `<h1>Confirmer la suppression du compte</h1>
4679
+ <p>Utilisez ce code pour confirmer que vous souhaitez supprimer votre compte :</p>
4680
+ <p><span class="pin-code">{{code}}</span></p>
4681
+ <p class="muted">Ce code expire dans 15 minutes. Si vous n&rsquo;avez pas demand\xE9 la suppression de votre compte, ignorez ce message et changez votre mot de passe &mdash; votre compte reste tel quel.</p>`,
4682
+ text: `Votre code pour confirmer la suppression de votre compte : {{code}}. Il expire dans 15 minutes. Ce n'est pas vous ? Ignorez ce message et changez votre mot de passe.`
4683
+ },
4684
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4685
+ subject: `Votre compte sera supprim\xE9 le {{deleteOn}}`,
4686
+ html: `<h1>Votre compte sera supprim\xE9</h1>
4687
+ <p>Nous avons bien re\xE7u votre demande. Votre compte est ferm\xE9 et sera supprim\xE9 d\xE9finitivement le <strong>{{deleteOn}}</strong>.</p>
4688
+ <p>Vous avez chang\xE9 d&rsquo;avis ? Connectez-vous avant cette date et choisissez <strong>Conserver mon compte</strong>.</p>
4689
+ <p class="muted">Si vous n&rsquo;\xEAtes pas \xE0 l&rsquo;origine de cette demande, connectez-vous maintenant pour conserver votre compte et changez votre mot de passe.</p>`,
4690
+ text: `Votre compte sera supprim\xE9 d\xE9finitivement le {{deleteOn}}. Vous avez chang\xE9 d'avis ? Connectez-vous avant cette date et choisissez Conserver mon compte.`
4691
+ },
4692
+ [MESSAGE_KEYS.accountRestored]: {
4693
+ subject: `Votre compte a \xE9t\xE9 r\xE9tabli`,
4694
+ html: `<h1>Bon retour</h1>
4695
+ <p>La suppression de votre compte a \xE9t\xE9 annul\xE9e et votre compte est de nouveau actif.</p>
4696
+ <p class="muted">Si ce n&rsquo;est pas vous, changez votre mot de passe imm\xE9diatement.</p>`,
4697
+ text: `La suppression de votre compte a \xE9t\xE9 annul\xE9e ; votre compte est de nouveau actif. Ce n'est pas vous ? Changez votre mot de passe maintenant.`
4221
4698
  }
4222
4699
  };
4223
4700
 
@@ -4372,6 +4849,29 @@ var ZH_HANS_TEMPLATES = {
4372
4849
  \u5DF2\u65E0\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5F55\u3002\u60A8\u7684\u7535\u5B50\u90AE\u7BB1\u548C\u5BC6\u7801\u4ECD\u7136\u53EF\u7528\u3002
4373
4850
 
4374
4851
  \u5982\u679C\u662F\u60A8\u672C\u4EBA\u79FB\u9664\u7684\uFF0C\u65E0\u9700\u4EFB\u4F55\u64CD\u4F5C\u3002\u5982\u679C\u4E0D\u662F\uFF0C\u8BF7\u7ACB\u5373\u8054\u7CFB\u5BA2\u670D\u3002`
4852
+ },
4853
+ [MESSAGE_KEYS.accountDeletionCode]: {
4854
+ subject: `\u786E\u8BA4\u5220\u9664\u60A8\u7684\u8D26\u6237`,
4855
+ html: `<h1>\u786E\u8BA4\u5220\u9664\u8D26\u6237</h1>
4856
+ <p>\u8BF7\u4F7F\u7528\u6B64\u9A8C\u8BC1\u7801\u786E\u8BA4\u60A8\u8981\u5220\u9664\u8D26\u6237\uFF1A</p>
4857
+ <p><span class="pin-code">{{code}}</span></p>
4858
+ <p class="muted">\u6B64\u9A8C\u8BC1\u7801\u5C06\u5728 15 \u5206\u949F\u540E\u5931\u6548\u3002\u5982\u679C\u60A8\u6CA1\u6709\u7533\u8BF7\u5220\u9664\u8D26\u6237\uFF0C\u8BF7\u5FFD\u7565\u6B64\u6D88\u606F\u5E76\u66F4\u6539\u5BC6\u7801\uFF0C\u60A8\u7684\u8D26\u6237\u5C06\u4FDD\u6301\u4E0D\u53D8\u3002</p>`,
4859
+ text: `\u786E\u8BA4\u5220\u9664\u8D26\u6237\u7684\u9A8C\u8BC1\u7801\uFF1A{{code}}\uFF0C15 \u5206\u949F\u5185\u6709\u6548\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8BF7\u5FFD\u7565\u5E76\u66F4\u6539\u5BC6\u7801\u3002`
4860
+ },
4861
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4862
+ subject: `\u60A8\u7684\u8D26\u6237\u5C06\u4E8E {{deleteOn}} \u5220\u9664`,
4863
+ html: `<h1>\u60A8\u7684\u8D26\u6237\u5DF2\u5B89\u6392\u5220\u9664</h1>
4864
+ <p>\u6211\u4EEC\u5DF2\u6536\u5230\u60A8\u7684\u7533\u8BF7\u3002\u60A8\u7684\u8D26\u6237\u5DF2\u5173\u95ED\uFF0C\u5E76\u5C06\u4E8E <strong>{{deleteOn}}</strong> \u6C38\u4E45\u5220\u9664\u3002</p>
4865
+ <p>\u6539\u53D8\u4E3B\u610F\u4E86\uFF1F\u8BF7\u5728\u8BE5\u65E5\u671F\u524D\u767B\u5F55\u5E76\u9009\u62E9<strong>\u4FDD\u7559\u6211\u7684\u8D26\u6237</strong>\u3002</p>
4866
+ <p class="muted">\u5982\u679C\u8FD9\u4E0D\u662F\u60A8\u672C\u4EBA\u7684\u7533\u8BF7\uFF0C\u8BF7\u7ACB\u5373\u767B\u5F55\u4EE5\u4FDD\u7559\u8D26\u6237\u5E76\u66F4\u6539\u5BC6\u7801\u3002</p>`,
4867
+ text: `\u60A8\u7684\u8D26\u6237\u5C06\u4E8E {{deleteOn}} \u6C38\u4E45\u5220\u9664\u3002\u6539\u53D8\u4E3B\u610F\u4E86\uFF1F\u8BF7\u5728\u6B64\u524D\u767B\u5F55\u5E76\u9009\u62E9\u201C\u4FDD\u7559\u6211\u7684\u8D26\u6237\u201D\u3002`
4868
+ },
4869
+ [MESSAGE_KEYS.accountRestored]: {
4870
+ subject: `\u60A8\u7684\u8D26\u6237\u5DF2\u6062\u590D`,
4871
+ html: `<h1>\u6B22\u8FCE\u56DE\u6765</h1>
4872
+ <p>\u60A8\u7684\u8D26\u6237\u5220\u9664\u5DF2\u53D6\u6D88\uFF0C\u8D26\u6237\u5DF2\u91CD\u65B0\u542F\u7528\u3002</p>
4873
+ <p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8BF7\u7ACB\u5373\u66F4\u6539\u5BC6\u7801\u3002</p>`,
4874
+ text: `\u60A8\u7684\u8D26\u6237\u5220\u9664\u5DF2\u53D6\u6D88\uFF0C\u8D26\u6237\u5DF2\u91CD\u65B0\u542F\u7528\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8BF7\u7ACB\u5373\u66F4\u6539\u5BC6\u7801\u3002`
4375
4875
  }
4376
4876
  };
4377
4877
 
@@ -4526,6 +5026,29 @@ var ZH_HANT_TEMPLATES = {
4526
5026
  \u5DF2\u7121\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5165\u3002\u60A8\u7684\u96FB\u5B50\u90F5\u4EF6\u548C\u5BC6\u78BC\u4ECD\u53EF\u4F7F\u7528\u3002
4527
5027
 
4528
5028
  \u5982\u679C\u662F\u60A8\u672C\u4EBA\u79FB\u9664\u7684\uFF0C\u7121\u9808\u63A1\u53D6\u4EFB\u4F55\u884C\u52D5\u3002\u5982\u679C\u4E0D\u662F\uFF0C\u8ACB\u7ACB\u5373\u806F\u7D61\u5BA2\u670D\u3002`
5029
+ },
5030
+ [MESSAGE_KEYS.accountDeletionCode]: {
5031
+ subject: `\u78BA\u8A8D\u522A\u9664\u60A8\u7684\u5E33\u6236`,
5032
+ html: `<h1>\u78BA\u8A8D\u522A\u9664\u5E33\u6236</h1>
5033
+ <p>\u8ACB\u4F7F\u7528\u6B64\u9A57\u8B49\u78BC\u78BA\u8A8D\u60A8\u8981\u522A\u9664\u5E33\u6236\uFF1A</p>
5034
+ <p><span class="pin-code">{{code}}</span></p>
5035
+ <p class="muted">\u6B64\u9A57\u8B49\u78BC\u5C07\u5728 15 \u5206\u9418\u5F8C\u5931\u6548\u3002\u5982\u679C\u60A8\u6C92\u6709\u7533\u8ACB\u522A\u9664\u5E33\u6236\uFF0C\u8ACB\u5FFD\u7565\u6B64\u8A0A\u606F\u4E26\u66F4\u6539\u5BC6\u78BC\uFF0C\u60A8\u7684\u5E33\u6236\u5C07\u4FDD\u6301\u4E0D\u8B8A\u3002</p>`,
5036
+ text: `\u78BA\u8A8D\u522A\u9664\u5E33\u6236\u7684\u9A57\u8B49\u78BC\uFF1A{{code}}\uFF0C15 \u5206\u9418\u5167\u6709\u6548\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8ACB\u5FFD\u7565\u4E26\u66F4\u6539\u5BC6\u78BC\u3002`
5037
+ },
5038
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
5039
+ subject: `\u60A8\u7684\u5E33\u6236\u5C07\u65BC {{deleteOn}} \u522A\u9664`,
5040
+ html: `<h1>\u60A8\u7684\u5E33\u6236\u5DF2\u6392\u5B9A\u522A\u9664</h1>
5041
+ <p>\u6211\u5011\u5DF2\u6536\u5230\u60A8\u7684\u7533\u8ACB\u3002\u60A8\u7684\u5E33\u6236\u5DF2\u95DC\u9589\uFF0C\u4E26\u5C07\u65BC <strong>{{deleteOn}}</strong> \u6C38\u4E45\u522A\u9664\u3002</p>
5042
+ <p>\u6539\u8B8A\u4E3B\u610F\u4E86\uFF1F\u8ACB\u5728\u8A72\u65E5\u671F\u524D\u767B\u5165\u4E26\u9078\u64C7<strong>\u4FDD\u7559\u6211\u7684\u5E33\u6236</strong>\u3002</p>
5043
+ <p class="muted">\u5982\u679C\u9019\u4E0D\u662F\u60A8\u672C\u4EBA\u7684\u7533\u8ACB\uFF0C\u8ACB\u7ACB\u5373\u767B\u5165\u4EE5\u4FDD\u7559\u5E33\u6236\u4E26\u66F4\u6539\u5BC6\u78BC\u3002</p>`,
5044
+ text: `\u60A8\u7684\u5E33\u6236\u5C07\u65BC {{deleteOn}} \u6C38\u4E45\u522A\u9664\u3002\u6539\u8B8A\u4E3B\u610F\u4E86\uFF1F\u8ACB\u5728\u6B64\u524D\u767B\u5165\u4E26\u9078\u64C7\u300C\u4FDD\u7559\u6211\u7684\u5E33\u6236\u300D\u3002`
5045
+ },
5046
+ [MESSAGE_KEYS.accountRestored]: {
5047
+ subject: `\u60A8\u7684\u5E33\u6236\u5DF2\u6062\u5FA9`,
5048
+ html: `<h1>\u6B61\u8FCE\u56DE\u4F86</h1>
5049
+ <p>\u60A8\u7684\u5E33\u6236\u522A\u9664\u5DF2\u53D6\u6D88\uFF0C\u5E33\u6236\u5DF2\u91CD\u65B0\u555F\u7528\u3002</p>
5050
+ <p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8ACB\u7ACB\u5373\u66F4\u6539\u5BC6\u78BC\u3002</p>`,
5051
+ text: `\u60A8\u7684\u5E33\u6236\u522A\u9664\u5DF2\u53D6\u6D88\uFF0C\u5E33\u6236\u5DF2\u91CD\u65B0\u555F\u7528\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8ACB\u7ACB\u5373\u66F4\u6539\u5BC6\u78BC\u3002`
4529
5052
  }
4530
5053
  };
4531
5054
 
@@ -4684,9 +5207,32 @@ change your password \u2014 someone else may be able to sign in as you.`
4684
5207
  {{provider}} can no longer be used to sign in. Your email and password still work.
4685
5208
 
4686
5209
  If you did this, you're all set. If not, contact support right away.`
5210
+ },
5211
+ [MESSAGE_KEYS.accountDeletionCode]: {
5212
+ subject: `Confirm your account deletion`,
5213
+ html: `<h1>Confirm account deletion</h1>
5214
+ <p>Use this code to confirm that you want to delete your account:</p>
5215
+ <p><span class="pin-code">{{code}}</span></p>
5216
+ <p class="muted">This code expires in 15 minutes. If you didn&rsquo;t ask to delete your account, ignore this message and change your password &mdash; your account stays as it is.</p>`,
5217
+ text: `Your code to confirm deleting your account: {{code}}. It expires in 15 minutes. Didn't ask? Ignore this and change your password.`
5218
+ },
5219
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
5220
+ subject: `Your account will be deleted on {{deleteOn}}`,
5221
+ html: `<h1>Your account is scheduled for deletion</h1>
5222
+ <p>We received your request. Your account is closed and will be permanently deleted on <strong>{{deleteOn}}</strong>.</p>
5223
+ <p>Changed your mind? Sign in before that date and choose <strong>Keep my account</strong>.</p>
5224
+ <p class="muted">If you didn&rsquo;t ask for this, sign in now to keep your account and change your password.</p>`,
5225
+ text: `Your account will be permanently deleted on {{deleteOn}}. Changed your mind? Sign in before then and choose Keep my account.`
5226
+ },
5227
+ [MESSAGE_KEYS.accountRestored]: {
5228
+ subject: `Your account was restored`,
5229
+ html: `<h1>Welcome back</h1>
5230
+ <p>Your account deletion was cancelled and your account is active again.</p>
5231
+ <p class="muted">If you didn&rsquo;t do this, change your password right away.</p>`,
5232
+ text: `Your account deletion was cancelled; your account is active again. Didn't do this? Change your password now.`
4687
5233
  }
4688
5234
  };
4689
- var DEFAULT_TEMPLATES = (0, import_core14.withTranslations)(EN_TEMPLATES, {
5235
+ var DEFAULT_TEMPLATES = (0, import_core15.withTranslations)(EN_TEMPLATES, {
4690
5236
  fr: FR_TEMPLATES,
4691
5237
  es: ES_TEMPLATES,
4692
5238
  "zh-Hans": ZH_HANS_TEMPLATES,
@@ -4709,7 +5255,10 @@ var SAMPLE_PAYLOADS = {
4709
5255
  [MESSAGE_KEYS.mfaDisabled]: {},
4710
5256
  [MESSAGE_KEYS.mfaBackupCodesRegenerated]: {},
4711
5257
  [MESSAGE_KEYS.emailChanged]: { newEmail: "new@example.com" },
4712
- [MESSAGE_KEYS.phoneChanged]: {}
5258
+ [MESSAGE_KEYS.phoneChanged]: {},
5259
+ [MESSAGE_KEYS.accountDeletionCode]: { code: "123456" },
5260
+ [MESSAGE_KEYS.accountDeletionScheduled]: { deleteOn: "November 3, 2026" },
5261
+ [MESSAGE_KEYS.accountRestored]: {}
4713
5262
  };
4714
5263
 
4715
5264
  // src/middlewares/require-auth.ts