@fonderie/auth 7.23.0 → 7.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -223,13 +223,16 @@ var schemas_exports = {};
223
223
  __export(schemas_exports, {
224
224
  appleNativeSchema: () => appleNativeSchema,
225
225
  changePasswordSchema: () => changePasswordSchema,
226
+ confirmDeletionSchema: () => confirmDeletionSchema,
226
227
  forgotPasswordSchema: () => forgotPasswordSchema,
227
228
  googleNativeSchema: () => googleNativeSchema,
228
229
  loginSchema: () => loginSchema,
229
230
  mfaTokenSchema: () => mfaTokenSchema,
230
231
  refreshSchema: () => refreshSchema,
231
232
  registerSchema: () => registerSchema,
233
+ requestDeletionSchema: () => requestDeletionSchema,
232
234
  resetPasswordSchema: () => resetPasswordSchema,
235
+ restoreAccountSchema: () => restoreAccountSchema,
233
236
  updateEmailSchema: () => updateEmailSchema,
234
237
  updatePhoneSchema: () => updatePhoneSchema,
235
238
  updatePreferencesSchema: () => updatePreferencesSchema,
@@ -309,6 +312,10 @@ var changePasswordSchema = import_zod.z.object({
309
312
  newPassword: password
310
313
  });
311
314
  var mfaTokenSchema = import_zod.z.object({ token: import_zod.z.string().trim().min(6).max(64) });
315
+ var secondFactor = import_zod.z.string().trim().min(6).max(16).optional();
316
+ var requestDeletionSchema = import_zod.z.object({ channel: import_zod.z.enum(["email", "sms"]) });
317
+ var confirmDeletionSchema = import_zod.z.object({ code: sixDigitPin, mfaCode: secondFactor });
318
+ var restoreAccountSchema = import_zod.z.object({ restoreToken: import_zod.z.string().min(20).max(4096), mfaCode: secondFactor });
312
319
 
313
320
  // src/services/cookies.ts
314
321
  function secureAttr(config) {
@@ -374,13 +381,23 @@ var MESSAGE_KEYS = {
374
381
  // arrives already verified by the provider and has nothing to confirm.
375
382
  oauthRegistration: "oauth-registration",
376
383
  oauthLinked: "oauth-linked",
377
- oauthUnlinked: "oauth-unlinked"
384
+ oauthUnlinked: "oauth-unlinked",
385
+ // Account deletion (docs/ACCOUNT-DELETION-DESIGN.md). Each goes to the ONE
386
+ // verified channel the person chose — route these types to both 'email'
387
+ // and 'sms' in courier: auth fills in only the chosen address, and the
388
+ // other channel skips. SMS sends each template's text.
389
+ accountDeletionCode: "account-deletion-code",
390
+ accountDeletionScheduled: "account-deletion-scheduled",
391
+ accountRestored: "account-restored"
378
392
  };
379
393
  var EVENT_KEYS = {
380
394
  userRegistered: "fonderie.user.registered",
381
395
  userDeleted: "fonderie.user.deleted",
382
396
  // Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.
383
397
  userPurged: "fonderie.user.purged",
398
+ // An archived account was kept: { userId }. Undo what deletion paused
399
+ // (billing resumes a subscription set to end at the period's end).
400
+ userRestored: "fonderie.user.restored",
384
401
  emailVerified: "fonderie.user.email_verified",
385
402
  passwordChanged: "fonderie.user.password_changed",
386
403
  // Sessions the server revoked: { userId, sids (null = all), reason }. Reaches
@@ -822,6 +839,14 @@ var UserModel = class {
822
839
  );
823
840
  return row ?? null;
824
841
  }
842
+ /** An archived account by id (restore). */
843
+ async findArchivedById(id) {
844
+ const [row] = await this.store.query(
845
+ `SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NOT NULL`,
846
+ [id]
847
+ );
848
+ return row ?? null;
849
+ }
825
850
  /** As findArchivedByEmail, for a phone number. */
826
851
  async findArchivedByPhone(phone2) {
827
852
  const [row] = await this.store.query(
@@ -1373,10 +1398,17 @@ var BackupCodeModel = class {
1373
1398
  );
1374
1399
  return rows.map((r) => ({ id: r.id, codeHash: r.code_hash }));
1375
1400
  }
1401
+ /**
1402
+ * Spend a backup code — once. Conditional on still being unused, so two
1403
+ * requests racing with the same code cannot both sign in: the second finds
1404
+ * it spent (false).
1405
+ */
1376
1406
  async consume(id) {
1377
- await this.store.query(`UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1`, [
1378
- id
1379
- ]);
1407
+ const rows = await this.store.query(
1408
+ `UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`,
1409
+ [id]
1410
+ );
1411
+ return rows.length > 0;
1380
1412
  }
1381
1413
  async deleteByUser(userId) {
1382
1414
  await this.store.query(`DELETE FROM fonderie_mfa_backup_codes WHERE user_id = $1`, [userId]);
@@ -1516,6 +1548,7 @@ function mfaController(store, config, issuer, bus) {
1516
1548
  },
1517
1549
  // ── 2. Verify (setup confirm · TOTP login · backup code login) ──
1518
1550
  verify: async (ctx) => {
1551
+ if (ctx.user.deletedAt) return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1519
1552
  const body = ctx.meta["body"];
1520
1553
  const token = body?.["token"];
1521
1554
  const isLoginCompletion = ctx.user.mfaPending === true;
@@ -1570,7 +1603,10 @@ function mfaController(store, config, issuer, bus) {
1570
1603
  if (isLoginCompletion) await failedMfa();
1571
1604
  return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
1572
1605
  }
1573
- await backupCodes.consume(matched.id);
1606
+ if (!await backupCodes.consume(matched.id)) {
1607
+ if (isLoginCompletion) await failedMfa();
1608
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
1609
+ }
1574
1610
  } else {
1575
1611
  if (!ctx.user.mfaPending) {
1576
1612
  return (0, import_core5.setApiResponse)(
@@ -1697,16 +1733,39 @@ init_password();
1697
1733
 
1698
1734
  // src/services/pending-deletion.ts
1699
1735
  var import_core6 = require("@fonderie/core");
1736
+
1737
+ // src/services/restore-token.ts
1738
+ var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
1739
+ var RESTORE_TTL = "10m";
1740
+ function issueRestoreToken(userId, deletedAt, loginMethod, config) {
1741
+ return import_jsonwebtoken2.default.sign(
1742
+ { sub: userId, type: "restore", loginMethod, archivedAt: deletedAt.toISOString() },
1743
+ config.jwtSecret,
1744
+ { expiresIn: RESTORE_TTL, keyid: keyIdOf(config.jwtSecret) }
1745
+ );
1746
+ }
1747
+ function verifyRestoreToken(token, config) {
1748
+ const payload = verifyToken(token, config);
1749
+ if (!payload || payload.type !== "restore" || typeof payload.sub !== "string" || typeof payload.archivedAt !== "string") return null;
1750
+ return payload;
1751
+ }
1752
+
1753
+ // src/services/pending-deletion.ts
1700
1754
  function deletionDate(deletedAt, config) {
1701
1755
  return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
1702
1756
  }
1703
- function pendingDeletionResponse(deletedAt, config) {
1704
- const deleteOn = deletionDate(deletedAt, config);
1757
+ function pendingDeletionResponse(user, loginMethod, config) {
1758
+ const deleteOn = deletionDate(user.deletedAt, config);
1705
1759
  return (0, import_core6.setApiResponse)(
1706
1760
  import_core6.HTTP.FORBIDDEN,
1707
1761
  "ACCOUNT_PENDING_DELETION",
1708
1762
  `This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
1709
- { requestedAt: deletedAt.toISOString(), deleteOn: deleteOn.toISOString() }
1763
+ {
1764
+ requestedAt: user.deletedAt.toISOString(),
1765
+ deleteOn: deleteOn.toISOString(),
1766
+ restoreToken: issueRestoreToken(user.id, user.deletedAt, loginMethod, config),
1767
+ mfaRequired: user.mfaEnabled === true
1768
+ }
1710
1769
  );
1711
1770
  }
1712
1771
  function archivedAddressResponse() {
@@ -1717,6 +1776,27 @@ function archivedAddressResponse() {
1717
1776
  );
1718
1777
  }
1719
1778
 
1779
+ // src/services/second-factor.ts
1780
+ init_password();
1781
+ async function verifySecondFactor(store, config, userId, code) {
1782
+ if (typeof code !== "string") return false;
1783
+ const value = code.trim();
1784
+ if (/^\d{6}$/.test(value)) {
1785
+ const stored = await new UserModel(store).getMfaSecret(userId);
1786
+ if (!stored) return false;
1787
+ return verifyTotpToken(value, makeMfaCipher(config.mfaSecretKey).decrypt(stored));
1788
+ }
1789
+ if (/^[A-Z0-9]{8}$/i.test(value)) {
1790
+ const backupCodes = new BackupCodeModel(store);
1791
+ for (const row of await backupCodes.findUnused(userId)) {
1792
+ if (await verifyPassword(value.toUpperCase(), row.codeHash)) {
1793
+ return backupCodes.consume(row.id);
1794
+ }
1795
+ }
1796
+ }
1797
+ return false;
1798
+ }
1799
+
1720
1800
  // src/models/password-reset.model.ts
1721
1801
  var import_node_crypto5 = require("crypto");
1722
1802
  var hashSecret = (value) => (0, import_node_crypto5.createHash)("sha256").update(value).digest("hex");
@@ -2057,7 +2137,7 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2057
2137
  failureReason: valid2 ? "pending_deletion" : "bad_password",
2058
2138
  ...meta
2059
2139
  });
2060
- if (valid2) return pendingDeletionResponse(archived.deletedAt, config);
2140
+ if (valid2) return pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, "email", config);
2061
2141
  return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2062
2142
  }
2063
2143
  }
@@ -2135,7 +2215,7 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2135
2215
  }
2136
2216
  const phone2 = body?.["phone"];
2137
2217
  if (isValidPhone(phone2)) {
2138
- const user = await users.findByPhone(normalizePhone(phone2));
2218
+ const user = await users.findByPhone(normalizePhone(phone2)) ?? await users.findArchivedByPhone(normalizePhone(phone2));
2139
2219
  if (!user) {
2140
2220
  return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2141
2221
  }
@@ -2171,6 +2251,65 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2171
2251
  "Provide email + password or a valid phone number"
2172
2252
  );
2173
2253
  },
2254
+ /**
2255
+ * "Keep my account": the restoreToken from a sign-in to an archived account
2256
+ * (proof already given: password, Google / Apple, phone code), plus a
2257
+ * second factor when two-factor is on. Un-archives in one statement —
2258
+ * only the SAME archive the proof was for — then signs the person in.
2259
+ */
2260
+ restoreAccount: async (ctx) => {
2261
+ const body = ctx.meta["body"];
2262
+ const proof = verifyRestoreToken(String(body?.["restoreToken"] ?? ""), config);
2263
+ const invalid = () => (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "RESTORE_TOKEN_INVALID", "Sign in again to keep your account.");
2264
+ if (!proof) return invalid();
2265
+ const archived = await users.findArchivedById(proof.sub);
2266
+ if (!archived?.deletedAt || archived.deletedAt.toISOString() !== proof.archivedAt) return invalid();
2267
+ if (archived.suspended) {
2268
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.FORBIDDEN, "ACCOUNT_SUSPENDED", "Account suspended. Please contact support.");
2269
+ }
2270
+ if (archived.mfaEnabled && !await verifySecondFactor(store, config, archived.id, body?.["mfaCode"])) {
2271
+ return (0, import_core7.setApiResponse)(import_core7.HTTP.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to keep your account.");
2272
+ }
2273
+ const [restored] = await store.query(
2274
+ `UPDATE fonderie_users
2275
+ SET deleted_at = NULL, deletion_channel = NULL, deletion_reminded_at = NULL, updated_at = now()
2276
+ -- The SAME archive the proof was for. Postgres keeps microseconds,
2277
+ -- the proof (a JS date) milliseconds: compare at that precision.
2278
+ WHERE id = $1 AND date_trunc('milliseconds', deleted_at) = date_trunc('milliseconds', $2::timestamptz)
2279
+ RETURNING (SELECT deletion_channel FROM fonderie_users WHERE id = $1) AS channel`,
2280
+ [archived.id, proof.archivedAt]
2281
+ );
2282
+ if (!restored) return invalid();
2283
+ const meta = requestMeta(ctx);
2284
+ const method = proof.loginMethod === "google" ? "oauth-google" : proof.loginMethod === "apple" ? "oauth-apple" : proof.loginMethod === "phone" ? "phone" : "password";
2285
+ const { accessToken, refreshToken, sid } = issueTokenPair(archived.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2286
+ loginMethod: proof.loginMethod,
2287
+ phoneVerified: proof.loginMethod === "phone"
2288
+ });
2289
+ await sessions.create(archived.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2290
+ await loginEvents.recordSafe({ userId: archived.id, emailAttempted: archived.email, method, outcome: "success", ...meta });
2291
+ const reqId = ctx.meta["requestId"];
2292
+ await (0, import_core7.background)(bus?.emit(EVENT_KEYS.userRestored, { userId: archived.id }, reqId !== void 0 ? { requestId: reqId } : void 0));
2293
+ const viaSms = restored.channel === "sms" || !archived.email && !!archived.phone;
2294
+ const address = viaSms ? archived.phone : archived.email;
2295
+ if (address) {
2296
+ await (0, import_core7.background)(bus?.emit(import_events2.NOTIFICATION_EVENT, {
2297
+ type: MESSAGE_KEYS.accountRestored,
2298
+ locale: archived.locale,
2299
+ data: {},
2300
+ recipient: viaSms ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
2301
+ }));
2302
+ }
2303
+ const user = await users.findById(archived.id);
2304
+ return Response.json(
2305
+ {
2306
+ reason: "ACCOUNT_RESTORED",
2307
+ explanation: "Your account is active again.",
2308
+ result: { tokens: { access: accessToken, refresh: refreshToken }, user: user ? toUserDTO(user) : null }
2309
+ },
2310
+ { status: 200, headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config)) }
2311
+ );
2312
+ },
2174
2313
  logout: async (ctx) => {
2175
2314
  const sid = ctx.user?.sid;
2176
2315
  if (sid) {
@@ -2309,8 +2448,10 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2309
2448
  );
2310
2449
  }
2311
2450
  let row;
2451
+ let presented;
2312
2452
  if (typeof rawToken === "string" && rawToken.trim().length >= 32) {
2313
- row = await passwordReset.findByToken(rawToken.trim());
2453
+ presented = rawToken.trim();
2454
+ row = await passwordReset.findByToken(presented);
2314
2455
  } else {
2315
2456
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2316
2457
  return (0, import_core7.setApiResponse)(
@@ -2319,7 +2460,8 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2319
2460
  "a 6-digit pin or a reset token is required"
2320
2461
  );
2321
2462
  }
2322
- row = await passwordReset.findByPin(raw.trim());
2463
+ presented = raw.trim();
2464
+ row = await passwordReset.findByPin(presented);
2323
2465
  }
2324
2466
  if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
2325
2467
  return (0, import_core7.setApiResponse)(
@@ -2330,17 +2472,22 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2330
2472
  }
2331
2473
  const passwordHash = await hashPassword(password2);
2332
2474
  const [changed] = await store.query(
2333
- `WITH changed AS (
2475
+ `WITH spent AS (
2476
+ -- The code is spent HERE, and only what this statement spent
2477
+ -- changes a password: two resets racing with one code \u2014 the
2478
+ -- second finds it gone and changes nothing.
2479
+ DELETE FROM fonderie_password_resets
2480
+ WHERE user_id = $2 AND (pin = $3 OR token = $3) AND expires_at > now()
2481
+ RETURNING user_id
2482
+ ), changed AS (
2334
2483
  UPDATE fonderie_users SET password_hash = $1, updated_at = now()
2335
- WHERE id = $2 AND deleted_at IS NULL
2484
+ WHERE id IN (SELECT user_id FROM spent) AND deleted_at IS NULL
2336
2485
  RETURNING id
2337
- ), spent AS (
2338
- DELETE FROM fonderie_password_resets WHERE user_id = $2
2339
2486
  ), revoked AS (
2340
2487
  DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
2341
2488
  )
2342
2489
  SELECT id FROM changed`,
2343
- [passwordHash, row.userId]
2490
+ [passwordHash, row.userId, hashSecret(presented)]
2344
2491
  );
2345
2492
  if (!changed) {
2346
2493
  return (0, import_core7.setApiResponse)(
@@ -2402,6 +2549,17 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2402
2549
  );
2403
2550
  }
2404
2551
  await phoneVerif.deleteByUser(ctx.user.id);
2552
+ if (ctx.user.deletedAt) {
2553
+ await loginEvents.recordSafe({
2554
+ userId: ctx.user.id,
2555
+ emailAttempted: null,
2556
+ method: "phone",
2557
+ outcome: "failed",
2558
+ failureReason: "pending_deletion",
2559
+ ...phoneMeta
2560
+ });
2561
+ return pendingDeletionResponse({ ...ctx.user, deletedAt: ctx.user.deletedAt }, "phone", config);
2562
+ }
2405
2563
  const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2406
2564
  loginMethod: "phone",
2407
2565
  phoneVerified: true
@@ -2546,7 +2704,7 @@ function authController(store, config, bus, locales = (0, import_core7.defineLoc
2546
2704
  }
2547
2705
 
2548
2706
  // src/controllers/user.controller.ts
2549
- var import_node_crypto7 = require("crypto");
2707
+ var import_node_crypto8 = require("crypto");
2550
2708
  var import_core9 = require("@fonderie/core");
2551
2709
  var import_events3 = require("@fonderie/events");
2552
2710
 
@@ -2585,7 +2743,64 @@ function toSessionDTO(row, currentSid) {
2585
2743
  };
2586
2744
  }
2587
2745
 
2746
+ // src/models/account-deletion.model.ts
2747
+ var import_node_crypto7 = require("crypto");
2748
+ var hashCode = (code) => (0, import_node_crypto7.createHash)("sha256").update(code).digest("hex");
2749
+ var DELETION_CODE_MAX_ATTEMPTS = 5;
2750
+ var AccountDeletionModel = class {
2751
+ constructor(store) {
2752
+ this.store = store;
2753
+ }
2754
+ store;
2755
+ /** Replace any pending code (a new request supersedes the old one). */
2756
+ async saveCode(userId, code, channel, expiresAt) {
2757
+ await this.store.query(
2758
+ `INSERT INTO fonderie_account_deletion_codes (user_id, code_hash, channel, attempts, expires_at, created_at)
2759
+ VALUES ($1, $2, $3, 0, $4, now())
2760
+ ON CONFLICT (user_id) DO UPDATE
2761
+ SET code_hash = $2, channel = $3, attempts = 0, expires_at = $4, created_at = now()`,
2762
+ [userId, hashCode(code), channel, expiresAt]
2763
+ );
2764
+ }
2765
+ async lastSentAt(userId) {
2766
+ const [row] = await this.store.query(
2767
+ `SELECT created_at AS "createdAt" FROM fonderie_account_deletion_codes WHERE user_id = $1`,
2768
+ [userId]
2769
+ );
2770
+ return row ? new Date(row.createdAt) : null;
2771
+ }
2772
+ /**
2773
+ * Check a code in ONE statement: a wrong code spends an attempt, a right
2774
+ * one is consumed (deleted) — so a code is good once, and a guesser gets
2775
+ * five tries in total however many requests they race.
2776
+ */
2777
+ async checkCode(userId, code) {
2778
+ const [row] = await this.store.query(
2779
+ `WITH current AS (
2780
+ SELECT user_id, code_hash = $2 AS matched, expires_at <= now() AS expired, attempts, channel
2781
+ FROM fonderie_account_deletion_codes WHERE user_id = $1
2782
+ FOR UPDATE
2783
+ ), spent AS (
2784
+ DELETE FROM fonderie_account_deletion_codes d USING current c
2785
+ WHERE d.user_id = c.user_id AND c.matched AND NOT c.expired AND c.attempts < $3
2786
+ ), missed AS (
2787
+ UPDATE fonderie_account_deletion_codes d SET attempts = d.attempts + 1
2788
+ FROM current c
2789
+ WHERE d.user_id = c.user_id AND NOT (c.matched AND NOT c.expired AND c.attempts < $3)
2790
+ )
2791
+ SELECT matched, expired, attempts, channel FROM current`,
2792
+ [userId, hashCode(code), DELETION_CODE_MAX_ATTEMPTS]
2793
+ );
2794
+ if (!row) return { result: "none" };
2795
+ if (row.attempts >= DELETION_CODE_MAX_ATTEMPTS) return { result: "exhausted" };
2796
+ if (row.expired) return { result: "expired" };
2797
+ return { result: row.matched ? "ok" : "invalid", channel: row.channel };
2798
+ }
2799
+ };
2800
+
2588
2801
  // src/controllers/user.controller.ts
2802
+ var DELETION_CODE_TTL_MS = 15 * 60 * 1e3;
2803
+ var DELETION_CODE_COOLDOWN_MS = 60 * 1e3;
2589
2804
  function normalizePhone2(phone2) {
2590
2805
  return phone2.trim().replace(/[\s()\-\.]/g, "");
2591
2806
  }
@@ -2598,6 +2813,55 @@ function userController(store, config, bus) {
2598
2813
  const loginEvents = new LoginEventModel(store, config.location);
2599
2814
  const emailVerif = new EmailVerificationModel(store);
2600
2815
  const phoneVerif = new PhoneVerificationModel(store);
2816
+ const deletions = new AccountDeletionModel(store);
2817
+ const deletionBlocked = async (userId) => {
2818
+ for (const blocker of config.accountDeletion?.blockers ?? []) {
2819
+ const refusal = await blocker(userId);
2820
+ if (refusal) return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, refusal.reason, refusal.explanation, refusal.details);
2821
+ }
2822
+ return null;
2823
+ };
2824
+ const archive = async (userId, channel) => {
2825
+ const [row] = await store.query(
2826
+ `WITH archived AS (
2827
+ UPDATE fonderie_users
2828
+ SET deleted_at = now(), updated_at = now(), deletion_channel = $2, deletion_reminded_at = NULL
2829
+ WHERE id = $1 AND deleted_at IS NULL
2830
+ RETURNING id, deleted_at
2831
+ ), sessions_ended AS (
2832
+ DELETE FROM fonderie_sessions WHERE user_id = $1
2833
+ ), resets AS (
2834
+ DELETE FROM fonderie_password_resets WHERE user_id = $1
2835
+ ), emails AS (
2836
+ DELETE FROM fonderie_email_verifications WHERE user_id = $1
2837
+ ), phones AS (
2838
+ DELETE FROM fonderie_phone_verifications WHERE user_id = $1
2839
+ ), codes AS (
2840
+ DELETE FROM fonderie_account_deletion_codes WHERE user_id = $1
2841
+ )
2842
+ SELECT deleted_at AS "deletedAt" FROM archived`,
2843
+ [userId, channel]
2844
+ );
2845
+ return row ? new Date(row.deletedAt) : /* @__PURE__ */ new Date();
2846
+ };
2847
+ const announceArchived = async (ctx, deletedAt) => {
2848
+ const userId = ctx.user.id;
2849
+ const reqId = ctx.meta["requestId"];
2850
+ await (0, import_core9.background)(bus?.emit(
2851
+ EVENT_KEYS.userDeleted,
2852
+ { userId, deletedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() },
2853
+ reqId !== void 0 ? { requestId: reqId } : void 0
2854
+ ));
2855
+ await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2856
+ };
2857
+ const archivedResponse = (deletedAt) => Response.json(
2858
+ {
2859
+ reason: "ACCOUNT_DELETED",
2860
+ explanation: "Account closed. It will be permanently deleted on the date shown; sign in before then to keep it.",
2861
+ result: { requestedAt: deletedAt.toISOString(), deleteOn: deletionDate(deletedAt, config).toISOString() }
2862
+ },
2863
+ { status: 200, headers: cookieHeaders(clearedTokenCookies(config)) }
2864
+ );
2601
2865
  return {
2602
2866
  me: async (ctx) => {
2603
2867
  const user = await users.findById(ctx.user.id);
@@ -2744,7 +3008,7 @@ function userController(store, config, bus) {
2744
3008
  if (existing) {
2745
3009
  return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, "EMAIL_IN_USE", "Email already in use");
2746
3010
  }
2747
- const pin = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
3011
+ const pin = (0, import_node_crypto8.randomInt)(1e5, 1e6).toString();
2748
3012
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60 * 24);
2749
3013
  await emailVerif.replace(ctx.user.id, pin, expiresAt);
2750
3014
  await users.updateEmail(ctx.user.id, normalised);
@@ -2786,7 +3050,7 @@ function userController(store, config, bus) {
2786
3050
  if (existing) {
2787
3051
  return (0, import_core9.setApiResponse)(import_core9.HTTP.CONFLICT, "PHONE_IN_USE", "Phone number already in use");
2788
3052
  }
2789
- const otp = (0, import_node_crypto7.randomInt)(1e5, 1e6).toString();
3053
+ const otp = (0, import_node_crypto8.randomInt)(1e5, 1e6).toString();
2790
3054
  const expiresAt = new Date(Date.now() + 10 * 60 * 1e3);
2791
3055
  await phoneVerif.upsert(ctx.user.id, normalised, otp, expiresAt);
2792
3056
  await users.updatePhone(ctx.user.id, normalised);
@@ -2880,30 +3144,95 @@ function userController(store, config, bus) {
2880
3144
  }
2881
3145
  return (0, import_core9.setApiResponse)(import_core9.HTTP.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2882
3146
  },
3147
+ // Legacy one-call deletion (no code). Kept so existing apps keep working;
3148
+ // apps move to POST /users/me/deletion + /confirm (proof + notices).
2883
3149
  deleteMe: async (ctx) => {
2884
- const userId = ctx.user.id;
2885
- await users.softDelete(userId);
2886
- await sessions.deleteByUser(userId);
2887
- await store.query(
2888
- `WITH resets AS (DELETE FROM fonderie_password_resets WHERE user_id = $1),
2889
- emails AS (DELETE FROM fonderie_email_verifications WHERE user_id = $1)
2890
- DELETE FROM fonderie_phone_verifications WHERE user_id = $1`,
2891
- [userId]
2892
- );
2893
- const reqId = ctx.meta["requestId"];
2894
- await (0, import_core9.background)(bus?.emit(
2895
- EVENT_KEYS.userDeleted,
2896
- { userId },
2897
- reqId !== void 0 ? { requestId: reqId } : void 0
2898
- ));
2899
- await (0, import_core9.background)(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2900
- return Response.json(
2901
- { reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
2902
- {
2903
- status: 200,
2904
- headers: cookieHeaders(clearedTokenCookies(config))
2905
- }
2906
- );
3150
+ const deletedAt = await archive(ctx.user.id, null);
3151
+ await announceArchived(ctx, deletedAt);
3152
+ return archivedResponse(deletedAt);
3153
+ },
3154
+ /**
3155
+ * Step 1 of deleting an account: a 6-digit code to the channel the person
3156
+ * picks ('email' | 'sms' — an address on the account; receiving the code
3157
+ * proves they hold it). Refused while a blocker objects (e.g. they own a
3158
+ * team with other members — transfer it first).
3159
+ */
3160
+ requestDeletion: async (ctx) => {
3161
+ const user = ctx.user;
3162
+ const body = ctx.meta["body"];
3163
+ const channel = body?.["channel"] === "sms" ? "sms" : body?.["channel"] === "email" ? "email" : null;
3164
+ if (!channel) {
3165
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "channel must be 'email' or 'sms'");
3166
+ }
3167
+ const address = channel === "email" ? user.email : user.phone;
3168
+ if (!address) {
3169
+ return (0, import_core9.setApiResponse)(
3170
+ import_core9.HTTP.UNPROCESSABLE,
3171
+ channel === "email" ? "NO_EMAIL_ON_ACCOUNT" : "NO_PHONE_ON_ACCOUNT",
3172
+ `There is no ${channel === "email" ? "email address" : "phone number"} on this account to send the code to.`
3173
+ );
3174
+ }
3175
+ const refused = await deletionBlocked(user.id);
3176
+ if (refused) return refused;
3177
+ const remainingMs = checkCooldown(await deletions.lastSentAt(user.id), DELETION_CODE_COOLDOWN_MS);
3178
+ if (remainingMs > 0) {
3179
+ const retryAfter = Math.ceil(remainingMs / 1e3);
3180
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.TOO_MANY_REQUESTS, "VERIFICATION_COOLDOWN", `Wait ${retryAfter}s before requesting a new code.`, { retryAfter });
3181
+ }
3182
+ const code = (0, import_node_crypto8.randomInt)(1e5, 1e6).toString();
3183
+ await deletions.saveCode(user.id, code, channel, new Date(Date.now() + DELETION_CODE_TTL_MS));
3184
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3185
+ type: MESSAGE_KEYS.accountDeletionCode,
3186
+ locale: user.locale,
3187
+ data: { code },
3188
+ recipient: channel === "email" ? { email: address, phone: null, deviceToken: null } : { email: null, phone: address, deviceToken: null }
3189
+ }));
3190
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.ACCEPTED, "ACCOUNT_DELETION_CODE_SENT", "A code to confirm the deletion was sent.", {
3191
+ channel,
3192
+ expiresInSeconds: DELETION_CODE_TTL_MS / 1e3,
3193
+ mfaRequired: user.mfaEnabled === true
3194
+ });
3195
+ },
3196
+ /**
3197
+ * Step 2: the code (and, with two-factor on, a second factor) archives the
3198
+ * account — closed at once, restorable at sign-in until the purge date.
3199
+ */
3200
+ confirmDeletion: async (ctx) => {
3201
+ const user = ctx.user;
3202
+ const body = ctx.meta["body"];
3203
+ const code = typeof body?.["code"] === "string" ? body["code"].trim() : "";
3204
+ if (!/^\d{6}$/.test(code)) {
3205
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "code must be the 6-digit code that was sent");
3206
+ }
3207
+ const refused = await deletionBlocked(user.id);
3208
+ if (refused) return refused;
3209
+ if (user.mfaEnabled && !await verifySecondFactor(store, config, user.id, body?.["mfaCode"])) {
3210
+ return (0, import_core9.setApiResponse)(import_core9.HTTP.UNAUTHORIZED, "MFA_REQUIRED", "Enter a code from your authenticator app (or a backup code) to confirm.");
3211
+ }
3212
+ const checked = await deletions.checkCode(user.id, code);
3213
+ if (checked.result !== "ok") {
3214
+ return (0, import_core9.setApiResponse)(
3215
+ import_core9.HTTP.BAD_REQUEST,
3216
+ "VERIFICATION_FAILED",
3217
+ checked.result === "exhausted" || checked.result === "none" || checked.result === "expired" ? "This code can no longer be used. Request a new one." : "That code is incorrect."
3218
+ );
3219
+ }
3220
+ const deletedAt = await archive(user.id, checked.channel ?? null);
3221
+ await announceArchived(ctx, deletedAt);
3222
+ const deleteOn = deletionDate(deletedAt, config);
3223
+ const address = checked.channel === "sms" ? user.phone : user.email;
3224
+ if (address) {
3225
+ await (0, import_core9.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
3226
+ type: MESSAGE_KEYS.accountDeletionScheduled,
3227
+ locale: user.locale,
3228
+ data: {
3229
+ deleteOn: deleteOn.toISOString().slice(0, 10),
3230
+ [import_core9.COURIER_FORMAT_KEY]: { deleteOn: { date: deleteOn.toISOString(), style: "long" } }
3231
+ },
3232
+ recipient: checked.channel === "sms" ? { email: null, phone: address, deviceToken: null } : { email: address, phone: null, deviceToken: null }
3233
+ }));
3234
+ }
3235
+ return archivedResponse(deletedAt);
2907
3236
  },
2908
3237
  // Subject Access Request — the authenticated user's own data as a portable
2909
3238
  // JSON bundle. Only auth-owned data, and only safe fields: no password hash,
@@ -2945,13 +3274,13 @@ function userController(store, config, bus) {
2945
3274
  }
2946
3275
 
2947
3276
  // src/controllers/oauth.controller.ts
2948
- var import_node_crypto9 = require("crypto");
2949
- var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
3277
+ var import_node_crypto10 = require("crypto");
3278
+ var import_jsonwebtoken3 = __toESM(require("jsonwebtoken"), 1);
2950
3279
  var import_core10 = require("@fonderie/core");
2951
3280
  var import_events4 = require("@fonderie/events");
2952
3281
 
2953
3282
  // src/services/jwks.ts
2954
- var import_node_crypto8 = require("crypto");
3283
+ var import_node_crypto9 = require("crypto");
2955
3284
  var TTL_MS = 60 * 60 * 1e3;
2956
3285
  var MIN_REFETCH_MS = 60 * 1e3;
2957
3286
  var FETCH_TIMEOUT_MS = 5e3;
@@ -2985,7 +3314,7 @@ function createJwksSource(url) {
2985
3314
  }
2986
3315
  if (!jwk) return null;
2987
3316
  try {
2988
- return (0, import_node_crypto8.createPublicKey)({ key: jwk, format: "jwk" });
3317
+ return (0, import_node_crypto9.createPublicKey)({ key: jwk, format: "jwk" });
2989
3318
  } catch {
2990
3319
  return null;
2991
3320
  }
@@ -3031,7 +3360,7 @@ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
3031
3360
  var APPLE_ISSUER = "https://appleid.apple.com";
3032
3361
  function mintAppleClientSecret(apple) {
3033
3362
  const now = Math.floor(Date.now() / 1e3);
3034
- return import_jsonwebtoken2.default.sign(
3363
+ return import_jsonwebtoken3.default.sign(
3035
3364
  { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
3036
3365
  apple.privateKey,
3037
3366
  { algorithm: "ES256", keyid: apple.keyId }
@@ -3040,14 +3369,14 @@ function mintAppleClientSecret(apple) {
3040
3369
  var appleKeys = createJwksSource(APPLE_KEYS_URL);
3041
3370
  async function verifyAppleIdToken(idToken, opts) {
3042
3371
  if (opts.audiences.length === 0) return null;
3043
- const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
3372
+ const decoded = import_jsonwebtoken3.default.decode(idToken, { complete: true });
3044
3373
  if (!decoded || typeof decoded === "string") return null;
3045
3374
  const kid = decoded.header.kid;
3046
3375
  if (!kid) return null;
3047
3376
  const key = await appleKeys.getKey(kid);
3048
3377
  if (!key) return null;
3049
3378
  try {
3050
- const payload = import_jsonwebtoken2.default.verify(idToken, key, {
3379
+ const payload = import_jsonwebtoken3.default.verify(idToken, key, {
3051
3380
  algorithms: ["RS256"],
3052
3381
  issuer: APPLE_ISSUER,
3053
3382
  // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
@@ -3067,14 +3396,14 @@ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
3067
3396
  var googleKeys = createJwksSource(GOOGLE_KEYS_URL);
3068
3397
  async function verifyGoogleIdToken(idToken, opts) {
3069
3398
  if (opts.audiences.length === 0) return null;
3070
- const decoded = import_jsonwebtoken2.default.decode(idToken, { complete: true });
3399
+ const decoded = import_jsonwebtoken3.default.decode(idToken, { complete: true });
3071
3400
  if (!decoded || typeof decoded === "string") return null;
3072
3401
  const kid = decoded.header.kid;
3073
3402
  if (!kid) return null;
3074
3403
  const key = await googleKeys.getKey(kid);
3075
3404
  if (!key) return null;
3076
3405
  try {
3077
- const payload = import_jsonwebtoken2.default.verify(idToken, key, {
3406
+ const payload = import_jsonwebtoken3.default.verify(idToken, key, {
3078
3407
  algorithms: ["RS256"],
3079
3408
  issuer: [...GOOGLE_ISSUERS],
3080
3409
  audience: opts.audiences
@@ -3176,7 +3505,7 @@ function oauthController(store, config, bus) {
3176
3505
  failureReason: "pending_deletion",
3177
3506
  ...meta
3178
3507
  });
3179
- const res = pendingDeletionResponse(archived.deletedAt, config);
3508
+ const res = pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, provider, config);
3180
3509
  const headers = cookieHeaders(clearCookies);
3181
3510
  headers.set("content-type", res.headers.get("content-type") ?? "application/json");
3182
3511
  return new Response(res.body, { status: res.status, headers });
@@ -3248,7 +3577,7 @@ function oauthController(store, config, bus) {
3248
3577
  "Google OAuth not configured"
3249
3578
  );
3250
3579
  }
3251
- const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
3580
+ const state = (0, import_node_crypto10.randomBytes)(16).toString("hex");
3252
3581
  const params = new URLSearchParams({
3253
3582
  client_id: google.clientId,
3254
3583
  redirect_uri: google.redirectUri,
@@ -3350,7 +3679,7 @@ function oauthController(store, config, bus) {
3350
3679
  if (!apple) {
3351
3680
  return (0, import_core10.setApiResponse)(import_core10.HTTP.NOT_IMPLEMENTED, "NOT_CONFIGURED", "Apple OAuth not configured");
3352
3681
  }
3353
- const state = (0, import_node_crypto9.randomBytes)(16).toString("hex");
3682
+ const state = (0, import_node_crypto10.randomBytes)(16).toString("hex");
3354
3683
  const params = new URLSearchParams({
3355
3684
  client_id: apple.clientId,
3356
3685
  redirect_uri: apple.redirectUri,
@@ -3446,7 +3775,7 @@ function oauthController(store, config, bus) {
3446
3775
  });
3447
3776
  return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3448
3777
  }
3449
- const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(identityToken).digest("hex");
3778
+ const tokenHash = (0, import_node_crypto10.createHash)("sha256").update(identityToken).digest("hex");
3450
3779
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3451
3780
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3452
3781
  if (!firstUse) {
@@ -3496,7 +3825,7 @@ function oauthController(store, config, bus) {
3496
3825
  });
3497
3826
  return (0, import_core10.setApiResponse)(import_core10.HTTP.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3498
3827
  }
3499
- const tokenHash = (0, import_node_crypto9.createHash)("sha256").update(idToken).digest("hex");
3828
+ const tokenHash = (0, import_node_crypto10.createHash)("sha256").update(idToken).digest("hex");
3500
3829
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3501
3830
  if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3502
3831
  await loginEvents.recordSafe({
@@ -3585,6 +3914,11 @@ function buildAuthRoutes(store, config, bus, locales) {
3585
3914
  // refuses when it would leave the account with no way to sign in.
3586
3915
  R("unlinkOauth", "DELETE", "/auth/oauth/:provider", import_middlewares2.requireAuth, user.unlinkOauth),
3587
3916
  R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
3917
+ // Deleting with proof: a code to a chosen channel, then confirm. The
3918
+ // account is archived until the purge; signing in offers to keep it.
3919
+ R("requestDeletion", "POST", "/users/me/deletion", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(requestDeletionSchema), user.requestDeletion),
3920
+ R("confirmDeletion", "POST", "/users/me/deletion/confirm", ipLimit("verify"), import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(confirmDeletionSchema), user.confirmDeletion),
3921
+ R("restoreAccount", "POST", "/auth/account/restore", ipLimit("login"), (0, import_middlewares.validate)(restoreAccountSchema), auth.restoreAccount),
3588
3922
  R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
3589
3923
  // Security surfaces (Protected; the caller's own login history + sessions).
3590
3924
  // The literal /others route is registered before /:id so it isn't
@@ -3788,8 +4122,11 @@ function withSession(store, config) {
3788
4122
  if (payload.sid && !await sessions.aliveBySid(payload.sid)) {
3789
4123
  return next();
3790
4124
  }
3791
- const user = await users.findById(payload.sub);
3792
- if (!user || user.suspended || user.deletedAt) {
4125
+ let user = await users.findById(payload.sub);
4126
+ if (!user && payload.mfaPending && payload.loginMethod === "phone") {
4127
+ user = await users.findArchivedById(payload.sub);
4128
+ }
4129
+ if (!user || user.suspended || user.deletedAt && !payload.mfaPending) {
3793
4130
  return next();
3794
4131
  }
3795
4132
  Object.assign(ctx, {
@@ -3964,7 +4301,7 @@ var AuthModule = class {
3964
4301
  name = "@fonderie/auth";
3965
4302
  // Baked in at build time by tsup.base, so the operator's Modules page can
3966
4303
  // say what is actually deployed rather than 'not reported'.
3967
- version = "7.23.0";
4304
+ version = "7.24.0";
3968
4305
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3969
4306
  describeAdmin() {
3970
4307
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };
@@ -4152,6 +4489,29 @@ inmediato y cambia tu contrase\xF1a \u2014 otra persona podr\xEDa iniciar sesi\x
4152
4489
  {{provider}} ya no se puede usar para iniciar sesi\xF3n. Tu correo electr\xF3nico y tu contrase\xF1a siguen funcionando.
4153
4490
 
4154
4491
  Si fuiste t\xFA, todo est\xE1 en orden. Si no, contacta con soporte de inmediato.`
4492
+ },
4493
+ [MESSAGE_KEYS.accountDeletionCode]: {
4494
+ subject: `Confirma la eliminaci\xF3n de tu cuenta`,
4495
+ html: `<h1>Confirmar la eliminaci\xF3n de la cuenta</h1>
4496
+ <p>Usa este c\xF3digo para confirmar que quieres eliminar tu cuenta:</p>
4497
+ <p><span class="pin-code">{{code}}</span></p>
4498
+ <p class="muted">Este c\xF3digo vence en 15 minutos. Si no pediste eliminar tu cuenta, ignora este mensaje y cambia tu contrase\xF1a: tu cuenta seguir\xE1 igual.</p>`,
4499
+ text: `Tu c\xF3digo para confirmar la eliminaci\xF3n de tu cuenta: {{code}}. Vence en 15 minutos. \xBFNo fuiste t\xFA? Ignora esto y cambia tu contrase\xF1a.`
4500
+ },
4501
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4502
+ subject: `Tu cuenta se eliminar\xE1 el {{deleteOn}}`,
4503
+ html: `<h1>Tu cuenta est\xE1 programada para eliminarse</h1>
4504
+ <p>Recibimos tu solicitud. Tu cuenta est\xE1 cerrada y se eliminar\xE1 de forma permanente el <strong>{{deleteOn}}</strong>.</p>
4505
+ <p>\xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes de esa fecha y elige <strong>Conservar mi cuenta</strong>.</p>
4506
+ <p class="muted">Si no lo pediste, inicia sesi\xF3n ahora para conservar tu cuenta y cambia tu contrase\xF1a.</p>`,
4507
+ text: `Tu cuenta se eliminar\xE1 de forma permanente el {{deleteOn}}. \xBFCambiaste de opini\xF3n? Inicia sesi\xF3n antes y elige Conservar mi cuenta.`
4508
+ },
4509
+ [MESSAGE_KEYS.accountRestored]: {
4510
+ subject: `Tu cuenta fue restablecida`,
4511
+ html: `<h1>Bienvenido de nuevo</h1>
4512
+ <p>Se cancel\xF3 la eliminaci\xF3n de tu cuenta y tu cuenta vuelve a estar activa.</p>
4513
+ <p class="muted">Si no fuiste t\xFA, cambia tu contrase\xF1a de inmediato.</p>`,
4514
+ text: `Se cancel\xF3 la eliminaci\xF3n de tu cuenta; tu cuenta vuelve a estar activa. \xBFNo fuiste t\xFA? Cambia tu contrase\xF1a ahora.`
4155
4515
  }
4156
4516
  };
4157
4517
 
@@ -4312,6 +4672,29 @@ pourrait se connecter \xE0 votre place.`
4312
4672
  {{provider}} ne peut plus \xEAtre utilis\xE9 pour vous connecter. Votre adresse e-mail et votre mot de passe fonctionnent toujours.
4313
4673
 
4314
4674
  Si c'est bien vous, tout est en ordre. Sinon, contactez imm\xE9diatement le support.`
4675
+ },
4676
+ [MESSAGE_KEYS.accountDeletionCode]: {
4677
+ subject: `Confirmez la suppression de votre compte`,
4678
+ html: `<h1>Confirmer la suppression du compte</h1>
4679
+ <p>Utilisez ce code pour confirmer que vous souhaitez supprimer votre compte :</p>
4680
+ <p><span class="pin-code">{{code}}</span></p>
4681
+ <p class="muted">Ce code expire dans 15 minutes. Si vous n&rsquo;avez pas demand\xE9 la suppression de votre compte, ignorez ce message et changez votre mot de passe &mdash; votre compte reste tel quel.</p>`,
4682
+ text: `Votre code pour confirmer la suppression de votre compte : {{code}}. Il expire dans 15 minutes. Ce n'est pas vous ? Ignorez ce message et changez votre mot de passe.`
4683
+ },
4684
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4685
+ subject: `Votre compte sera supprim\xE9 le {{deleteOn}}`,
4686
+ html: `<h1>Votre compte sera supprim\xE9</h1>
4687
+ <p>Nous avons bien re\xE7u votre demande. Votre compte est ferm\xE9 et sera supprim\xE9 d\xE9finitivement le <strong>{{deleteOn}}</strong>.</p>
4688
+ <p>Vous avez chang\xE9 d&rsquo;avis ? Connectez-vous avant cette date et choisissez <strong>Conserver mon compte</strong>.</p>
4689
+ <p class="muted">Si vous n&rsquo;\xEAtes pas \xE0 l&rsquo;origine de cette demande, connectez-vous maintenant pour conserver votre compte et changez votre mot de passe.</p>`,
4690
+ text: `Votre compte sera supprim\xE9 d\xE9finitivement le {{deleteOn}}. Vous avez chang\xE9 d'avis ? Connectez-vous avant cette date et choisissez Conserver mon compte.`
4691
+ },
4692
+ [MESSAGE_KEYS.accountRestored]: {
4693
+ subject: `Votre compte a \xE9t\xE9 r\xE9tabli`,
4694
+ html: `<h1>Bon retour</h1>
4695
+ <p>La suppression de votre compte a \xE9t\xE9 annul\xE9e et votre compte est de nouveau actif.</p>
4696
+ <p class="muted">Si ce n&rsquo;est pas vous, changez votre mot de passe imm\xE9diatement.</p>`,
4697
+ text: `La suppression de votre compte a \xE9t\xE9 annul\xE9e ; votre compte est de nouveau actif. Ce n'est pas vous ? Changez votre mot de passe maintenant.`
4315
4698
  }
4316
4699
  };
4317
4700
 
@@ -4466,6 +4849,29 @@ var ZH_HANS_TEMPLATES = {
4466
4849
  \u5DF2\u65E0\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5F55\u3002\u60A8\u7684\u7535\u5B50\u90AE\u7BB1\u548C\u5BC6\u7801\u4ECD\u7136\u53EF\u7528\u3002
4467
4850
 
4468
4851
  \u5982\u679C\u662F\u60A8\u672C\u4EBA\u79FB\u9664\u7684\uFF0C\u65E0\u9700\u4EFB\u4F55\u64CD\u4F5C\u3002\u5982\u679C\u4E0D\u662F\uFF0C\u8BF7\u7ACB\u5373\u8054\u7CFB\u5BA2\u670D\u3002`
4852
+ },
4853
+ [MESSAGE_KEYS.accountDeletionCode]: {
4854
+ subject: `\u786E\u8BA4\u5220\u9664\u60A8\u7684\u8D26\u6237`,
4855
+ html: `<h1>\u786E\u8BA4\u5220\u9664\u8D26\u6237</h1>
4856
+ <p>\u8BF7\u4F7F\u7528\u6B64\u9A8C\u8BC1\u7801\u786E\u8BA4\u60A8\u8981\u5220\u9664\u8D26\u6237\uFF1A</p>
4857
+ <p><span class="pin-code">{{code}}</span></p>
4858
+ <p class="muted">\u6B64\u9A8C\u8BC1\u7801\u5C06\u5728 15 \u5206\u949F\u540E\u5931\u6548\u3002\u5982\u679C\u60A8\u6CA1\u6709\u7533\u8BF7\u5220\u9664\u8D26\u6237\uFF0C\u8BF7\u5FFD\u7565\u6B64\u6D88\u606F\u5E76\u66F4\u6539\u5BC6\u7801\uFF0C\u60A8\u7684\u8D26\u6237\u5C06\u4FDD\u6301\u4E0D\u53D8\u3002</p>`,
4859
+ text: `\u786E\u8BA4\u5220\u9664\u8D26\u6237\u7684\u9A8C\u8BC1\u7801\uFF1A{{code}}\uFF0C15 \u5206\u949F\u5185\u6709\u6548\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8BF7\u5FFD\u7565\u5E76\u66F4\u6539\u5BC6\u7801\u3002`
4860
+ },
4861
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
4862
+ subject: `\u60A8\u7684\u8D26\u6237\u5C06\u4E8E {{deleteOn}} \u5220\u9664`,
4863
+ html: `<h1>\u60A8\u7684\u8D26\u6237\u5DF2\u5B89\u6392\u5220\u9664</h1>
4864
+ <p>\u6211\u4EEC\u5DF2\u6536\u5230\u60A8\u7684\u7533\u8BF7\u3002\u60A8\u7684\u8D26\u6237\u5DF2\u5173\u95ED\uFF0C\u5E76\u5C06\u4E8E <strong>{{deleteOn}}</strong> \u6C38\u4E45\u5220\u9664\u3002</p>
4865
+ <p>\u6539\u53D8\u4E3B\u610F\u4E86\uFF1F\u8BF7\u5728\u8BE5\u65E5\u671F\u524D\u767B\u5F55\u5E76\u9009\u62E9<strong>\u4FDD\u7559\u6211\u7684\u8D26\u6237</strong>\u3002</p>
4866
+ <p class="muted">\u5982\u679C\u8FD9\u4E0D\u662F\u60A8\u672C\u4EBA\u7684\u7533\u8BF7\uFF0C\u8BF7\u7ACB\u5373\u767B\u5F55\u4EE5\u4FDD\u7559\u8D26\u6237\u5E76\u66F4\u6539\u5BC6\u7801\u3002</p>`,
4867
+ text: `\u60A8\u7684\u8D26\u6237\u5C06\u4E8E {{deleteOn}} \u6C38\u4E45\u5220\u9664\u3002\u6539\u53D8\u4E3B\u610F\u4E86\uFF1F\u8BF7\u5728\u6B64\u524D\u767B\u5F55\u5E76\u9009\u62E9\u201C\u4FDD\u7559\u6211\u7684\u8D26\u6237\u201D\u3002`
4868
+ },
4869
+ [MESSAGE_KEYS.accountRestored]: {
4870
+ subject: `\u60A8\u7684\u8D26\u6237\u5DF2\u6062\u590D`,
4871
+ html: `<h1>\u6B22\u8FCE\u56DE\u6765</h1>
4872
+ <p>\u60A8\u7684\u8D26\u6237\u5220\u9664\u5DF2\u53D6\u6D88\uFF0C\u8D26\u6237\u5DF2\u91CD\u65B0\u542F\u7528\u3002</p>
4873
+ <p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8BF7\u7ACB\u5373\u66F4\u6539\u5BC6\u7801\u3002</p>`,
4874
+ text: `\u60A8\u7684\u8D26\u6237\u5220\u9664\u5DF2\u53D6\u6D88\uFF0C\u8D26\u6237\u5DF2\u91CD\u65B0\u542F\u7528\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8BF7\u7ACB\u5373\u66F4\u6539\u5BC6\u7801\u3002`
4469
4875
  }
4470
4876
  };
4471
4877
 
@@ -4620,6 +5026,29 @@ var ZH_HANT_TEMPLATES = {
4620
5026
  \u5DF2\u7121\u6CD5\u518D\u4F7F\u7528 {{provider}} \u767B\u5165\u3002\u60A8\u7684\u96FB\u5B50\u90F5\u4EF6\u548C\u5BC6\u78BC\u4ECD\u53EF\u4F7F\u7528\u3002
4621
5027
 
4622
5028
  \u5982\u679C\u662F\u60A8\u672C\u4EBA\u79FB\u9664\u7684\uFF0C\u7121\u9808\u63A1\u53D6\u4EFB\u4F55\u884C\u52D5\u3002\u5982\u679C\u4E0D\u662F\uFF0C\u8ACB\u7ACB\u5373\u806F\u7D61\u5BA2\u670D\u3002`
5029
+ },
5030
+ [MESSAGE_KEYS.accountDeletionCode]: {
5031
+ subject: `\u78BA\u8A8D\u522A\u9664\u60A8\u7684\u5E33\u6236`,
5032
+ html: `<h1>\u78BA\u8A8D\u522A\u9664\u5E33\u6236</h1>
5033
+ <p>\u8ACB\u4F7F\u7528\u6B64\u9A57\u8B49\u78BC\u78BA\u8A8D\u60A8\u8981\u522A\u9664\u5E33\u6236\uFF1A</p>
5034
+ <p><span class="pin-code">{{code}}</span></p>
5035
+ <p class="muted">\u6B64\u9A57\u8B49\u78BC\u5C07\u5728 15 \u5206\u9418\u5F8C\u5931\u6548\u3002\u5982\u679C\u60A8\u6C92\u6709\u7533\u8ACB\u522A\u9664\u5E33\u6236\uFF0C\u8ACB\u5FFD\u7565\u6B64\u8A0A\u606F\u4E26\u66F4\u6539\u5BC6\u78BC\uFF0C\u60A8\u7684\u5E33\u6236\u5C07\u4FDD\u6301\u4E0D\u8B8A\u3002</p>`,
5036
+ text: `\u78BA\u8A8D\u522A\u9664\u5E33\u6236\u7684\u9A57\u8B49\u78BC\uFF1A{{code}}\uFF0C15 \u5206\u9418\u5167\u6709\u6548\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8ACB\u5FFD\u7565\u4E26\u66F4\u6539\u5BC6\u78BC\u3002`
5037
+ },
5038
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
5039
+ subject: `\u60A8\u7684\u5E33\u6236\u5C07\u65BC {{deleteOn}} \u522A\u9664`,
5040
+ html: `<h1>\u60A8\u7684\u5E33\u6236\u5DF2\u6392\u5B9A\u522A\u9664</h1>
5041
+ <p>\u6211\u5011\u5DF2\u6536\u5230\u60A8\u7684\u7533\u8ACB\u3002\u60A8\u7684\u5E33\u6236\u5DF2\u95DC\u9589\uFF0C\u4E26\u5C07\u65BC <strong>{{deleteOn}}</strong> \u6C38\u4E45\u522A\u9664\u3002</p>
5042
+ <p>\u6539\u8B8A\u4E3B\u610F\u4E86\uFF1F\u8ACB\u5728\u8A72\u65E5\u671F\u524D\u767B\u5165\u4E26\u9078\u64C7<strong>\u4FDD\u7559\u6211\u7684\u5E33\u6236</strong>\u3002</p>
5043
+ <p class="muted">\u5982\u679C\u9019\u4E0D\u662F\u60A8\u672C\u4EBA\u7684\u7533\u8ACB\uFF0C\u8ACB\u7ACB\u5373\u767B\u5165\u4EE5\u4FDD\u7559\u5E33\u6236\u4E26\u66F4\u6539\u5BC6\u78BC\u3002</p>`,
5044
+ text: `\u60A8\u7684\u5E33\u6236\u5C07\u65BC {{deleteOn}} \u6C38\u4E45\u522A\u9664\u3002\u6539\u8B8A\u4E3B\u610F\u4E86\uFF1F\u8ACB\u5728\u6B64\u524D\u767B\u5165\u4E26\u9078\u64C7\u300C\u4FDD\u7559\u6211\u7684\u5E33\u6236\u300D\u3002`
5045
+ },
5046
+ [MESSAGE_KEYS.accountRestored]: {
5047
+ subject: `\u60A8\u7684\u5E33\u6236\u5DF2\u6062\u5FA9`,
5048
+ html: `<h1>\u6B61\u8FCE\u56DE\u4F86</h1>
5049
+ <p>\u60A8\u7684\u5E33\u6236\u522A\u9664\u5DF2\u53D6\u6D88\uFF0C\u5E33\u6236\u5DF2\u91CD\u65B0\u555F\u7528\u3002</p>
5050
+ <p class="muted">\u5982\u679C\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF0C\u8ACB\u7ACB\u5373\u66F4\u6539\u5BC6\u78BC\u3002</p>`,
5051
+ text: `\u60A8\u7684\u5E33\u6236\u522A\u9664\u5DF2\u53D6\u6D88\uFF0C\u5E33\u6236\u5DF2\u91CD\u65B0\u555F\u7528\u3002\u4E0D\u662F\u60A8\u672C\u4EBA\u64CD\u4F5C\uFF1F\u8ACB\u7ACB\u5373\u66F4\u6539\u5BC6\u78BC\u3002`
4623
5052
  }
4624
5053
  };
4625
5054
 
@@ -4778,6 +5207,29 @@ change your password \u2014 someone else may be able to sign in as you.`
4778
5207
  {{provider}} can no longer be used to sign in. Your email and password still work.
4779
5208
 
4780
5209
  If you did this, you're all set. If not, contact support right away.`
5210
+ },
5211
+ [MESSAGE_KEYS.accountDeletionCode]: {
5212
+ subject: `Confirm your account deletion`,
5213
+ html: `<h1>Confirm account deletion</h1>
5214
+ <p>Use this code to confirm that you want to delete your account:</p>
5215
+ <p><span class="pin-code">{{code}}</span></p>
5216
+ <p class="muted">This code expires in 15 minutes. If you didn&rsquo;t ask to delete your account, ignore this message and change your password &mdash; your account stays as it is.</p>`,
5217
+ text: `Your code to confirm deleting your account: {{code}}. It expires in 15 minutes. Didn't ask? Ignore this and change your password.`
5218
+ },
5219
+ [MESSAGE_KEYS.accountDeletionScheduled]: {
5220
+ subject: `Your account will be deleted on {{deleteOn}}`,
5221
+ html: `<h1>Your account is scheduled for deletion</h1>
5222
+ <p>We received your request. Your account is closed and will be permanently deleted on <strong>{{deleteOn}}</strong>.</p>
5223
+ <p>Changed your mind? Sign in before that date and choose <strong>Keep my account</strong>.</p>
5224
+ <p class="muted">If you didn&rsquo;t ask for this, sign in now to keep your account and change your password.</p>`,
5225
+ text: `Your account will be permanently deleted on {{deleteOn}}. Changed your mind? Sign in before then and choose Keep my account.`
5226
+ },
5227
+ [MESSAGE_KEYS.accountRestored]: {
5228
+ subject: `Your account was restored`,
5229
+ html: `<h1>Welcome back</h1>
5230
+ <p>Your account deletion was cancelled and your account is active again.</p>
5231
+ <p class="muted">If you didn&rsquo;t do this, change your password right away.</p>`,
5232
+ text: `Your account deletion was cancelled; your account is active again. Didn't do this? Change your password now.`
4781
5233
  }
4782
5234
  };
4783
5235
  var DEFAULT_TEMPLATES = (0, import_core15.withTranslations)(EN_TEMPLATES, {
@@ -4803,7 +5255,10 @@ var SAMPLE_PAYLOADS = {
4803
5255
  [MESSAGE_KEYS.mfaDisabled]: {},
4804
5256
  [MESSAGE_KEYS.mfaBackupCodesRegenerated]: {},
4805
5257
  [MESSAGE_KEYS.emailChanged]: { newEmail: "new@example.com" },
4806
- [MESSAGE_KEYS.phoneChanged]: {}
5258
+ [MESSAGE_KEYS.phoneChanged]: {},
5259
+ [MESSAGE_KEYS.accountDeletionCode]: { code: "123456" },
5260
+ [MESSAGE_KEYS.accountDeletionScheduled]: { deleteOn: "November 3, 2026" },
5261
+ [MESSAGE_KEYS.accountRestored]: {}
4807
5262
  };
4808
5263
 
4809
5264
  // src/middlewares/require-auth.ts