@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.js CHANGED
@@ -44,7 +44,7 @@ var init_password = __esm({
44
44
  });
45
45
 
46
46
  // src/routes.ts
47
- import { setApiResponse as setApiResponse6, HTTP as HTTP6 } from "@fonderie/core";
47
+ import { setApiResponse as setApiResponse7, HTTP as HTTP7 } from "@fonderie/core";
48
48
  import { requireAuth, requireAnyAuth, requireVerified } from "@fonderie/core/middlewares";
49
49
 
50
50
  // src/middlewares/require-email-login.ts
@@ -172,13 +172,16 @@ var schemas_exports = {};
172
172
  __export(schemas_exports, {
173
173
  appleNativeSchema: () => appleNativeSchema,
174
174
  changePasswordSchema: () => changePasswordSchema,
175
+ confirmDeletionSchema: () => confirmDeletionSchema,
175
176
  forgotPasswordSchema: () => forgotPasswordSchema,
176
177
  googleNativeSchema: () => googleNativeSchema,
177
178
  loginSchema: () => loginSchema,
178
179
  mfaTokenSchema: () => mfaTokenSchema,
179
180
  refreshSchema: () => refreshSchema,
180
181
  registerSchema: () => registerSchema,
182
+ requestDeletionSchema: () => requestDeletionSchema,
181
183
  resetPasswordSchema: () => resetPasswordSchema,
184
+ restoreAccountSchema: () => restoreAccountSchema,
182
185
  updateEmailSchema: () => updateEmailSchema,
183
186
  updatePhoneSchema: () => updatePhoneSchema,
184
187
  updatePreferencesSchema: () => updatePreferencesSchema,
@@ -258,6 +261,10 @@ var changePasswordSchema = z.object({
258
261
  newPassword: password
259
262
  });
260
263
  var mfaTokenSchema = z.object({ token: z.string().trim().min(6).max(64) });
264
+ var secondFactor = z.string().trim().min(6).max(16).optional();
265
+ var requestDeletionSchema = z.object({ channel: z.enum(["email", "sms"]) });
266
+ var confirmDeletionSchema = z.object({ code: sixDigitPin, mfaCode: secondFactor });
267
+ var restoreAccountSchema = z.object({ restoreToken: z.string().min(20).max(4096), mfaCode: secondFactor });
261
268
 
262
269
  // src/services/cookies.ts
263
270
  function secureAttr(config) {
@@ -323,13 +330,23 @@ var MESSAGE_KEYS = {
323
330
  // arrives already verified by the provider and has nothing to confirm.
324
331
  oauthRegistration: "oauth-registration",
325
332
  oauthLinked: "oauth-linked",
326
- oauthUnlinked: "oauth-unlinked"
333
+ oauthUnlinked: "oauth-unlinked",
334
+ // Account deletion (docs/ACCOUNT-DELETION-DESIGN.md). Each goes to the ONE
335
+ // verified channel the person chose — route these types to both 'email'
336
+ // and 'sms' in courier: auth fills in only the chosen address, and the
337
+ // other channel skips. SMS sends each template's text.
338
+ accountDeletionCode: "account-deletion-code",
339
+ accountDeletionScheduled: "account-deletion-scheduled",
340
+ accountRestored: "account-restored"
327
341
  };
328
342
  var EVENT_KEYS = {
329
343
  userRegistered: "fonderie.user.registered",
330
344
  userDeleted: "fonderie.user.deleted",
331
345
  // Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
332
346
  userPurged: "fonderie.user.purged",
347
+ // An archived account was kept: { userId }. Undo what deletion paused
348
+ // (billing resumes a subscription set to end at the period's end).
349
+ userRestored: "fonderie.user.restored",
333
350
  emailVerified: "fonderie.user.email_verified",
334
351
  passwordChanged: "fonderie.user.password_changed",
335
352
  // Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
@@ -338,6 +355,10 @@ var EVENT_KEYS = {
338
355
  // 'password-changed', 'admin', 'refresh-reuse' (theft signal).
339
356
  sessionRevoked: "fonderie.session.revoked"
340
357
  };
358
+ var deletionGracePeriodDays = (config) => {
359
+ const days = config.accountDeletion?.gracePeriodDays;
360
+ return typeof days === "number" && Number.isFinite(days) && days >= 0 ? days : 30;
361
+ };
341
362
 
342
363
  // src/services/jwt.ts
343
364
  import { createHash, randomUUID } from "crypto";
@@ -754,6 +775,35 @@ var UserModel = class {
754
775
  );
755
776
  return row ?? null;
756
777
  }
778
+ /**
779
+ * An ARCHIVED (soft-deleted, not yet purged) account holding this address.
780
+ * Only for telling its owner — after they prove it is theirs — that it is
781
+ * scheduled for deletion, and for refusing to create a second account on
782
+ * the address meanwhile. Never for signing in.
783
+ */
784
+ async findArchivedByEmail(email2) {
785
+ const [row] = await this.store.query(
786
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NOT NULL`,
787
+ [email2]
788
+ );
789
+ return row ?? null;
790
+ }
791
+ /** An archived account by id (restore). */
792
+ async findArchivedById(id) {
793
+ const [row] = await this.store.query(
794
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NOT NULL`,
795
+ [id]
796
+ );
797
+ return row ?? null;
798
+ }
799
+ /** As findArchivedByEmail, for a phone number. */
800
+ async findArchivedByPhone(phone2) {
801
+ const [row] = await this.store.query(
802
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NOT NULL`,
803
+ [phone2]
804
+ );
805
+ return row ?? null;
806
+ }
757
807
  async findByPhone(phone2) {
758
808
  const [row] = await this.store.query(
759
809
  `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,
@@ -1297,10 +1347,17 @@ var BackupCodeModel = class {
1297
1347
  );
1298
1348
  return rows.map((r) => ({ id: r.id, codeHash: r.code_hash }));
1299
1349
  }
1350
+ /**
1351
+ * Spend a backup code — once. Conditional on still being unused, so two
1352
+ * requests racing with the same code cannot both sign in: the second finds
1353
+ * it spent (false).
1354
+ */
1300
1355
  async consume(id) {
1301
- await this.store.query(`UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1`, [
1302
- id
1303
- ]);
1356
+ const rows = await this.store.query(
1357
+ `UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`,
1358
+ [id]
1359
+ );
1360
+ return rows.length > 0;
1304
1361
  }
1305
1362
  async deleteByUser(userId) {
1306
1363
  await this.store.query(`DELETE FROM fonderie_mfa_backup_codes WHERE user_id = $1`, [userId]);
@@ -1440,6 +1497,7 @@ function mfaController(store, config, issuer, bus) {
1440
1497
  },
1441
1498
  // ── 2. Verify (setup confirm · TOTP login · backup code login) ──
1442
1499
  verify: async (ctx) => {
1500
+ if (ctx.user.deletedAt) return setApiResponse2(HTTP2.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1443
1501
  const body = ctx.meta["body"];
1444
1502
  const token = body?.["token"];
1445
1503
  const isLoginCompletion = ctx.user.mfaPending === true;
@@ -1494,7 +1552,10 @@ function mfaController(store, config, issuer, bus) {
1494
1552
  if (isLoginCompletion) await failedMfa();
1495
1553
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
1496
1554
  }
1497
- await backupCodes.consume(matched.id);
1555
+ if (!await backupCodes.consume(matched.id)) {
1556
+ if (isLoginCompletion) await failedMfa();
1557
+ return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
1558
+ }
1498
1559
  } else {
1499
1560
  if (!ctx.user.mfaPending) {
1500
1561
  return setApiResponse2(
@@ -1608,7 +1669,7 @@ function mfaController(store, config, issuer, bus) {
1608
1669
  // src/controllers/auth.controller.ts
1609
1670
  import { randomBytes as randomBytes3, randomInt } from "crypto";
1610
1671
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT2 } from "@fonderie/events";
1611
- import { setApiResponse as setApiResponse3, HTTP as HTTP3, background as background3, canonicalLocale, defineLocales } from "@fonderie/core";
1672
+ import { setApiResponse as setApiResponse4, HTTP as HTTP4, background as background3, canonicalLocale, defineLocales } from "@fonderie/core";
1612
1673
 
1613
1674
  // src/services/cooldown.ts
1614
1675
  function checkCooldown(lastSentAt, cooldownMs) {
@@ -1619,6 +1680,72 @@ function checkCooldown(lastSentAt, cooldownMs) {
1619
1680
  // src/controllers/auth.controller.ts
1620
1681
  init_password();
1621
1682
 
1683
+ // src/services/pending-deletion.ts
1684
+ import { setApiResponse as setApiResponse3, HTTP as HTTP3 } from "@fonderie/core";
1685
+
1686
+ // src/services/restore-token.ts
1687
+ import jwt2 from "jsonwebtoken";
1688
+ var RESTORE_TTL = "10m";
1689
+ function issueRestoreToken(userId, deletedAt, loginMethod, config) {
1690
+ return jwt2.sign(
1691
+ { sub: userId, type: "restore", loginMethod, archivedAt: deletedAt.toISOString() },
1692
+ config.jwtSecret,
1693
+ { expiresIn: RESTORE_TTL, keyid: keyIdOf(config.jwtSecret) }
1694
+ );
1695
+ }
1696
+ function verifyRestoreToken(token, config) {
1697
+ const payload = verifyToken(token, config);
1698
+ if (!payload || payload.type !== "restore" || typeof payload.sub !== "string" || typeof payload.archivedAt !== "string") return null;
1699
+ return payload;
1700
+ }
1701
+
1702
+ // src/services/pending-deletion.ts
1703
+ function deletionDate(deletedAt, config) {
1704
+ return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
1705
+ }
1706
+ function pendingDeletionResponse(user, loginMethod, config) {
1707
+ const deleteOn = deletionDate(user.deletedAt, config);
1708
+ return setApiResponse3(
1709
+ HTTP3.FORBIDDEN,
1710
+ "ACCOUNT_PENDING_DELETION",
1711
+ `This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
1712
+ {
1713
+ requestedAt: user.deletedAt.toISOString(),
1714
+ deleteOn: deleteOn.toISOString(),
1715
+ restoreToken: issueRestoreToken(user.id, user.deletedAt, loginMethod, config),
1716
+ mfaRequired: user.mfaEnabled === true
1717
+ }
1718
+ );
1719
+ }
1720
+ function archivedAddressResponse() {
1721
+ return setApiResponse3(
1722
+ HTTP3.CONFLICT,
1723
+ "ACCOUNT_PENDING_DELETION",
1724
+ "An account using this address is scheduled for deletion. Sign in to keep it, or wait until it is deleted."
1725
+ );
1726
+ }
1727
+
1728
+ // src/services/second-factor.ts
1729
+ init_password();
1730
+ async function verifySecondFactor(store, config, userId, code) {
1731
+ if (typeof code !== "string") return false;
1732
+ const value = code.trim();
1733
+ if (/^\d{6}$/.test(value)) {
1734
+ const stored = await new UserModel(store).getMfaSecret(userId);
1735
+ if (!stored) return false;
1736
+ return verifyTotpToken(value, makeMfaCipher(config.mfaSecretKey).decrypt(stored));
1737
+ }
1738
+ if (/^[A-Z0-9]{8}$/i.test(value)) {
1739
+ const backupCodes = new BackupCodeModel(store);
1740
+ for (const row of await backupCodes.findUnused(userId)) {
1741
+ if (await verifyPassword(value.toUpperCase(), row.codeHash)) {
1742
+ return backupCodes.consume(row.id);
1743
+ }
1744
+ }
1745
+ }
1746
+ return false;
1747
+ }
1748
+
1622
1749
  // src/models/password-reset.model.ts
1623
1750
  import { createHash as createHash3 } from "crypto";
1624
1751
  var hashSecret = (value) => createHash3("sha256").update(value).digest("hex");
@@ -1791,19 +1918,20 @@ function authController(store, config, bus, locales = defineLocales()) {
1791
1918
  if (typeof email2 === "string" && typeof password2 === "string") {
1792
1919
  const normalizedEmail = normalizeEmailSafe(email2);
1793
1920
  if (!normalizedEmail) {
1794
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1921
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1795
1922
  }
1796
1923
  if (password2.length < 8) {
1797
- return setApiResponse3(
1798
- HTTP3.UNPROCESSABLE,
1924
+ return setApiResponse4(
1925
+ HTTP4.UNPROCESSABLE,
1799
1926
  "INVALID_PARAMETER",
1800
1927
  "password must be at least 8 characters"
1801
1928
  );
1802
1929
  }
1803
1930
  const existing = await users.findByEmail(normalizedEmail);
1804
1931
  if (existing) {
1805
- return setApiResponse3(HTTP3.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1932
+ return setApiResponse4(HTTP4.CONFLICT, "USER_ALREADY_EXISTS", "Email already registered");
1806
1933
  }
1934
+ if (await users.findArchivedByEmail(normalizedEmail)) return archivedAddressResponse();
1807
1935
  const passwordHash = await hashPassword(password2);
1808
1936
  const row = await users.create(
1809
1937
  normalizedEmail,
@@ -1813,14 +1941,14 @@ function authController(store, config, bus, locales = defineLocales()) {
1813
1941
  locale
1814
1942
  );
1815
1943
  if (!row) {
1816
- return setApiResponse3(HTTP3.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1944
+ return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1817
1945
  }
1818
1946
  const pin = randomInt(1e5, 1e6).toString();
1819
1947
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
1820
1948
  await emailVerif.create(row.id, pin, expiresAt);
1821
1949
  const user = await users.findById(row.id);
1822
1950
  if (!user) {
1823
- return setApiResponse3(HTTP3.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1951
+ return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1824
1952
  }
1825
1953
  const reqId = ctx.meta["requestId"];
1826
1954
  const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
@@ -1878,8 +2006,9 @@ function authController(store, config, bus, locales = defineLocales()) {
1878
2006
  if (isValidPhone(phone2)) {
1879
2007
  const existing = await users.findByPhone(normalizePhone(phone2));
1880
2008
  if (existing) {
1881
- return setApiResponse3(HTTP3.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
2009
+ return setApiResponse4(HTTP4.CONFLICT, "USER_ALREADY_EXISTS", "Phone already registered");
1882
2010
  }
2011
+ if (await users.findArchivedByPhone(normalizePhone(phone2))) return archivedAddressResponse();
1883
2012
  const { id } = await users.findOrCreateByPhone(
1884
2013
  normalizePhone(phone2),
1885
2014
  firstName ?? null,
@@ -1891,7 +2020,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1891
2020
  await phoneVerif.upsert(id, normalizePhone(phone2), otp, expiresAt);
1892
2021
  const user = await users.findById(id);
1893
2022
  if (!user) {
1894
- return setApiResponse3(HTTP3.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
2023
+ return setApiResponse4(HTTP4.SERVER_ERROR, "SERVER_ERROR", "Registration failed");
1895
2024
  }
1896
2025
  const reqId2 = ctx.meta["requestId"];
1897
2026
  const reqOpts2 = reqId2 !== void 0 ? { requestId: reqId2 } : void 0;
@@ -1929,8 +2058,8 @@ function authController(store, config, bus, locales = defineLocales()) {
1929
2058
  { status: 202 }
1930
2059
  );
1931
2060
  }
1932
- return setApiResponse3(
1933
- HTTP3.UNPROCESSABLE,
2061
+ return setApiResponse4(
2062
+ HTTP4.UNPROCESSABLE,
1934
2063
  "INVALID_PARAMETER",
1935
2064
  "Provide email + password or a valid phone number"
1936
2065
  );
@@ -1942,9 +2071,25 @@ function authController(store, config, bus, locales = defineLocales()) {
1942
2071
  const { email: rawEmail, password: password2 } = body;
1943
2072
  const email2 = normalizeEmailSafe(rawEmail);
1944
2073
  if (!email2) {
1945
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2074
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
1946
2075
  }
1947
2076
  const user = await users.findByEmail(email2);
2077
+ if (!user) {
2078
+ const archived = await users.findArchivedByEmail(email2);
2079
+ if (archived?.passwordHash && archived.deletedAt) {
2080
+ const { valid: valid2 } = await verifyPasswordForLogin(password2, archived.passwordHash, config.legacyVerify);
2081
+ await loginEvents.recordSafe({
2082
+ userId: archived.id,
2083
+ emailAttempted: email2,
2084
+ method: "password",
2085
+ outcome: "failed",
2086
+ failureReason: valid2 ? "pending_deletion" : "bad_password",
2087
+ ...meta
2088
+ });
2089
+ if (valid2) return pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, "email", config);
2090
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2091
+ }
2092
+ }
1948
2093
  if (!user || !user.passwordHash) {
1949
2094
  await loginEvents.recordSafe({
1950
2095
  userId: null,
@@ -1954,7 +2099,7 @@ function authController(store, config, bus, locales = defineLocales()) {
1954
2099
  failureReason: "unknown_email",
1955
2100
  ...meta
1956
2101
  });
1957
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2102
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1958
2103
  }
1959
2104
  const { valid, needsRehash } = await verifyPasswordForLogin(
1960
2105
  password2,
@@ -1970,21 +2115,21 @@ function authController(store, config, bus, locales = defineLocales()) {
1970
2115
  failureReason: "bad_password",
1971
2116
  ...meta
1972
2117
  });
1973
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2118
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
1974
2119
  }
1975
2120
  if (needsRehash) {
1976
2121
  await users.updatePassword(user.id, await hashPassword(password2));
1977
2122
  }
1978
2123
  if (user.suspended) {
1979
- return setApiResponse3(
1980
- HTTP3.FORBIDDEN,
2124
+ return setApiResponse4(
2125
+ HTTP4.FORBIDDEN,
1981
2126
  "ACCOUNT_SUSPENDED",
1982
2127
  "Account suspended. Please contact support."
1983
2128
  );
1984
2129
  }
1985
2130
  if (user.mfaEnabled) {
1986
2131
  const mfaToken = issueMfaPendingToken(user.id, config, "email");
1987
- return setApiResponse3(HTTP3.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
2132
+ return setApiResponse4(HTTP4.OK, "MFA_REQUIRED", "Multi-factor authentication required", {
1988
2133
  mfaToken
1989
2134
  });
1990
2135
  }
@@ -2019,13 +2164,13 @@ function authController(store, config, bus, locales = defineLocales()) {
2019
2164
  }
2020
2165
  const phone2 = body?.["phone"];
2021
2166
  if (isValidPhone(phone2)) {
2022
- const user = await users.findByPhone(normalizePhone(phone2));
2167
+ const user = await users.findByPhone(normalizePhone(phone2)) ?? await users.findArchivedByPhone(normalizePhone(phone2));
2023
2168
  if (!user) {
2024
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2169
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2025
2170
  }
2026
2171
  if (user.suspended) {
2027
- return setApiResponse3(
2028
- HTTP3.FORBIDDEN,
2172
+ return setApiResponse4(
2173
+ HTTP4.FORBIDDEN,
2029
2174
  "ACCOUNT_SUSPENDED",
2030
2175
  "Account suspended. Please contact support."
2031
2176
  );
@@ -2049,12 +2194,71 @@ function authController(store, config, bus, locales = defineLocales()) {
2049
2194
  { status: 202 }
2050
2195
  );
2051
2196
  }
2052
- return setApiResponse3(
2053
- HTTP3.UNPROCESSABLE,
2197
+ return setApiResponse4(
2198
+ HTTP4.UNPROCESSABLE,
2054
2199
  "INVALID_PARAMETER",
2055
2200
  "Provide email + password or a valid phone number"
2056
2201
  );
2057
2202
  },
2203
+ /**
2204
+ * "Keep my account": the restoreToken from a sign-in to an archived account
2205
+ * (proof already given: password, Google / Apple, phone code), plus a
2206
+ * second factor when two-factor is on. Un-archives in one statement —
2207
+ * only the SAME archive the proof was for — then signs the person in.
2208
+ */
2209
+ restoreAccount: async (ctx) => {
2210
+ const body = ctx.meta["body"];
2211
+ const proof = verifyRestoreToken(String(body?.["restoreToken"] ?? ""), config);
2212
+ const invalid = () => setApiResponse4(HTTP4.UNAUTHORIZED, "RESTORE_TOKEN_INVALID", "Sign in again to keep your account.");
2213
+ if (!proof) return invalid();
2214
+ const archived = await users.findArchivedById(proof.sub);
2215
+ if (!archived?.deletedAt || archived.deletedAt.toISOString() !== proof.archivedAt) return invalid();
2216
+ if (archived.suspended) {
2217
+ return setApiResponse4(HTTP4.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
2218
+ }
2219
+ if (archived.mfaEnabled && !await verifySecondFactor(store, config, archived.id, body?.["mfaCode"])) {
2220
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to keep your account.");
2221
+ }
2222
+ const [restored] = await store.query(
2223
+ `UPDATE fonderie_users
2224
+ SET deleted_at = NULL, deletion_channel = NULL, deletion_reminded_at = NULL, updated_at = now()
2225
+ -- The SAME archive the proof was for. Postgres keeps microseconds,
2226
+ -- the proof (a JS date) milliseconds: compare at that precision.
2227
+ WHERE id = $1 AND date_trunc('milliseconds', deleted_at) = date_trunc('milliseconds', $2::timestamptz)
2228
+ RETURNING (SELECT deletion_channel FROM fonderie_users WHERE id = $1) AS channel`,
2229
+ [archived.id, proof.archivedAt]
2230
+ );
2231
+ if (!restored) return invalid();
2232
+ const meta = requestMeta(ctx);
2233
+ const method = proof.loginMethod === "google" ? "oauth-google" : proof.loginMethod === "apple" ? "oauth-apple" : proof.loginMethod === "phone" ? "phone" : "password";
2234
+ const { accessToken, refreshToken, sid } = issueTokenPair(archived.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2235
+ loginMethod: proof.loginMethod,
2236
+ phoneVerified: proof.loginMethod === "phone"
2237
+ });
2238
+ await sessions.create(archived.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2239
+ await loginEvents.recordSafe({ userId: archived.id, emailAttempted: archived.email, method, outcome: "success", ...meta });
2240
+ const reqId = ctx.meta["requestId"];
2241
+ await background3(bus?.emit(EVENT_KEYS.userRestored, { userId: archived.id }, reqId !== void 0 ? { requestId: reqId } : void 0));
2242
+ const viaSms = restored.channel === "sms" || !archived.email && !!archived.phone;
2243
+ const address = viaSms ? archived.phone : archived.email;
2244
+ if (address) {
2245
+ await background3(bus?.emit(NOTIFICATION_EVENT2, {
2246
+ type: MESSAGE_KEYS.accountRestored,
2247
+ locale: archived.locale,
2248
+ data: {},
2249
+ recipient: viaSms ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
2250
+ }));
2251
+ }
2252
+ const user = await users.findById(archived.id);
2253
+ return Response.json(
2254
+ {
2255
+ reason: "ACCOUNT_RESTORED",
2256
+ explanation: "Your account is active again.",
2257
+ result: { tokens: { access: accessToken, refresh: refreshToken }, user: user ? toUserDTO(user) : null }
2258
+ },
2259
+ { status: 200, headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config)) }
2260
+ );
2261
+ },
2058
2262
  logout: async (ctx) => {
2059
2263
  const sid = ctx.user?.sid;
2060
2264
  if (sid) {
@@ -2075,35 +2279,35 @@ function authController(store, config, bus, locales = defineLocales()) {
2075
2279
  refresh: async (ctx) => {
2076
2280
  const token = extractRefreshToken(ctx);
2077
2281
  if (!token) {
2078
- return setApiResponse3(HTTP3.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2282
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_PARAMETER", "No refresh token provided");
2079
2283
  }
2080
2284
  const payload = verifyToken(token, config);
2081
2285
  if (!payload || payload.type !== "refresh") {
2082
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2286
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Invalid refresh token");
2083
2287
  }
2084
2288
  const runtime = config.resolve?.(ctx);
2085
2289
  let issued = null;
2086
2290
  for (let attempt = 0; attempt < 3 && !issued; attempt++) {
2087
2291
  const found = await sessions.match(token);
2088
2292
  if (found.kind === "unknown") {
2089
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2293
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2090
2294
  }
2091
2295
  if (found.kind === "reused") {
2092
2296
  await sessions.revokeById(found.row.id);
2093
2297
  await background3(
2094
2298
  bus?.emit(EVENT_KEYS.sessionRevoked, { userId: found.row.userId, sids: [found.row.sid], reason: "refresh-reuse" })
2095
2299
  );
2096
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2300
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2097
2301
  }
2098
2302
  const resolvedRefresh = configForClient(config, runtime, found.row.clientKind);
2099
2303
  const maxAgeMs = resolvedRefresh.sessionMaxAge ? durationMs(String(resolvedRefresh.sessionMaxAge)) : null;
2100
2304
  if (maxAgeMs !== null && Date.now() - found.row.createdAt.getTime() > maxAgeMs) {
2101
2305
  await sessions.revokeById(found.row.id);
2102
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2306
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session reached its maximum age \u2014 sign in again");
2103
2307
  }
2104
2308
  const user = await users.findById(found.row.userId);
2105
2309
  if (!user || user.suspended || user.deletedAt || user.id !== payload.sub) {
2106
- return setApiResponse3(HTTP3.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2310
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
2107
2311
  }
2108
2312
  const pair = issueTokenPair(user.id, resolvedRefresh, {
2109
2313
  loginMethod: payload.loginMethod ?? "email",
@@ -2116,7 +2320,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2116
2320
  if (await sessions.rotate(found.row, token, next, found.kind)) issued = pair;
2117
2321
  }
2118
2322
  if (!issued) {
2119
- return setApiResponse3(HTTP3.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2323
+ return setApiResponse4(HTTP4.UNAUTHORIZED, "TOKEN_REFRESH_FAILED", "Session expired or already revoked");
2120
2324
  }
2121
2325
  const { accessToken, refreshToken } = issued;
2122
2326
  return Response.json(
@@ -2135,16 +2339,16 @@ function authController(store, config, bus, locales = defineLocales()) {
2135
2339
  const body = ctx.meta["body"];
2136
2340
  const rawEmail = body?.["email"];
2137
2341
  if (typeof rawEmail !== "string") {
2138
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2342
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "email is required");
2139
2343
  }
2140
2344
  const email2 = normalizeEmailSafe(rawEmail);
2141
2345
  if (!email2) {
2142
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2346
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid email address");
2143
2347
  }
2144
2348
  const user = await users.findByEmail(email2);
2145
2349
  if (!user) {
2146
- return setApiResponse3(
2147
- HTTP3.OK,
2350
+ return setApiResponse4(
2351
+ HTTP4.OK,
2148
2352
  "PASSWORD_RESET_EMAIL_SENT",
2149
2353
  "Password reset email sent (if account exists)."
2150
2354
  );
@@ -2153,8 +2357,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2153
2357
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2154
2358
  const remaining = checkCooldown(await passwordReset.findLastSentAt(user.id), cooldown);
2155
2359
  if (remaining > 0) {
2156
- return setApiResponse3(
2157
- HTTP3.OK,
2360
+ return setApiResponse4(
2361
+ HTTP4.OK,
2158
2362
  "PASSWORD_RESET_EMAIL_SENT",
2159
2363
  "Password reset email sent (if account exists)."
2160
2364
  );
@@ -2171,8 +2375,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2171
2375
  recipient: { email: email2, phone: null, deviceToken: null },
2172
2376
  data: { pin, token, resetUrl }
2173
2377
  }));
2174
- return setApiResponse3(
2175
- HTTP3.OK,
2378
+ return setApiResponse4(
2379
+ HTTP4.OK,
2176
2380
  "PASSWORD_RESET_EMAIL_SENT",
2177
2381
  "Password reset email sent (if account exists)."
2178
2382
  );
@@ -2183,56 +2387,72 @@ function authController(store, config, bus, locales = defineLocales()) {
2183
2387
  const password2 = body?.["password"];
2184
2388
  const rawToken = body?.["token"];
2185
2389
  if (typeof password2 !== "string") {
2186
- return setApiResponse3(HTTP3.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2390
+ return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
2187
2391
  }
2188
2392
  if (password2.length < 8) {
2189
- return setApiResponse3(
2190
- HTTP3.UNPROCESSABLE,
2393
+ return setApiResponse4(
2394
+ HTTP4.UNPROCESSABLE,
2191
2395
  "INVALID_PARAMETER",
2192
2396
  "password must be at least 8 characters"
2193
2397
  );
2194
2398
  }
2195
2399
  let row;
2400
+ let presented;
2196
2401
  if (typeof rawToken === "string" && rawToken.trim().length >= 32) {
2197
- row = await passwordReset.findByToken(rawToken.trim());
2402
+ presented = rawToken.trim();
2403
+ row = await passwordReset.findByToken(presented);
2198
2404
  } else {
2199
2405
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2200
- return setApiResponse3(
2201
- HTTP3.UNPROCESSABLE,
2406
+ return setApiResponse4(
2407
+ HTTP4.UNPROCESSABLE,
2202
2408
  "INVALID_PARAMETER",
2203
2409
  "a 6-digit pin or a reset token is required"
2204
2410
  );
2205
2411
  }
2206
- row = await passwordReset.findByPin(raw.trim());
2412
+ presented = raw.trim();
2413
+ row = await passwordReset.findByPin(presented);
2207
2414
  }
2208
2415
  if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
2209
- return setApiResponse3(
2210
- HTTP3.BAD_REQUEST,
2416
+ return setApiResponse4(
2417
+ HTTP4.BAD_REQUEST,
2211
2418
  "PASSWORD_RESET_FAILED",
2212
2419
  "Invalid or expired reset credentials"
2213
2420
  );
2214
2421
  }
2215
2422
  const passwordHash = await hashPassword(password2);
2216
- await store.transaction(async (tx) => {
2217
- await Promise.all([
2218
- tx.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
2219
- passwordHash,
2220
- row.userId
2221
- ]),
2222
- tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
2223
- // Revoke every session: the reset exists because the account may
2224
- // be compromised — a stolen session must not survive it.
2225
- tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
2226
- ]);
2227
- });
2228
- return setApiResponse3(HTTP3.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2423
+ const [changed] = await store.query(
2424
+ `WITH spent AS (
2425
+ -- The code is spent HERE, and only what this statement spent
2426
+ -- changes a password: two resets racing with one code \u2014 the
2427
+ -- second finds it gone and changes nothing.
2428
+ DELETE FROM fonderie_password_resets
2429
+ WHERE user_id = $2 AND (pin = $3 OR token = $3) AND expires_at > now()
2430
+ RETURNING user_id
2431
+ ), changed AS (
2432
+ UPDATE fonderie_users SET password_hash = $1, updated_at = now()
2433
+ WHERE id IN (SELECT user_id FROM spent) AND deleted_at IS NULL
2434
+ RETURNING id
2435
+ ), revoked AS (
2436
+ DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
2437
+ )
2438
+ SELECT id FROM changed`,
2439
+ [passwordHash, row.userId, hashSecret(presented)]
2440
+ );
2441
+ if (!changed) {
2442
+ return setApiResponse4(
2443
+ HTTP4.BAD_REQUEST,
2444
+ "PASSWORD_RESET_FAILED",
2445
+ "Invalid or expired reset credentials"
2446
+ );
2447
+ }
2448
+ return setApiResponse4(HTTP4.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
2229
2449
  },
2230
2450
  verify: async (ctx) => {
2231
2451
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2232
- return setApiResponse3(HTTP3.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2452
+ return setApiResponse4(HTTP4.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2233
2453
  }
2234
2454
  if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
2235
- return setApiResponse3(HTTP3.OK, "VERIFIED", "Email verified successfully.", {
2455
+ return setApiResponse4(HTTP4.OK, "VERIFIED", "Email verified successfully.", {
2236
2456
  verified: true,
2237
2457
  email: ctx.user.email
2238
2458
  });
@@ -2240,8 +2460,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2240
2460
  const body = ctx.meta["body"];
2241
2461
  const raw = body?.["token"];
2242
2462
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2243
- return setApiResponse3(
2244
- HTTP3.UNPROCESSABLE,
2463
+ return setApiResponse4(
2464
+ HTTP4.UNPROCESSABLE,
2245
2465
  "INVALID_PARAMETER",
2246
2466
  "token must be a 6-digit code"
2247
2467
  );
@@ -2259,7 +2479,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2259
2479
  failureReason: "invalid_pin",
2260
2480
  ...phoneMeta
2261
2481
  });
2262
- return setApiResponse3(HTTP3.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2482
+ return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2263
2483
  }
2264
2484
  if (/* @__PURE__ */ new Date() > record.expiresAt) {
2265
2485
  await phoneVerif.deleteByUser(ctx.user.id);
@@ -2271,13 +2491,24 @@ function authController(store, config, bus, locales = defineLocales()) {
2271
2491
  failureReason: "expired_pin",
2272
2492
  ...phoneMeta
2273
2493
  });
2274
- return setApiResponse3(
2275
- HTTP3.BAD_REQUEST,
2494
+ return setApiResponse4(
2495
+ HTTP4.BAD_REQUEST,
2276
2496
  "VERIFICATION_FAILED",
2277
2497
  "Verification code expired"
2278
2498
  );
2279
2499
  }
2280
2500
  await phoneVerif.deleteByUser(ctx.user.id);
2501
+ if (ctx.user.deletedAt) {
2502
+ await loginEvents.recordSafe({
2503
+ userId: ctx.user.id,
2504
+ emailAttempted: null,
2505
+ method: "phone",
2506
+ outcome: "failed",
2507
+ failureReason: "pending_deletion",
2508
+ ...phoneMeta
2509
+ });
2510
+ return pendingDeletionResponse({ ...ctx.user, deletedAt: ctx.user.deletedAt }, "phone", config);
2511
+ }
2281
2512
  const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2282
2513
  loginMethod: "phone",
2283
2514
  phoneVerified: true
@@ -2292,11 +2523,11 @@ function authController(store, config, bus, locales = defineLocales()) {
2292
2523
  });
2293
2524
  const verifiedUser = await users.findById(ctx.user.id);
2294
2525
  if (!verifiedUser) {
2295
- return setApiResponse3(HTTP3.NOT_FOUND, "NOT_FOUND", "User not found");
2526
+ return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2296
2527
  }
2297
2528
  if (verifiedUser.suspended) {
2298
- return setApiResponse3(
2299
- HTTP3.FORBIDDEN,
2529
+ return setApiResponse4(
2530
+ HTTP4.FORBIDDEN,
2300
2531
  "ACCOUNT_SUSPENDED",
2301
2532
  "Account suspended. Please contact support."
2302
2533
  );
@@ -2318,10 +2549,10 @@ function authController(store, config, bus, locales = defineLocales()) {
2318
2549
  }
2319
2550
  const row = await emailVerif.findByUser(ctx.user.id, pin);
2320
2551
  if (!row) {
2321
- return setApiResponse3(HTTP3.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2552
+ return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Invalid or expired pin");
2322
2553
  }
2323
2554
  if (/* @__PURE__ */ new Date() > row.expiresAt) {
2324
- return setApiResponse3(HTTP3.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2555
+ return setApiResponse4(HTTP4.BAD_REQUEST, "VERIFICATION_FAILED", "Pin expired");
2325
2556
  }
2326
2557
  await store.transaction(async (tx) => {
2327
2558
  await Promise.all([
@@ -2335,30 +2566,30 @@ function authController(store, config, bus, locales = defineLocales()) {
2335
2566
  ])
2336
2567
  ]);
2337
2568
  });
2338
- return setApiResponse3(HTTP3.OK, "VERIFIED", "Email verified successfully.", {
2569
+ return setApiResponse4(HTTP4.OK, "VERIFIED", "Email verified successfully.", {
2339
2570
  verified: true,
2340
2571
  email: ctx.user.email
2341
2572
  });
2342
2573
  },
2343
2574
  sendVerification: async (ctx) => {
2344
2575
  if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
2345
- return setApiResponse3(HTTP3.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2576
+ return setApiResponse4(HTTP4.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
2346
2577
  }
2347
2578
  const resolved = { ...config, ...config.resolve?.(ctx) };
2348
2579
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
2349
2580
  if (ctx.user.loginMethod === "phone") {
2350
2581
  const phone2 = ctx.user.phone;
2351
2582
  if (!phone2) {
2352
- return setApiResponse3(
2353
- HTTP3.BAD_REQUEST,
2583
+ return setApiResponse4(
2584
+ HTTP4.BAD_REQUEST,
2354
2585
  "NO_PHONE_ON_ACCOUNT",
2355
2586
  "No phone number associated with this account"
2356
2587
  );
2357
2588
  }
2358
2589
  const remaining2 = checkCooldown(await phoneVerif.findLastSentAt(ctx.user.id), cooldown);
2359
2590
  if (remaining2 > 0) {
2360
- return setApiResponse3(
2361
- HTTP3.TOO_MANY_REQUESTS,
2591
+ return setApiResponse4(
2592
+ HTTP4.TOO_MANY_REQUESTS,
2362
2593
  "VERIFICATION_COOLDOWN",
2363
2594
  "Please wait before requesting a new code.",
2364
2595
  {
@@ -2375,29 +2606,29 @@ function authController(store, config, bus, locales = defineLocales()) {
2375
2606
  data: { otp },
2376
2607
  recipient: { email: null, phone: phone2, deviceToken: null }
2377
2608
  }));
2378
- return setApiResponse3(
2379
- HTTP3.OK,
2609
+ return setApiResponse4(
2610
+ HTTP4.OK,
2380
2611
  "VERIFICATION_SENT",
2381
2612
  "A verification code has been sent to your phone."
2382
2613
  );
2383
2614
  }
2384
2615
  if (!ctx.user.email) {
2385
- return setApiResponse3(
2386
- HTTP3.BAD_REQUEST,
2616
+ return setApiResponse4(
2617
+ HTTP4.BAD_REQUEST,
2387
2618
  "NO_EMAIL_ON_ACCOUNT",
2388
2619
  "No email address associated with this account"
2389
2620
  );
2390
2621
  }
2391
2622
  if (ctx.user.emailVerifiedAt) {
2392
- return setApiResponse3(HTTP3.OK, "EMAIL_VERIFIED", "Email already verified.", {
2623
+ return setApiResponse4(HTTP4.OK, "EMAIL_VERIFIED", "Email already verified.", {
2393
2624
  verified: true,
2394
2625
  email: ctx.user.email
2395
2626
  });
2396
2627
  }
2397
2628
  const remaining = checkCooldown(await emailVerif.findLastSentAt(ctx.user.id), cooldown);
2398
2629
  if (remaining > 0) {
2399
- return setApiResponse3(
2400
- HTTP3.TOO_MANY_REQUESTS,
2630
+ return setApiResponse4(
2631
+ HTTP4.TOO_MANY_REQUESTS,
2401
2632
  "VERIFICATION_COOLDOWN",
2402
2633
  "Please wait before requesting a new code.",
2403
2634
  {
@@ -2414,7 +2645,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2414
2645
  recipient: { email: ctx.user.email, phone: null, deviceToken: null },
2415
2646
  data: { pin }
2416
2647
  }));
2417
- return setApiResponse3(HTTP3.OK, "VERIFICATION_SENT", "Verification email sent.", {
2648
+ return setApiResponse4(HTTP4.OK, "VERIFICATION_SENT", "Verification email sent.", {
2418
2649
  email: ctx.user.email
2419
2650
  });
2420
2651
  }
@@ -2423,7 +2654,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2423
2654
 
2424
2655
  // src/controllers/user.controller.ts
2425
2656
  import { randomInt as randomInt2 } from "crypto";
2426
- import { setApiResponse as setApiResponse4, HTTP as HTTP4, dateOrEmpty as dateOrEmpty2, background as background4 } from "@fonderie/core";
2657
+ import { setApiResponse as setApiResponse5, HTTP as HTTP5, dateOrEmpty as dateOrEmpty2, background as background4, COURIER_FORMAT_KEY } from "@fonderie/core";
2427
2658
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
2428
2659
 
2429
2660
  // src/dtos/login-activity.ts
@@ -2461,7 +2692,64 @@ function toSessionDTO(row, currentSid) {
2461
2692
  };
2462
2693
  }
2463
2694
 
2695
+ // src/models/account-deletion.model.ts
2696
+ import { createHash as createHash4 } from "crypto";
2697
+ var hashCode = (code) => createHash4("sha256").update(code).digest("hex");
2698
+ var DELETION_CODE_MAX_ATTEMPTS = 5;
2699
+ var AccountDeletionModel = class {
2700
+ constructor(store) {
2701
+ this.store = store;
2702
+ }
2703
+ store;
2704
+ /** Replace any pending code (a new request supersedes the old one). */
2705
+ async saveCode(userId, code, channel, expiresAt) {
2706
+ await this.store.query(
2707
+ `INSERT INTO fonderie_account_deletion_codes (user_id, code_hash, channel, attempts, expires_at, created_at)
2708
+ VALUES ($1, $2, $3, 0, $4, now())
2709
+ ON CONFLICT (user_id) DO UPDATE
2710
+ SET code_hash = $2, channel = $3, attempts = 0, expires_at = $4, created_at = now()`,
2711
+ [userId, hashCode(code), channel, expiresAt]
2712
+ );
2713
+ }
2714
+ async lastSentAt(userId) {
2715
+ const [row] = await this.store.query(
2716
+ `SELECT created_at AS "createdAt" FROM fonderie_account_deletion_codes WHERE user_id = $1`,
2717
+ [userId]
2718
+ );
2719
+ return row ? new Date(row.createdAt) : null;
2720
+ }
2721
+ /**
2722
+ * Check a code in ONE statement: a wrong code spends an attempt, a right
2723
+ * one is consumed (deleted) — so a code is good once, and a guesser gets
2724
+ * five tries in total however many requests they race.
2725
+ */
2726
+ async checkCode(userId, code) {
2727
+ const [row] = await this.store.query(
2728
+ `WITH current AS (
2729
+ SELECT user_id, code_hash = $2 AS matched, expires_at <= now() AS expired, attempts, channel
2730
+ FROM fonderie_account_deletion_codes WHERE user_id = $1
2731
+ FOR UPDATE
2732
+ ), spent AS (
2733
+ DELETE FROM fonderie_account_deletion_codes d USING current c
2734
+ WHERE d.user_id = c.user_id AND c.matched AND NOT c.expired AND c.attempts < $3
2735
+ ), missed AS (
2736
+ UPDATE fonderie_account_deletion_codes d SET attempts = d.attempts + 1
2737
+ FROM current c
2738
+ WHERE d.user_id = c.user_id AND NOT (c.matched AND NOT c.expired AND c.attempts < $3)
2739
+ )
2740
+ SELECT matched, expired, attempts, channel FROM current`,
2741
+ [userId, hashCode(code), DELETION_CODE_MAX_ATTEMPTS]
2742
+ );
2743
+ if (!row) return { result: "none" };
2744
+ if (row.attempts >= DELETION_CODE_MAX_ATTEMPTS) return { result: "exhausted" };
2745
+ if (row.expired) return { result: "expired" };
2746
+ return { result: row.matched ? "ok" : "invalid", channel: row.channel };
2747
+ }
2748
+ };
2749
+
2464
2750
  // src/controllers/user.controller.ts
2751
+ var DELETION_CODE_TTL_MS = 15 * 60 * 1e3;
2752
+ var DELETION_CODE_COOLDOWN_MS = 60 * 1e3;
2465
2753
  function normalizePhone2(phone2) {
2466
2754
  return phone2.trim().replace(/[\s()\-\.]/g, "");
2467
2755
  }
@@ -2474,13 +2762,62 @@ function userController(store, config, bus) {
2474
2762
  const loginEvents = new LoginEventModel(store, config.location);
2475
2763
  const emailVerif = new EmailVerificationModel(store);
2476
2764
  const phoneVerif = new PhoneVerificationModel(store);
2765
+ const deletions = new AccountDeletionModel(store);
2766
+ const deletionBlocked = async (userId) => {
2767
+ for (const blocker of config.accountDeletion?.blockers ?? []) {
2768
+ const refusal = await blocker(userId);
2769
+ if (refusal) return setApiResponse5(HTTP5.CONFLICT, refusal.reason, refusal.explanation, refusal.details);
2770
+ }
2771
+ return null;
2772
+ };
2773
+ const archive = async (userId, channel) => {
2774
+ const [row] = await store.query(
2775
+ `WITH archived AS (
2776
+ UPDATE fonderie_users
2777
+ SET deleted_at = now(), updated_at = now(), deletion_channel = $2, deletion_reminded_at = NULL
2778
+ WHERE id = $1 AND deleted_at IS NULL
2779
+ RETURNING id, deleted_at
2780
+ ), sessions_ended AS (
2781
+ DELETE FROM fonderie_sessions WHERE user_id = $1
2782
+ ), resets AS (
2783
+ DELETE FROM fonderie_password_resets WHERE user_id = $1
2784
+ ), emails AS (
2785
+ DELETE FROM fonderie_email_verifications WHERE user_id = $1
2786
+ ), phones AS (
2787
+ DELETE FROM fonderie_phone_verifications WHERE user_id = $1
2788
+ ), codes AS (
2789
+ DELETE FROM fonderie_account_deletion_codes WHERE user_id = $1
2790
+ )
2791
+ SELECT deleted_at AS "deletedAt" FROM archived`,
2792
+ [userId, channel]
2793
+ );
2794
+ return row ? new Date(row.deletedAt) : /* @__PURE__ */ new Date();
2795
+ };
2796
+ const announceArchived = async (ctx, deletedAt) => {
2797
+ const userId = ctx.user.id;
2798
+ const reqId = ctx.meta["requestId"];
2799
+ await background4(bus?.emit(
2800
+ EVENT_KEYS.userDeleted,
2801
+ { userId, deletedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() },
2802
+ reqId !== void 0 ? { requestId: reqId } : void 0
2803
+ ));
2804
+ await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2805
+ };
2806
+ const archivedResponse = (deletedAt) => Response.json(
2807
+ {
2808
+ reason: "ACCOUNT_DELETED",
2809
+ explanation: "Account closed. It will be permanently deleted on the date shown; sign in before then to keep it.",
2810
+ result: { requestedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() }
2811
+ },
2812
+ { status: 200, headers: cookieHeaders(clearedTokenCookies(config)) }
2813
+ );
2477
2814
  return {
2478
2815
  me: async (ctx) => {
2479
2816
  const user = await users.findById(ctx.user.id);
2480
2817
  if (!user) {
2481
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2818
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2482
2819
  }
2483
- return setApiResponse4(HTTP4.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2820
+ return setApiResponse5(HTTP5.OK, "USER_ACCOUNT_FETCHED", "User account fetched successful.", {
2484
2821
  user: toUserDTO(user, ctx.user.phoneVerified)
2485
2822
  });
2486
2823
  },
@@ -2491,14 +2828,14 @@ function userController(store, config, bus) {
2491
2828
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
2492
2829
  const outcomeParam = params.get("outcome");
2493
2830
  if (outcomeParam !== null && outcomeParam !== "success" && outcomeParam !== "failed") {
2494
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2831
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "outcome must be 'success' or 'failed'");
2495
2832
  }
2496
2833
  const cursorParam = params.get("cursor");
2497
2834
  let cursor = null;
2498
2835
  if (cursorParam) {
2499
2836
  cursor = decodeLoginCursor(cursorParam);
2500
2837
  if (!cursor) {
2501
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2838
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
2502
2839
  }
2503
2840
  }
2504
2841
  const query = { userId: ctx.user.id, limit };
@@ -2509,8 +2846,8 @@ function userController(store, config, bus) {
2509
2846
  if (from) query.from = new Date(from);
2510
2847
  if (to) query.to = new Date(to);
2511
2848
  const page = await loginEvents.listByUser(query);
2512
- return setApiResponse4(
2513
- HTTP4.OK,
2849
+ return setApiResponse5(
2850
+ HTTP5.OK,
2514
2851
  "LOGIN_HISTORY_FETCHED",
2515
2852
  "Login history retrieved.",
2516
2853
  toLoginHistoryPageDTO(page)
@@ -2520,27 +2857,27 @@ function userController(store, config, bus) {
2520
2857
  listSessions: async (ctx) => {
2521
2858
  const rows = await sessions.listLiveByUser(ctx.user.id);
2522
2859
  const currentSid = ctx.user.sid ?? null;
2523
- return setApiResponse4(HTTP4.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2860
+ return setApiResponse5(HTTP5.OK, "SESSIONS_FETCHED", "Active sessions retrieved.", {
2524
2861
  sessions: rows.map((r) => toSessionDTO(r, currentSid))
2525
2862
  });
2526
2863
  },
2527
2864
  terminateSession: async (ctx) => {
2528
2865
  const id = ctx.meta["params"]?.["id"];
2529
2866
  if (!id) {
2530
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
2867
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Session not found");
2531
2868
  }
2532
2869
  const removed = await sessions.terminateByIdReturningSid(ctx.user.id, id);
2533
2870
  if (removed === void 0) {
2534
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "Session not found");
2871
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "Session not found");
2535
2872
  }
2536
2873
  await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: [removed], reason: "terminated" }));
2537
- return setApiResponse4(HTTP4.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2874
+ return setApiResponse5(HTTP5.OK, "SESSION_TERMINATED", "Session terminated.", { id });
2538
2875
  },
2539
2876
  terminateOtherSessions: async (ctx) => {
2540
2877
  const currentSid = ctx.user.sid ?? null;
2541
2878
  if (!currentSid) {
2542
- return setApiResponse4(
2543
- HTTP4.UNPROCESSABLE,
2879
+ return setApiResponse5(
2880
+ HTTP5.UNPROCESSABLE,
2544
2881
  "NO_CURRENT_SESSION",
2545
2882
  "Cannot terminate others without a current session context"
2546
2883
  );
@@ -2548,7 +2885,7 @@ function userController(store, config, bus) {
2548
2885
  const sids = await sessions.terminateOthersReturningSids(ctx.user.id, currentSid);
2549
2886
  const count = sids.length;
2550
2887
  if (count) await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids, reason: "terminated" }));
2551
- return setApiResponse4(HTTP4.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2888
+ return setApiResponse5(HTTP5.OK, "SESSIONS_TERMINATED", "Other sessions terminated.", { count });
2552
2889
  },
2553
2890
  updateProfile: async (ctx) => {
2554
2891
  const body = ctx.meta["body"];
@@ -2558,18 +2895,18 @@ function userController(store, config, bus) {
2558
2895
  if (body?.[key] !== void 0) fields[key] = body[key];
2559
2896
  }
2560
2897
  if (Object.keys(fields).length === 0) {
2561
- return setApiResponse4(
2562
- HTTP4.UNPROCESSABLE,
2898
+ return setApiResponse5(
2899
+ HTTP5.UNPROCESSABLE,
2563
2900
  "INVALID_PARAMETER",
2564
2901
  "Provide at least one of: firstName, lastName, avatarUrl"
2565
2902
  );
2566
2903
  }
2567
2904
  const row = await users.update(ctx.user.id, fields);
2568
2905
  if (!row) {
2569
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
2906
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2570
2907
  }
2571
2908
  const updated = await users.findById(ctx.user.id);
2572
- return setApiResponse4(HTTP4.OK, "PROFILE_UPDATED", "Profile updated.", {
2909
+ return setApiResponse5(HTTP5.OK, "PROFILE_UPDATED", "Profile updated.", {
2573
2910
  user: toUserDTO(updated, ctx.user.phoneVerified)
2574
2911
  });
2575
2912
  },
@@ -2586,14 +2923,14 @@ function userController(store, config, bus) {
2586
2923
  if (Object.keys(patch).length > 0) fields.patch = patch;
2587
2924
  const row = await users.updatePreferences(ctx.user.id, fields);
2588
2925
  if (!row) {
2589
- return setApiResponse4(
2590
- HTTP4.UNPROCESSABLE,
2926
+ return setApiResponse5(
2927
+ HTTP5.UNPROCESSABLE,
2591
2928
  "INVALID_PARAMETER",
2592
2929
  "Provide at least one preference field"
2593
2930
  );
2594
2931
  }
2595
2932
  const updated = await users.findById(ctx.user.id);
2596
- return setApiResponse4(HTTP4.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2933
+ return setApiResponse5(HTTP5.OK, "PREFERENCES_UPDATED", "Preferences updated.", {
2597
2934
  user: toUserDTO(updated, ctx.user.phoneVerified)
2598
2935
  });
2599
2936
  },
@@ -2602,23 +2939,23 @@ function userController(store, config, bus) {
2602
2939
  const newEmail = body?.["email"];
2603
2940
  const normalised = typeof newEmail === "string" ? normalizeEmailSafe(newEmail) : null;
2604
2941
  if (!normalised) {
2605
- return setApiResponse4(
2606
- HTTP4.UNPROCESSABLE,
2942
+ return setApiResponse5(
2943
+ HTTP5.UNPROCESSABLE,
2607
2944
  "INVALID_PARAMETER",
2608
2945
  "A valid email address is required"
2609
2946
  );
2610
2947
  }
2611
2948
  const oldEmail = ctx.user.email;
2612
2949
  if (normalised === oldEmail) {
2613
- return setApiResponse4(
2614
- HTTP4.UNPROCESSABLE,
2950
+ return setApiResponse5(
2951
+ HTTP5.UNPROCESSABLE,
2615
2952
  "INVALID_PARAMETER",
2616
2953
  "New email must differ from current email"
2617
2954
  );
2618
2955
  }
2619
2956
  const existing = await users.findByEmail(normalised);
2620
2957
  if (existing) {
2621
- return setApiResponse4(HTTP4.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2958
+ return setApiResponse5(HTTP5.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2622
2959
  }
2623
2960
  const pin = randomInt2(1e5, 1e6).toString();
2624
2961
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
@@ -2638,8 +2975,8 @@ function userController(store, config, bus) {
2638
2975
  recipient: { email: oldEmail, phone: null, deviceToken: null }
2639
2976
  }));
2640
2977
  }
2641
- return setApiResponse4(
2642
- HTTP4.OK,
2978
+ return setApiResponse5(
2979
+ HTTP5.OK,
2643
2980
  "EMAIL_UPDATED",
2644
2981
  "Email updated. A verification code has been sent to your new address.",
2645
2982
  {
@@ -2651,8 +2988,8 @@ function userController(store, config, bus) {
2651
2988
  const body = ctx.meta["body"];
2652
2989
  const newPhone = body?.["phone"];
2653
2990
  if (!isValidPhone2(newPhone)) {
2654
- return setApiResponse4(
2655
- HTTP4.UNPROCESSABLE,
2991
+ return setApiResponse5(
2992
+ HTTP5.UNPROCESSABLE,
2656
2993
  "INVALID_PARAMETER",
2657
2994
  "A valid phone number is required"
2658
2995
  );
@@ -2660,7 +2997,7 @@ function userController(store, config, bus) {
2660
2997
  const normalised = normalizePhone2(newPhone);
2661
2998
  const existing = await users.findByPhone(normalised);
2662
2999
  if (existing) {
2663
- return setApiResponse4(HTTP4.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
3000
+ return setApiResponse5(HTTP5.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2664
3001
  }
2665
3002
  const otp = randomInt2(1e5, 1e6).toString();
2666
3003
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
@@ -2680,8 +3017,8 @@ function userController(store, config, bus) {
2680
3017
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2681
3018
  }));
2682
3019
  }
2683
- return setApiResponse4(
2684
- HTTP4.OK,
3020
+ return setApiResponse5(
3021
+ HTTP5.OK,
2685
3022
  "PHONE_UPDATED",
2686
3023
  "Phone number updated. A verification code has been sent to your new number.",
2687
3024
  {
@@ -2694,23 +3031,23 @@ function userController(store, config, bus) {
2694
3031
  const currentPassword = body?.["currentPassword"];
2695
3032
  const newPassword = body?.["newPassword"];
2696
3033
  if (typeof currentPassword !== "string" || !currentPassword) {
2697
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
3034
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "currentPassword is required");
2698
3035
  }
2699
3036
  if (typeof newPassword !== "string" || newPassword.length < 8) {
2700
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
3037
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "newPassword must be at least 8 characters");
2701
3038
  }
2702
3039
  const user = await users.findById(ctx.user.id);
2703
- if (!user) return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
3040
+ if (!user) return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2704
3041
  const { hashPassword: hashPassword2, verifyPassword: verifyPassword2 } = await Promise.resolve().then(() => (init_password(), password_exports));
2705
3042
  const valid = await verifyPassword2(currentPassword, user.passwordHash ?? "");
2706
3043
  if (!valid) {
2707
- return setApiResponse4(HTTP4.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
3044
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_CREDENTIAL", "Current password is incorrect");
2708
3045
  }
2709
3046
  const hash = await hashPassword2(newPassword);
2710
3047
  await users.updatePassword(ctx.user.id, hash);
2711
3048
  await sessions.deleteByUser(ctx.user.id);
2712
3049
  await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: ctx.user.id, sids: null, reason: "password-changed" }));
2713
- return setApiResponse4(HTTP4.OK, "PASSWORD_CHANGED", "Password updated successfully.");
3050
+ return setApiResponse5(HTTP5.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2714
3051
  },
2715
3052
  /**
2716
3053
  * Remove an OAuth provider from the account.
@@ -2729,19 +3066,19 @@ function userController(store, config, bus) {
2729
3066
  const provider = String(ctx.meta["params"]?.["provider"] ?? "");
2730
3067
  const current = await users.findById(ctx.user.id);
2731
3068
  if (!current?.provider) {
2732
- return setApiResponse4(HTTP4.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
3069
+ return setApiResponse5(HTTP5.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2733
3070
  }
2734
3071
  if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
2735
- return setApiResponse4(
2736
- HTTP4.NOT_FOUND,
3072
+ return setApiResponse5(
3073
+ HTTP5.NOT_FOUND,
2737
3074
  "NOT_LINKED",
2738
3075
  `This account is not linked to ${provider}.`
2739
3076
  );
2740
3077
  }
2741
3078
  const removed = await users.clearProvider(ctx.user.id);
2742
3079
  if (!removed) {
2743
- return setApiResponse4(
2744
- HTTP4.CONFLICT,
3080
+ return setApiResponse5(
3081
+ HTTP5.CONFLICT,
2745
3082
  "PASSWORD_REQUIRED",
2746
3083
  "Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
2747
3084
  );
@@ -2754,33 +3091,104 @@ function userController(store, config, bus) {
2754
3091
  recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2755
3092
  }));
2756
3093
  }
2757
- return setApiResponse4(HTTP4.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
3094
+ return setApiResponse5(HTTP5.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2758
3095
  },
3096
+ // Legacy one-call deletion (no code). Kept so existing apps keep working;
3097
+ // apps move to POST /users/me/deletion + /confirm (proof + notices).
2759
3098
  deleteMe: async (ctx) => {
2760
- const userId = ctx.user.id;
2761
- await users.softDelete(userId);
2762
- await sessions.deleteByUser(userId);
2763
- const reqId = ctx.meta["requestId"];
2764
- await background4(bus?.emit(
2765
- EVENT_KEYS.userDeleted,
2766
- { userId },
2767
- reqId !== void 0 ? { requestId: reqId } : void 0
2768
- ));
2769
- await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2770
- return Response.json(
2771
- { reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
2772
- {
2773
- status: 200,
2774
- headers: cookieHeaders(clearedTokenCookies(config))
2775
- }
2776
- );
3099
+ const deletedAt = await archive(ctx.user.id, null);
3100
+ await announceArchived(ctx, deletedAt);
3101
+ return archivedResponse(deletedAt);
3102
+ },
3103
+ /**
3104
+ * Step 1 of deleting an account: a 6-digit code to the channel the person
3105
+ * picks ('email' | 'sms' — an address on the account; receiving the code
3106
+ * proves they hold it). Refused while a blocker objects (e.g. they own a
3107
+ * team with other members — transfer it first).
3108
+ */
3109
+ requestDeletion: async (ctx) => {
3110
+ const user = ctx.user;
3111
+ const body = ctx.meta["body"];
3112
+ const channel = body?.["channel"] === "sms" ? "sms" : body?.["channel"] === "email" ? "email" : null;
3113
+ if (!channel) {
3114
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "channel must be 'email' or 'sms'");
3115
+ }
3116
+ const address = channel === "email" ? user.email : user.phone;
3117
+ if (!address) {
3118
+ return setApiResponse5(
3119
+ HTTP5.UNPROCESSABLE,
3120
+ channel === "email" ? "NO_EMAIL_ON_ACCOUNT" : "NO_PHONE_ON_ACCOUNT",
3121
+ `There is no ${channel === "email" ? "email address" : "phone number"} on this account to send the code to.`
3122
+ );
3123
+ }
3124
+ const refused = await deletionBlocked(user.id);
3125
+ if (refused) return refused;
3126
+ const remainingMs = checkCooldown(await deletions.lastSentAt(user.id), DELETION_CODE_COOLDOWN_MS);
3127
+ if (remainingMs > 0) {
3128
+ const retryAfter = Math.ceil(remainingMs / 1e3);
3129
+ return setApiResponse5(HTTP5.TOO_MANY_REQUESTS, "VERIFICATION_COOLDOWN", `Wait ${retryAfter}s before requesting a new code.`, { retryAfter });
3130
+ }
3131
+ const code = randomInt2(1e5, 1e6).toString();
3132
+ await deletions.saveCode(user.id, code, channel, new Date(Date.now() + DELETION_CODE_TTL_MS));
3133
+ await background4(bus?.emit(NOTIFICATION_EVENT3, {
3134
+ type: MESSAGE_KEYS.accountDeletionCode,
3135
+ locale: user.locale,
3136
+ data: { code },
3137
+ recipient: channel === "email" ? { email: address, phone: null, deviceToken: null } : { email: null, phone: address, deviceToken: null }
3138
+ }));
3139
+ return setApiResponse5(HTTP5.ACCEPTED, "ACCOUNT_DELETION_CODE_SENT", "A code to confirm the deletion was sent.", {
3140
+ channel,
3141
+ expiresInSeconds: DELETION_CODE_TTL_MS / 1e3,
3142
+ mfaRequired: user.mfaEnabled === true
3143
+ });
3144
+ },
3145
+ /**
3146
+ * Step 2: the code (and, with two-factor on, a second factor) archives the
3147
+ * account — closed at once, restorable at sign-in until the purge date.
3148
+ */
3149
+ confirmDeletion: async (ctx) => {
3150
+ const user = ctx.user;
3151
+ const body = ctx.meta["body"];
3152
+ const code = typeof body?.["code"] === "string" ? body["code"].trim() : "";
3153
+ if (!/^\d{6}$/.test(code)) {
3154
+ return setApiResponse5(HTTP5.UNPROCESSABLE, "INVALID_PARAMETER", "code must be the 6-digit code that was sent");
3155
+ }
3156
+ const refused = await deletionBlocked(user.id);
3157
+ if (refused) return refused;
3158
+ if (user.mfaEnabled && !await verifySecondFactor(store, config, user.id, body?.["mfaCode"])) {
3159
+ return setApiResponse5(HTTP5.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to confirm.");
3160
+ }
3161
+ const checked = await deletions.checkCode(user.id, code);
3162
+ if (checked.result !== "ok") {
3163
+ return setApiResponse5(
3164
+ HTTP5.BAD_REQUEST,
3165
+ "VERIFICATION_FAILED",
3166
+ checked.result === "exhausted" || checked.result === "none" || checked.result === "expired" ? "This code can no longer be used. Request a new one." : "That code is incorrect."
3167
+ );
3168
+ }
3169
+ const deletedAt = await archive(user.id, checked.channel ?? null);
3170
+ await announceArchived(ctx, deletedAt);
3171
+ const deleteOn = deletionDate(deletedAt, config);
3172
+ const address = checked.channel === "sms" ? user.phone : user.email;
3173
+ if (address) {
3174
+ await background4(bus?.emit(NOTIFICATION_EVENT3, {
3175
+ type: MESSAGE_KEYS.accountDeletionScheduled,
3176
+ locale: user.locale,
3177
+ data: {
3178
+ deleteOn: deleteOn.toISOString().slice(0, 10),
3179
+ [COURIER_FORMAT_KEY]: { deleteOn: { date: deleteOn.toISOString(), style: "long" } }
3180
+ },
3181
+ recipient: checked.channel === "sms" ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
3182
+ }));
3183
+ }
3184
+ return archivedResponse(deletedAt);
2777
3185
  },
2778
3186
  // Subject Access Request — the authenticated user's own data as a portable
2779
3187
  // JSON bundle. Only auth-owned data, and only safe fields: no password hash,
2780
3188
  // no MFA secret, no session tokens (session metadata only).
2781
3189
  exportMe: async (ctx) => {
2782
3190
  const user = await users.findById(ctx.user.id);
2783
- if (!user) return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
3191
+ if (!user) return setApiResponse5(HTTP5.NOT_FOUND, "NOT_FOUND", "User not found");
2784
3192
  const sessionRows = await sessions.listByUser(ctx.user.id);
2785
3193
  const bundle = {
2786
3194
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -2809,15 +3217,15 @@ function userController(store, config, bus) {
2809
3217
  }
2810
3218
  bundle["modules"] = modules;
2811
3219
  }
2812
- return setApiResponse4(HTTP4.OK, "DATA_EXPORT", "Your account data.", bundle);
3220
+ return setApiResponse5(HTTP5.OK, "DATA_EXPORT", "Your account data.", bundle);
2813
3221
  }
2814
3222
  };
2815
3223
  }
2816
3224
 
2817
3225
  // src/controllers/oauth.controller.ts
2818
- import { randomBytes as randomBytes4, createHash as createHash4 } from "crypto";
2819
- import jwt2 from "jsonwebtoken";
2820
- import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
3226
+ import { randomBytes as randomBytes4, createHash as createHash5 } from "crypto";
3227
+ import jwt3 from "jsonwebtoken";
3228
+ import { setApiResponse as setApiResponse6, HTTP as HTTP6, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2821
3229
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
2822
3230
 
2823
3231
  // src/services/jwks.ts
@@ -2901,7 +3309,7 @@ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
2901
3309
  var APPLE_ISSUER = "https://appleid.apple.com";
2902
3310
  function mintAppleClientSecret(apple) {
2903
3311
  const now = Math.floor(Date.now() / 1e3);
2904
- return jwt2.sign(
3312
+ return jwt3.sign(
2905
3313
  { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
2906
3314
  apple.privateKey,
2907
3315
  { algorithm: "ES256", keyid: apple.keyId }
@@ -2910,14 +3318,14 @@ function mintAppleClientSecret(apple) {
2910
3318
  var appleKeys = createJwksSource(APPLE_KEYS_URL);
2911
3319
  async function verifyAppleIdToken(idToken, opts) {
2912
3320
  if (opts.audiences.length === 0) return null;
2913
- const decoded = jwt2.decode(idToken, { complete: true });
3321
+ const decoded = jwt3.decode(idToken, { complete: true });
2914
3322
  if (!decoded || typeof decoded === "string") return null;
2915
3323
  const kid = decoded.header.kid;
2916
3324
  if (!kid) return null;
2917
3325
  const key = await appleKeys.getKey(kid);
2918
3326
  if (!key) return null;
2919
3327
  try {
2920
- const payload = jwt2.verify(idToken, key, {
3328
+ const payload = jwt3.verify(idToken, key, {
2921
3329
  algorithms: ["RS256"],
2922
3330
  issuer: APPLE_ISSUER,
2923
3331
  // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
@@ -2937,14 +3345,14 @@ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
2937
3345
  var googleKeys = createJwksSource(GOOGLE_KEYS_URL);
2938
3346
  async function verifyGoogleIdToken(idToken, opts) {
2939
3347
  if (opts.audiences.length === 0) return null;
2940
- const decoded = jwt2.decode(idToken, { complete: true });
3348
+ const decoded = jwt3.decode(idToken, { complete: true });
2941
3349
  if (!decoded || typeof decoded === "string") return null;
2942
3350
  const kid = decoded.header.kid;
2943
3351
  if (!kid) return null;
2944
3352
  const key = await googleKeys.getKey(kid);
2945
3353
  if (!key) return null;
2946
3354
  try {
2947
- const payload = jwt2.verify(idToken, key, {
3355
+ const payload = jwt3.verify(idToken, key, {
2948
3356
  algorithms: ["RS256"],
2949
3357
  issuer: [...GOOGLE_ISSUERS],
2950
3358
  audience: opts.audiences
@@ -3027,14 +3435,29 @@ function oauthController(store, config, bus) {
3027
3435
  const method = provider === "apple" ? "oauth-apple" : "oauth-google";
3028
3436
  const meta = requestMeta(ctx);
3029
3437
  if (!claims.email) {
3030
- return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3438
+ return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `No email in ${p.label} identity token`);
3031
3439
  }
3032
3440
  if (!claims.emailVerified) {
3033
- return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3441
+ return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `${p.label} account email is not verified`);
3034
3442
  }
3035
3443
  const normalizedEmail = normalizeEmailSafe(claims.email);
3036
3444
  if (!normalizedEmail) {
3037
- return setApiResponse5(HTTP5.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3445
+ return setApiResponse6(HTTP6.BAD_REQUEST, p.failed, `Invalid email in ${p.label} identity token`);
3446
+ }
3447
+ const archived = await users.findArchivedByEmail(normalizedEmail);
3448
+ if (archived?.deletedAt) {
3449
+ await loginEvents.recordSafe({
3450
+ userId: archived.id,
3451
+ emailAttempted: normalizedEmail,
3452
+ method,
3453
+ outcome: "failed",
3454
+ failureReason: "pending_deletion",
3455
+ ...meta
3456
+ });
3457
+ const res = pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, provider, config);
3458
+ const headers = cookieHeaders(clearCookies);
3459
+ headers.set("content-type", res.headers.get("content-type") ?? "application/json");
3460
+ return new Response(res.body, { status: res.status, headers });
3038
3461
  }
3039
3462
  const upserted = await users.upsertByProvider(normalizedEmail, provider, claims.sub ?? "");
3040
3463
  if (!upserted) {
@@ -3046,15 +3469,15 @@ function oauthController(store, config, bus) {
3046
3469
  failureReason: "provider_upsert_failed",
3047
3470
  ...meta
3048
3471
  });
3049
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3472
+ return setApiResponse6(HTTP6.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3050
3473
  }
3051
3474
  const fullUser = await users.findById(upserted.id);
3052
3475
  if (!fullUser) {
3053
- return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3476
+ return setApiResponse6(HTTP6.SERVER_ERROR, "SERVER_ERROR", `${p.label} login failed`);
3054
3477
  }
3055
3478
  await announceOAuthUpsert(ctx, upserted, provider, fullUser);
3056
3479
  if (fullUser.suspended) {
3057
- return setApiResponse5(HTTP5.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3480
+ return setApiResponse6(HTTP6.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
3058
3481
  }
3059
3482
  if (fullUser.mfaEnabled) {
3060
3483
  const mfaToken = issueMfaPendingToken(upserted.id, config, provider);
@@ -3097,8 +3520,8 @@ function oauthController(store, config, bus) {
3097
3520
  googleInit: async (_ctx) => {
3098
3521
  const google = config.google;
3099
3522
  if (!google || !google.clientSecret || !google.redirectUri) {
3100
- return setApiResponse5(
3101
- HTTP5.NOT_IMPLEMENTED,
3523
+ return setApiResponse6(
3524
+ HTTP6.NOT_IMPLEMENTED,
3102
3525
  "NOT_CONFIGURED",
3103
3526
  "Google OAuth not configured"
3104
3527
  );
@@ -3132,8 +3555,8 @@ function oauthController(store, config, bus) {
3132
3555
  googleCallback: async (ctx) => {
3133
3556
  const google = config.google;
3134
3557
  if (!google || !google.clientSecret || !google.redirectUri) {
3135
- return setApiResponse5(
3136
- HTTP5.NOT_IMPLEMENTED,
3558
+ return setApiResponse6(
3559
+ HTTP6.NOT_IMPLEMENTED,
3137
3560
  "NOT_CONFIGURED",
3138
3561
  "Google OAuth not configured"
3139
3562
  );
@@ -3141,14 +3564,14 @@ function oauthController(store, config, bus) {
3141
3564
  const url = new URL(ctx.request.url);
3142
3565
  const code = url.searchParams.get("code");
3143
3566
  if (!code) {
3144
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3567
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3145
3568
  }
3146
3569
  const returnedState = url.searchParams.get("state") ?? "";
3147
3570
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3148
3571
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3149
3572
  if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
3150
- return setApiResponse5(
3151
- HTTP5.BAD_REQUEST,
3573
+ return setApiResponse6(
3574
+ HTTP6.BAD_REQUEST,
3152
3575
  "GOOGLE_AUTH_FAILED",
3153
3576
  "OAuth state mismatch \u2014 restart the sign-in flow"
3154
3577
  );
@@ -3166,8 +3589,8 @@ function oauthController(store, config, bus) {
3166
3589
  });
3167
3590
  const tokenData = await tokenRes.json();
3168
3591
  if (!tokenData.id_token) {
3169
- return setApiResponse5(
3170
- HTTP5.BAD_REQUEST,
3592
+ return setApiResponse6(
3593
+ HTTP6.BAD_REQUEST,
3171
3594
  "GOOGLE_AUTH_FAILED",
3172
3595
  "OAuth token exchange failed"
3173
3596
  );
@@ -3176,20 +3599,20 @@ function oauthController(store, config, bus) {
3176
3599
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
3177
3600
  );
3178
3601
  if (payload.aud !== google.clientId) {
3179
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3602
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
3180
3603
  }
3181
3604
  if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
3182
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3605
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
3183
3606
  }
3184
3607
  if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
3185
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3608
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
3186
3609
  }
3187
3610
  if (!payload.email) {
3188
- return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3611
+ return setApiResponse6(HTTP6.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
3189
3612
  }
3190
3613
  if (payload.email_verified !== true) {
3191
- return setApiResponse5(
3192
- HTTP5.BAD_REQUEST,
3614
+ return setApiResponse6(
3615
+ HTTP6.BAD_REQUEST,
3193
3616
  "GOOGLE_AUTH_FAILED",
3194
3617
  "Google account email is not verified"
3195
3618
  );
@@ -3203,7 +3626,7 @@ function oauthController(store, config, bus) {
3203
3626
  appleInit: async (_ctx) => {
3204
3627
  const apple = config.apple;
3205
3628
  if (!apple) {
3206
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3629
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3207
3630
  }
3208
3631
  const state = randomBytes4(16).toString("hex");
3209
3632
  const params = new URLSearchParams({
@@ -3234,20 +3657,20 @@ function oauthController(store, config, bus) {
3234
3657
  appleCallback: async (ctx) => {
3235
3658
  const apple = config.apple;
3236
3659
  if (!apple) {
3237
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3660
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3238
3661
  }
3239
3662
  const pre = ctx.meta["body"];
3240
3663
  const form = pre && typeof pre === "object" ? new URLSearchParams(pre) : new URLSearchParams(await ctx.request.text());
3241
3664
  const code = form.get("code");
3242
3665
  if (!code) {
3243
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3666
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
3244
3667
  }
3245
3668
  const returnedState = form.get("state") ?? "";
3246
3669
  const cookieHeader = ctx.request.headers.get("cookie") ?? "";
3247
3670
  const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
3248
3671
  if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
3249
- return setApiResponse5(
3250
- HTTP5.BAD_REQUEST,
3672
+ return setApiResponse6(
3673
+ HTTP6.BAD_REQUEST,
3251
3674
  "APPLE_AUTH_FAILED",
3252
3675
  "OAuth state mismatch \u2014 restart the sign-in flow"
3253
3676
  );
@@ -3265,11 +3688,11 @@ function oauthController(store, config, bus) {
3265
3688
  });
3266
3689
  const tokenData = await tokenRes.json();
3267
3690
  if (!tokenData.id_token) {
3268
- return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3691
+ return setApiResponse6(HTTP6.BAD_REQUEST, "APPLE_AUTH_FAILED", "OAuth token exchange failed");
3269
3692
  }
3270
3693
  const claims = await verifyAppleIdToken(tokenData.id_token, { audiences: [apple.clientId] });
3271
3694
  if (!claims) {
3272
- return setApiResponse5(HTTP5.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3695
+ return setApiResponse6(HTTP6.BAD_REQUEST, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3273
3696
  }
3274
3697
  return completeOAuthLogin(ctx, "apple", {
3275
3698
  email: claims.email,
@@ -3280,12 +3703,12 @@ function oauthController(store, config, bus) {
3280
3703
  appleNative: async (ctx) => {
3281
3704
  const apple = config.apple;
3282
3705
  if (!apple) {
3283
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3706
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3284
3707
  }
3285
3708
  const body = ctx.meta["body"];
3286
3709
  const identityToken = body?.identityToken;
3287
3710
  if (!identityToken) {
3288
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3711
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing identityToken");
3289
3712
  }
3290
3713
  const audiences = apple.nativeClientIds && apple.nativeClientIds.length ? apple.nativeClientIds : [apple.clientId];
3291
3714
  const claims = await verifyAppleIdToken(identityToken, { audiences, nonce: body?.nonce });
@@ -3299,9 +3722,9 @@ function oauthController(store, config, bus) {
3299
3722
  failureReason: "invalid_identity_token",
3300
3723
  ...meta
3301
3724
  });
3302
- return setApiResponse5(HTTP5.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3725
+ return setApiResponse6(HTTP6.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3303
3726
  }
3304
- const tokenHash = createHash4("sha256").update(identityToken).digest("hex");
3727
+ const tokenHash = createHash5("sha256").update(identityToken).digest("hex");
3305
3728
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3306
3729
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3307
3730
  if (!firstUse) {
@@ -3314,8 +3737,8 @@ function oauthController(store, config, bus) {
3314
3737
  failureReason: "token_replayed",
3315
3738
  ...meta
3316
3739
  });
3317
- return setApiResponse5(
3318
- HTTP5.UNAUTHORIZED,
3740
+ return setApiResponse6(
3741
+ HTTP6.UNAUTHORIZED,
3319
3742
  "APPLE_AUTH_FAILED",
3320
3743
  "This Apple identity token has already been used"
3321
3744
  );
@@ -3332,12 +3755,12 @@ function oauthController(store, config, bus) {
3332
3755
  googleNative: async (ctx) => {
3333
3756
  const audiences = config.google?.nativeClientIds ?? [];
3334
3757
  if (!config.google || audiences.length === 0) {
3335
- return setApiResponse5(HTTP5.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3758
+ return setApiResponse6(HTTP6.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Native Google sign-in not configured");
3336
3759
  }
3337
3760
  const body = ctx.meta["body"];
3338
3761
  const idToken = body?.idToken;
3339
3762
  if (!idToken) {
3340
- return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3763
+ return setApiResponse6(HTTP6.BAD_REQUEST, "INVALID_PARAMETER", "Missing idToken");
3341
3764
  }
3342
3765
  const claims = await verifyGoogleIdToken(idToken, { audiences, nonce: body?.nonce });
3343
3766
  if (!claims) {
@@ -3349,9 +3772,9 @@ function oauthController(store, config, bus) {
3349
3772
  failureReason: "invalid_identity_token",
3350
3773
  ...requestMeta(ctx)
3351
3774
  });
3352
- return setApiResponse5(HTTP5.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3775
+ return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3353
3776
  }
3354
- const tokenHash = createHash4("sha256").update(idToken).digest("hex");
3777
+ const tokenHash = createHash5("sha256").update(idToken).digest("hex");
3355
3778
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3356
3779
  if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3357
3780
  await loginEvents.recordSafe({
@@ -3362,7 +3785,7 @@ function oauthController(store, config, bus) {
3362
3785
  failureReason: "token_replayed",
3363
3786
  ...requestMeta(ctx)
3364
3787
  });
3365
- return setApiResponse5(HTTP5.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3788
+ return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "This Google ID token has already been used");
3366
3789
  }
3367
3790
  consumedTokens.sweepExpiredSafe();
3368
3791
  return completeOAuthLogin(ctx, "google", {
@@ -3408,7 +3831,7 @@ function buildAuthRoutes(store, config, bus, locales) {
3408
3831
  "providers",
3409
3832
  "GET",
3410
3833
  "/auth/providers",
3411
- async () => setApiResponse6(HTTP6.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3834
+ async () => setApiResponse7(HTTP7.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
3412
3835
  providers: [...config.providers]
3413
3836
  })
3414
3837
  ),
@@ -3440,6 +3863,11 @@ function buildAuthRoutes(store, config, bus, locales) {
3440
3863
  // refuses when it would leave the account with no way to sign in.
3441
3864
  R("unlinkOauth", "DELETE", "/auth/oauth/:provider", requireAuth, user.unlinkOauth),
3442
3865
  R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
3866
+ // Deleting with proof: a code to a chosen channel, then confirm. The
3867
+ // account is archived until the purge; signing in offers to keep it.
3868
+ R("requestDeletion", "POST", "/users/me/deletion", requireAuth, verifyGate, validate(requestDeletionSchema), user.requestDeletion),
3869
+ R("confirmDeletion", "POST", "/users/me/deletion/confirm", ipLimit("verify"), requireAuth, verifyGate, validate(confirmDeletionSchema), user.confirmDeletion),
3870
+ R("restoreAccount", "POST", "/auth/account/restore", ipLimit("login"), validate(restoreAccountSchema), auth.restoreAccount),
3443
3871
  R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
3444
3872
  // Security surfaces (Protected; the caller's own login history + sessions).
3445
3873
  // The literal /others route is registered before /:id so it isn't
@@ -3643,8 +4071,11 @@ function withSession(store, config) {
3643
4071
  if (payload.sid && !await sessions.aliveBySid(payload.sid)) {
3644
4072
  return next();
3645
4073
  }
3646
- const user = await users.findById(payload.sub);
3647
- if (!user || user.suspended || user.deletedAt) {
4074
+ let user = await users.findById(payload.sub);
4075
+ if (!user && payload.mfaPending && payload.loginMethod === "phone") {
4076
+ user = await users.findArchivedById(payload.sub);
4077
+ }
4078
+ if (!user || user.suspended || user.deletedAt && !payload.mfaPending) {
3648
4079
  return next();
3649
4080
  }
3650
4081
  Object.assign(ctx, {
@@ -3672,7 +4103,7 @@ function extractToken(request) {
3672
4103
  }
3673
4104
 
3674
4105
  // src/admin.ts
3675
- import { HTTP as HTTP7, encodeKeysetCursor as encodeKeysetCursor2, setApiResponse as setApiResponse7 } from "@fonderie/core";
4106
+ import { HTTP as HTTP8, encodeKeysetCursor as encodeKeysetCursor2, setApiResponse as setApiResponse8 } from "@fonderie/core";
3676
4107
  import { background as background6 } from "@fonderie/core";
3677
4108
  function toAdminUserDTO(user) {
3678
4109
  return {
@@ -3689,7 +4120,7 @@ function toAdminUserPageDTO(page) {
3689
4120
  }
3690
4121
  var ADMIN_PREFIX = "/_admin";
3691
4122
  var idOf = (ctx) => ctx.meta.params?.["id"] ?? "";
3692
- var NOT_FOUND = () => setApiResponse7(HTTP7.NOT_FOUND, "NOT_FOUND", "No such user");
4123
+ var NOT_FOUND = () => setApiResponse8(HTTP8.NOT_FOUND, "NOT_FOUND", "No such user");
3693
4124
  function adminRouteTable(store, bus) {
3694
4125
  const users = new UserModel(store);
3695
4126
  const sessions = new SessionModel(store);
@@ -3698,8 +4129,8 @@ function adminRouteTable(store, bus) {
3698
4129
  const ok = await users.setSuspended(idOf(ctx), suspended);
3699
4130
  if (!ok) return NOT_FOUND();
3700
4131
  const user = await users.findById(idOf(ctx));
3701
- return user ? setApiResponse7(
3702
- HTTP7.OK,
4132
+ return user ? setApiResponse8(
4133
+ HTTP8.OK,
3703
4134
  suspended ? "USER_SUSPENDED" : "USER_UNSUSPENDED",
3704
4135
  suspended ? "User suspended" : "User unsuspended",
3705
4136
  toAdminUserDTO(user)
@@ -3718,21 +4149,21 @@ function adminRouteTable(store, bus) {
3718
4149
  const email2 = raw ? normalizeEmailSafe(raw) ?? raw.toLowerCase() : void 0;
3719
4150
  if (email2) {
3720
4151
  const user = await users.findByEmail(email2);
3721
- return user ? setApiResponse7(HTTP7.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
4152
+ return user ? setApiResponse8(HTTP8.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3722
4153
  }
3723
4154
  const rawLimit = Number(params.get("limit") ?? 50);
3724
4155
  const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), 200) : 50;
3725
4156
  const cursorParam = params.get("cursor");
3726
4157
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3727
4158
  if (cursorParam && !cursor)
3728
- return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
4159
+ return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3729
4160
  const deleted = params.get("deleted") === "1" || params.get("deleted") === "true";
3730
4161
  const page = await users.list({
3731
4162
  limit,
3732
4163
  ...cursor ? { cursor } : {},
3733
4164
  ...deleted ? { deleted } : {}
3734
4165
  });
3735
- return setApiResponse7(HTTP7.OK, "USERS", "Users", toAdminUserPageDTO(page));
4166
+ return setApiResponse8(HTTP8.OK, "USERS", "Users", toAdminUserPageDTO(page));
3736
4167
  }
3737
4168
  ],
3738
4169
  [
@@ -3740,7 +4171,7 @@ function adminRouteTable(store, bus) {
3740
4171
  "/_admin/users/:id",
3741
4172
  async (ctx) => {
3742
4173
  const user = await users.findByIdIncludingDeleted(idOf(ctx));
3743
- return user ? setApiResponse7(HTTP7.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
4174
+ return user ? setApiResponse8(HTTP8.OK, "USER", "User", toAdminUserDTO(user)) : NOT_FOUND();
3744
4175
  }
3745
4176
  ],
3746
4177
  [
@@ -3749,8 +4180,8 @@ function adminRouteTable(store, bus) {
3749
4180
  async (ctx) => {
3750
4181
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3751
4182
  const rows = await sessions.listLiveByUser(idOf(ctx));
3752
- return setApiResponse7(
3753
- HTTP7.OK,
4183
+ return setApiResponse8(
4184
+ HTTP8.OK,
3754
4185
  "SESSIONS",
3755
4186
  "Live sessions",
3756
4187
  rows.map((r) => toSessionDTO(r, null))
@@ -3765,7 +4196,7 @@ function adminRouteTable(store, bus) {
3765
4196
  if (!await users.findById(idOf(ctx))) return NOT_FOUND();
3766
4197
  await sessions.deleteByUser(idOf(ctx));
3767
4198
  await background6(bus?.emit(EVENT_KEYS.sessionRevoked, { userId: idOf(ctx), sids: null, reason: "admin" }));
3768
- return setApiResponse7(HTTP7.OK, "SESSIONS_REVOKED", "All sessions revoked");
4199
+ return setApiResponse8(HTTP8.OK, "SESSIONS_REVOKED", "All sessions revoked");
3769
4200
  }
3770
4201
  ],
3771
4202
  [
@@ -3779,14 +4210,14 @@ function adminRouteTable(store, bus) {
3779
4210
  const cursorParam = params.get("cursor");
3780
4211
  const cursor = cursorParam ? decodeLoginCursor(cursorParam) : null;
3781
4212
  if (cursorParam && !cursor)
3782
- return setApiResponse7(HTTP7.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
4213
+ return setApiResponse8(HTTP8.UNPROCESSABLE, "INVALID_PARAMETER", "Invalid cursor");
3783
4214
  const page = await events.listByUser({
3784
4215
  userId: idOf(ctx),
3785
4216
  limit,
3786
4217
  ...cursor ? { cursor } : {}
3787
4218
  });
3788
- return setApiResponse7(
3789
- HTTP7.OK,
4219
+ return setApiResponse8(
4220
+ HTTP8.OK,
3790
4221
  "LOGIN_HISTORY",
3791
4222
  "Login history",
3792
4223
  toLoginHistoryPageDTO(page)
@@ -3819,7 +4250,7 @@ var AuthModule = class {
3819
4250
  name = "@fonderie/auth";
3820
4251
  // Baked in at build time by tsup.base, so the operator's Modules page can
3821
4252
  // say what is actually deployed rather than 'not reported'.
3822
- version = "7.22.1";
4253
+ version = "7.24.0";
3823
4254
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3824
4255
  describeAdmin() {
3825
4256
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };
@@ -4007,6 +4438,29 @@ inmediato y cambia tu contrase\xF1a \u2014 otra persona podr\xEDa iniciar sesi\x
4007
4438
  {{provider}} ya no se puede usar para iniciar sesi\xF3n. Tu correo electr\xF3nico y tu contrase\xF1a siguen funcionando.
4008
4439
 
4009
4440
  Si fuiste t\xFA, todo est\xE1 en orden. Si no, contacta con soporte de inmediato.`
4441
+ },
4442
+ [MESSAGE_KEYS.accountDeletionCode]: {
4443
+ subject: `Confirma la eliminaci\xF3n de tu cuenta`,
4444
+ html: `<h1>Confirmar la eliminaci\xF3n de la cuenta</h1>
4445
+ <p>Usa este c\xF3digo para confirmar que quieres eliminar tu cuenta:</p>
4446
+ <p><span class="pin-code">{{code}}</span></p>
4447
+ <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>`,
4448
+ 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.`
4449
+ },
4450
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4451
+ subject: `Tu cuenta se eliminar\xE1 el {{deleteOn}}`,
4452
+ html: `<h1>Tu cuenta est\xE1 programada para eliminarse</h1>
4453
+ <p>Recibimos tu solicitud. Tu cuenta est\xE1 cerrada y se eliminar\xE1 de forma permanente el <strong>{{deleteOn}}</strong>.</p>
4454
+ <p>\xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes de esa fecha y elige <strong>Conservar mi cuenta</strong>.</p>
4455
+ <p class="muted">Si no lo pediste, inicia sesi\xF3n ahora para conservar tu cuenta y cambia tu contrase\xF1a.</p>`,
4456
+ text: `Tu cuenta se eliminar\xE1 de forma permanente el {{deleteOn}}. \xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes y elige Conservar mi cuenta.`
4457
+ },
4458
+ [MESSAGE_KEYS.accountRestored]: {
4459
+ subject: `Tu cuenta fue restablecida`,
4460
+ html: `<h1>Bienvenido de nuevo</h1>
4461
+ <p>Se cancel\xF3 la eliminaci\xF3n de tu cuenta y tu cuenta vuelve a estar activa.</p>
4462
+ <p class="muted">Si no fuiste t\xFA, cambia tu contrase\xF1a de inmediato.</p>`,
4463
+ 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.`
4010
4464
  }
4011
4465
  };
4012
4466
 
@@ -4167,6 +4621,29 @@ pourrait se connecter \xE0 votre place.`
4167
4621
  {{provider}} ne peut plus \xEAtre utilis\xE9 pour vous connecter. Votre adresse e-mail et votre mot de passe fonctionnent toujours.
4168
4622
 
4169
4623
  Si c'est bien vous, tout est en ordre. Sinon, contactez imm\xE9diatement le support.`
4624
+ },
4625
+ [MESSAGE_KEYS.accountDeletionCode]: {
4626
+ subject: `Confirmez la suppression de votre compte`,
4627
+ html: `<h1>Confirmer la suppression du compte</h1>
4628
+ <p>Utilisez ce code pour confirmer que vous souhaitez supprimer votre compte :</p>
4629
+ <p><span class="pin-code">{{code}}</span></p>
4630
+ <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>`,
4631
+ 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.`
4632
+ },
4633
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4634
+ subject: `Votre compte sera supprim\xE9 le {{deleteOn}}`,
4635
+ html: `<h1>Votre compte sera supprim\xE9</h1>
4636
+ <p>Nous avons bien re\xE7u votre demande. Votre compte est ferm\xE9 et sera supprim\xE9 d\xE9finitivement le <strong>{{deleteOn}}</strong>.</p>
4637
+ <p>Vous avez chang\xE9 d&rsquo;avis ? Connectez-vous avant cette date et choisissez <strong>Conserver mon compte</strong>.</p>
4638
+ <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>`,
4639
+ 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.`
4640
+ },
4641
+ [MESSAGE_KEYS.accountRestored]: {
4642
+ subject: `Votre compte a \xE9t\xE9 r\xE9tabli`,
4643
+ html: `<h1>Bon retour</h1>
4644
+ <p>La suppression de votre compte a \xE9t\xE9 annul\xE9e et votre compte est de nouveau actif.</p>
4645
+ <p class="muted">Si ce n&rsquo;est pas vous, changez votre mot de passe imm\xE9diatement.</p>`,
4646
+ 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.`
4170
4647
  }
4171
4648
  };
4172
4649
 
@@ -4321,6 +4798,29 @@ var ZH_HANS_TEMPLATES = {
4321
4798
  \u5DF2\u65E0\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5F55\u3002\u60A8\u7684\u7535\u5B50\u90AE\u7BB1\u548C\u5BC6\u7801\u4ECD\u7136\u53EF\u7528\u3002
4322
4799
 
4323
4800
  \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`
4801
+ },
4802
+ [MESSAGE_KEYS.accountDeletionCode]: {
4803
+ subject: `\u786E\u8BA4\u5220\u9664\u60A8\u7684\u8D26\u6237`,
4804
+ html: `<h1>\u786E\u8BA4\u5220\u9664\u8D26\u6237</h1>
4805
+ <p>\u8BF7\u4F7F\u7528\u6B64\u9A8C\u8BC1\u7801\u786E\u8BA4\u60A8\u8981\u5220\u9664\u8D26\u6237\uFF1A</p>
4806
+ <p><span class="pin-code">{{code}}</span></p>
4807
+ <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>`,
4808
+ 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`
4809
+ },
4810
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4811
+ subject: `\u60A8\u7684\u8D26\u6237\u5C06\u4E8E {{deleteOn}} \u5220\u9664`,
4812
+ html: `<h1>\u60A8\u7684\u8D26\u6237\u5DF2\u5B89\u6392\u5220\u9664</h1>
4813
+ <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>
4814
+ <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>
4815
+ <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>`,
4816
+ 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`
4817
+ },
4818
+ [MESSAGE_KEYS.accountRestored]: {
4819
+ subject: `\u60A8\u7684\u8D26\u6237\u5DF2\u6062\u590D`,
4820
+ html: `<h1>\u6B22\u8FCE\u56DE\u6765</h1>
4821
+ <p>\u60A8\u7684\u8D26\u6237\u5220\u9664\u5DF2\u53D6\u6D88\uFF0C\u8D26\u6237\u5DF2\u91CD\u65B0\u542F\u7528\u3002</p>
4822
+ <p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8BF7\u7ACB\u5373\u66F4\u6539\u5BC6\u7801\u3002</p>`,
4823
+ 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`
4324
4824
  }
4325
4825
  };
4326
4826
 
@@ -4475,6 +4975,29 @@ var ZH_HANT_TEMPLATES = {
4475
4975
  \u5DF2\u7121\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5165\u3002\u60A8\u7684\u96FB\u5B50\u90F5\u4EF6\u548C\u5BC6\u78BC\u4ECD\u53EF\u4F7F\u7528\u3002
4476
4976
 
4477
4977
  \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`
4978
+ },
4979
+ [MESSAGE_KEYS.accountDeletionCode]: {
4980
+ subject: `\u78BA\u8A8D\u522A\u9664\u60A8\u7684\u5E33\u6236`,
4981
+ html: `<h1>\u78BA\u8A8D\u522A\u9664\u5E33\u6236</h1>
4982
+ <p>\u8ACB\u4F7F\u7528\u6B64\u9A57\u8B49\u78BC\u78BA\u8A8D\u60A8\u8981\u522A\u9664\u5E33\u6236\uFF1A</p>
4983
+ <p><span class="pin-code">{{code}}</span></p>
4984
+ <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>`,
4985
+ 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`
4986
+ },
4987
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4988
+ subject: `\u60A8\u7684\u5E33\u6236\u5C07\u65BC {{deleteOn}} \u522A\u9664`,
4989
+ html: `<h1>\u60A8\u7684\u5E33\u6236\u5DF2\u6392\u5B9A\u522A\u9664</h1>
4990
+ <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>
4991
+ <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>
4992
+ <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>`,
4993
+ 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`
4994
+ },
4995
+ [MESSAGE_KEYS.accountRestored]: {
4996
+ subject: `\u60A8\u7684\u5E33\u6236\u5DF2\u6062\u5FA9`,
4997
+ html: `<h1>\u6B61\u8FCE\u56DE\u4F86</h1>
4998
+ <p>\u60A8\u7684\u5E33\u6236\u522A\u9664\u5DF2\u53D6\u6D88\uFF0C\u5E33\u6236\u5DF2\u91CD\u65B0\u555F\u7528\u3002</p>
4999
+ <p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8ACB\u7ACB\u5373\u66F4\u6539\u5BC6\u78BC\u3002</p>`,
5000
+ 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`
4478
5001
  }
4479
5002
  };
4480
5003
 
@@ -4633,6 +5156,29 @@ change your password \u2014 someone else may be able to sign in as you.`
4633
5156
  {{provider}} can no longer be used to sign in. Your email and password still work.
4634
5157
 
4635
5158
  If you did this, you're all set. If not, contact support right away.`
5159
+ },
5160
+ [MESSAGE_KEYS.accountDeletionCode]: {
5161
+ subject: `Confirm your account deletion`,
5162
+ html: `<h1>Confirm account deletion</h1>
5163
+ <p>Use this code to confirm that you want to delete your account:</p>
5164
+ <p><span class="pin-code">{{code}}</span></p>
5165
+ <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>`,
5166
+ text: `Your code to confirm deleting your account: {{code}}. It expires in 15 minutes. Didn't ask? Ignore this and change your password.`
5167
+ },
5168
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
5169
+ subject: `Your account will be deleted on {{deleteOn}}`,
5170
+ html: `<h1>Your account is scheduled for deletion</h1>
5171
+ <p>We received your request. Your account is closed and will be permanently deleted on <strong>{{deleteOn}}</strong>.</p>
5172
+ <p>Changed your mind? Sign in before that date and choose <strong>Keep my account</strong>.</p>
5173
+ <p class="muted">If you didn&rsquo;t ask for this, sign in now to keep your account and change your password.</p>`,
5174
+ text: `Your account will be permanently deleted on {{deleteOn}}. Changed your mind? Sign in before then and choose Keep my account.`
5175
+ },
5176
+ [MESSAGE_KEYS.accountRestored]: {
5177
+ subject: `Your account was restored`,
5178
+ html: `<h1>Welcome back</h1>
5179
+ <p>Your account deletion was cancelled and your account is active again.</p>
5180
+ <p class="muted">If you didn&rsquo;t do this, change your password right away.</p>`,
5181
+ text: `Your account deletion was cancelled; your account is active again. Didn't do this? Change your password now.`
4636
5182
  }
4637
5183
  };
4638
5184
  var DEFAULT_TEMPLATES = withTranslations(EN_TEMPLATES, {
@@ -4658,7 +5204,10 @@ var SAMPLE_PAYLOADS = {
4658
5204
  [MESSAGE_KEYS.mfaDisabled]: {},
4659
5205
  [MESSAGE_KEYS.mfaBackupCodesRegenerated]: {},
4660
5206
  [MESSAGE_KEYS.emailChanged]: { newEmail: "new@example.com" },
4661
- [MESSAGE_KEYS.phoneChanged]: {}
5207
+ [MESSAGE_KEYS.phoneChanged]: {},
5208
+ [MESSAGE_KEYS.accountDeletionCode]: { code: "123456" },
5209
+ [MESSAGE_KEYS.accountDeletionScheduled]: { deleteOn: "November 3, 2026" },
5210
+ [MESSAGE_KEYS.accountRestored]: {}
4662
5211
  };
4663
5212
 
4664
5213
  // src/middlewares/require-auth.ts