@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.js CHANGED
@@ -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
@@ -771,6 +788,14 @@ var UserModel = class {
771
788
  );
772
789
  return row ?? null;
773
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
+ }
774
799
  /** As findArchivedByEmail, for a phone number. */
775
800
  async findArchivedByPhone(phone2) {
776
801
  const [row] = await this.store.query(
@@ -1322,10 +1347,17 @@ var BackupCodeModel = class {
1322
1347
  );
1323
1348
  return rows.map((r) => ({ id: r.id, codeHash: r.code_hash }));
1324
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
+ */
1325
1355
  async consume(id) {
1326
- await this.store.query(`UPDATE fonderie_mfa_backup_codes SET used_at = now() WHERE id = $1`, [
1327
- id
1328
- ]);
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;
1329
1361
  }
1330
1362
  async deleteByUser(userId) {
1331
1363
  await this.store.query(`DELETE FROM fonderie_mfa_backup_codes WHERE user_id = $1`, [userId]);
@@ -1465,6 +1497,7 @@ function mfaController(store, config, issuer, bus) {
1465
1497
  },
1466
1498
  // ── 2. Verify (setup confirm · TOTP login · backup code login) ──
1467
1499
  verify: async (ctx) => {
1500
+ if (ctx.user.deletedAt) return setApiResponse2(HTTP2.UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized");
1468
1501
  const body = ctx.meta["body"];
1469
1502
  const token = body?.["token"];
1470
1503
  const isLoginCompletion = ctx.user.mfaPending === true;
@@ -1519,7 +1552,10 @@ function mfaController(store, config, issuer, bus) {
1519
1552
  if (isLoginCompletion) await failedMfa();
1520
1553
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid backup code");
1521
1554
  }
1522
- 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
+ }
1523
1559
  } else {
1524
1560
  if (!ctx.user.mfaPending) {
1525
1561
  return setApiResponse2(
@@ -1646,16 +1682,39 @@ init_password();
1646
1682
 
1647
1683
  // src/services/pending-deletion.ts
1648
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
1649
1703
  function deletionDate(deletedAt, config) {
1650
1704
  return new Date(deletedAt.getTime() + deletionGracePeriodDays(config) * 24 * 60 * 60 * 1e3);
1651
1705
  }
1652
- function pendingDeletionResponse(deletedAt, config) {
1653
- const deleteOn = deletionDate(deletedAt, config);
1706
+ function pendingDeletionResponse(user, loginMethod, config) {
1707
+ const deleteOn = deletionDate(user.deletedAt, config);
1654
1708
  return setApiResponse3(
1655
1709
  HTTP3.FORBIDDEN,
1656
1710
  "ACCOUNT_PENDING_DELETION",
1657
1711
  `This account is scheduled for deletion on ${deleteOn.toISOString().slice(0, 10)}.`,
1658
- { requestedAt: deletedAt.toISOString(), deleteOn: deleteOn.toISOString() }
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
+ }
1659
1718
  );
1660
1719
  }
1661
1720
  function archivedAddressResponse() {
@@ -1666,6 +1725,27 @@ function archivedAddressResponse() {
1666
1725
  );
1667
1726
  }
1668
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
+
1669
1749
  // src/models/password-reset.model.ts
1670
1750
  import { createHash as createHash3 } from "crypto";
1671
1751
  var hashSecret = (value) => createHash3("sha256").update(value).digest("hex");
@@ -2006,7 +2086,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2006
2086
  failureReason: valid2 ? "pending_deletion" : "bad_password",
2007
2087
  ...meta
2008
2088
  });
2009
- if (valid2) return pendingDeletionResponse(archived.deletedAt, config);
2089
+ if (valid2) return pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, "email", config);
2010
2090
  return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2011
2091
  }
2012
2092
  }
@@ -2084,7 +2164,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2084
2164
  }
2085
2165
  const phone2 = body?.["phone"];
2086
2166
  if (isValidPhone(phone2)) {
2087
- const user = await users.findByPhone(normalizePhone(phone2));
2167
+ const user = await users.findByPhone(normalizePhone(phone2)) ?? await users.findArchivedByPhone(normalizePhone(phone2));
2088
2168
  if (!user) {
2089
2169
  return setApiResponse4(HTTP4.UNAUTHORIZED, "INVALID_CREDENTIALS", "Invalid credentials");
2090
2170
  }
@@ -2120,6 +2200,65 @@ function authController(store, config, bus, locales = defineLocales()) {
2120
2200
  "Provide email + password or a valid phone number"
2121
2201
  );
2122
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
+ },
2123
2262
  logout: async (ctx) => {
2124
2263
  const sid = ctx.user?.sid;
2125
2264
  if (sid) {
@@ -2258,8 +2397,10 @@ function authController(store, config, bus, locales = defineLocales()) {
2258
2397
  );
2259
2398
  }
2260
2399
  let row;
2400
+ let presented;
2261
2401
  if (typeof rawToken === "string" && rawToken.trim().length >= 32) {
2262
- row = await passwordReset.findByToken(rawToken.trim());
2402
+ presented = rawToken.trim();
2403
+ row = await passwordReset.findByToken(presented);
2263
2404
  } else {
2264
2405
  if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
2265
2406
  return setApiResponse4(
@@ -2268,7 +2409,8 @@ function authController(store, config, bus, locales = defineLocales()) {
2268
2409
  "a 6-digit pin or a reset token is required"
2269
2410
  );
2270
2411
  }
2271
- row = await passwordReset.findByPin(raw.trim());
2412
+ presented = raw.trim();
2413
+ row = await passwordReset.findByPin(presented);
2272
2414
  }
2273
2415
  if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
2274
2416
  return setApiResponse4(
@@ -2279,17 +2421,22 @@ function authController(store, config, bus, locales = defineLocales()) {
2279
2421
  }
2280
2422
  const passwordHash = await hashPassword(password2);
2281
2423
  const [changed] = await store.query(
2282
- `WITH changed AS (
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 (
2283
2432
  UPDATE fonderie_users SET password_hash = $1, updated_at = now()
2284
- WHERE id = $2 AND deleted_at IS NULL
2433
+ WHERE id IN (SELECT user_id FROM spent) AND deleted_at IS NULL
2285
2434
  RETURNING id
2286
- ), spent AS (
2287
- DELETE FROM fonderie_password_resets WHERE user_id = $2
2288
2435
  ), revoked AS (
2289
2436
  DELETE FROM fonderie_sessions WHERE user_id IN (SELECT id FROM changed)
2290
2437
  )
2291
2438
  SELECT id FROM changed`,
2292
- [passwordHash, row.userId]
2439
+ [passwordHash, row.userId, hashSecret(presented)]
2293
2440
  );
2294
2441
  if (!changed) {
2295
2442
  return setApiResponse4(
@@ -2351,6 +2498,17 @@ function authController(store, config, bus, locales = defineLocales()) {
2351
2498
  );
2352
2499
  }
2353
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
+ }
2354
2512
  const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, configForClient(config, config.resolve?.(ctx), clientKindOf(ctx.request.headers)), {
2355
2513
  loginMethod: "phone",
2356
2514
  phoneVerified: true
@@ -2496,7 +2654,7 @@ function authController(store, config, bus, locales = defineLocales()) {
2496
2654
 
2497
2655
  // src/controllers/user.controller.ts
2498
2656
  import { randomInt as randomInt2 } from "crypto";
2499
- import { setApiResponse as setApiResponse5, HTTP as HTTP5, 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";
2500
2658
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT3 } from "@fonderie/events";
2501
2659
 
2502
2660
  // src/dtos/login-activity.ts
@@ -2534,7 +2692,64 @@ function toSessionDTO(row, currentSid) {
2534
2692
  };
2535
2693
  }
2536
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
+
2537
2750
  // src/controllers/user.controller.ts
2751
+ var DELETION_CODE_TTL_MS = 15 * 60 * 1e3;
2752
+ var DELETION_CODE_COOLDOWN_MS = 60 * 1e3;
2538
2753
  function normalizePhone2(phone2) {
2539
2754
  return phone2.trim().replace(/[\s()\-\.]/g, "");
2540
2755
  }
@@ -2547,6 +2762,55 @@ function userController(store, config, bus) {
2547
2762
  const loginEvents = new LoginEventModel(store, config.location);
2548
2763
  const emailVerif = new EmailVerificationModel(store);
2549
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
+ );
2550
2814
  return {
2551
2815
  me: async (ctx) => {
2552
2816
  const user = await users.findById(ctx.user.id);
@@ -2829,30 +3093,95 @@ function userController(store, config, bus) {
2829
3093
  }
2830
3094
  return setApiResponse5(HTTP5.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2831
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).
2832
3098
  deleteMe: async (ctx) => {
2833
- const userId = ctx.user.id;
2834
- await users.softDelete(userId);
2835
- await sessions.deleteByUser(userId);
2836
- await store.query(
2837
- `WITH resets AS (DELETE FROM fonderie_password_resets WHERE user_id = $1),
2838
- emails AS (DELETE FROM fonderie_email_verifications WHERE user_id = $1)
2839
- DELETE FROM fonderie_phone_verifications WHERE user_id = $1`,
2840
- [userId]
2841
- );
2842
- const reqId = ctx.meta["requestId"];
2843
- await background4(bus?.emit(
2844
- EVENT_KEYS.userDeleted,
2845
- { userId },
2846
- reqId !== void 0 ? { requestId: reqId } : void 0
2847
- ));
2848
- await background4(bus?.emit(EVENT_KEYS.sessionRevoked, { userId, sids: null, reason: "account-deleted" }));
2849
- return Response.json(
2850
- { reason: "ACCOUNT_DELETED", explanation: "Account successfully deleted." },
2851
- {
2852
- status: 200,
2853
- headers: cookieHeaders(clearedTokenCookies(config))
2854
- }
2855
- );
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);
2856
3185
  },
2857
3186
  // Subject Access Request — the authenticated user's own data as a portable
2858
3187
  // JSON bundle. Only auth-owned data, and only safe fields: no password hash,
@@ -2894,8 +3223,8 @@ function userController(store, config, bus) {
2894
3223
  }
2895
3224
 
2896
3225
  // src/controllers/oauth.controller.ts
2897
- import { randomBytes as randomBytes4, createHash as createHash4 } from "crypto";
2898
- import jwt2 from "jsonwebtoken";
3226
+ import { randomBytes as randomBytes4, createHash as createHash5 } from "crypto";
3227
+ import jwt3 from "jsonwebtoken";
2899
3228
  import { setApiResponse as setApiResponse6, HTTP as HTTP6, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2900
3229
  import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
2901
3230
 
@@ -2980,7 +3309,7 @@ var APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
2980
3309
  var APPLE_ISSUER = "https://appleid.apple.com";
2981
3310
  function mintAppleClientSecret(apple) {
2982
3311
  const now = Math.floor(Date.now() / 1e3);
2983
- return jwt2.sign(
3312
+ return jwt3.sign(
2984
3313
  { iss: apple.teamId, iat: now, exp: now + 300, aud: APPLE_ISSUER, sub: apple.clientId },
2985
3314
  apple.privateKey,
2986
3315
  { algorithm: "ES256", keyid: apple.keyId }
@@ -2989,14 +3318,14 @@ function mintAppleClientSecret(apple) {
2989
3318
  var appleKeys = createJwksSource(APPLE_KEYS_URL);
2990
3319
  async function verifyAppleIdToken(idToken, opts) {
2991
3320
  if (opts.audiences.length === 0) return null;
2992
- const decoded = jwt2.decode(idToken, { complete: true });
3321
+ const decoded = jwt3.decode(idToken, { complete: true });
2993
3322
  if (!decoded || typeof decoded === "string") return null;
2994
3323
  const kid = decoded.header.kid;
2995
3324
  if (!kid) return null;
2996
3325
  const key = await appleKeys.getKey(kid);
2997
3326
  if (!key) return null;
2998
3327
  try {
2999
- const payload = jwt2.verify(idToken, key, {
3328
+ const payload = jwt3.verify(idToken, key, {
3000
3329
  algorithms: ["RS256"],
3001
3330
  issuer: APPLE_ISSUER,
3002
3331
  // jsonwebtoken types `audience` as a non-empty tuple; runtime accepts a
@@ -3016,14 +3345,14 @@ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
3016
3345
  var googleKeys = createJwksSource(GOOGLE_KEYS_URL);
3017
3346
  async function verifyGoogleIdToken(idToken, opts) {
3018
3347
  if (opts.audiences.length === 0) return null;
3019
- const decoded = jwt2.decode(idToken, { complete: true });
3348
+ const decoded = jwt3.decode(idToken, { complete: true });
3020
3349
  if (!decoded || typeof decoded === "string") return null;
3021
3350
  const kid = decoded.header.kid;
3022
3351
  if (!kid) return null;
3023
3352
  const key = await googleKeys.getKey(kid);
3024
3353
  if (!key) return null;
3025
3354
  try {
3026
- const payload = jwt2.verify(idToken, key, {
3355
+ const payload = jwt3.verify(idToken, key, {
3027
3356
  algorithms: ["RS256"],
3028
3357
  issuer: [...GOOGLE_ISSUERS],
3029
3358
  audience: opts.audiences
@@ -3125,7 +3454,7 @@ function oauthController(store, config, bus) {
3125
3454
  failureReason: "pending_deletion",
3126
3455
  ...meta
3127
3456
  });
3128
- const res = pendingDeletionResponse(archived.deletedAt, config);
3457
+ const res = pendingDeletionResponse({ ...archived, deletedAt: archived.deletedAt }, provider, config);
3129
3458
  const headers = cookieHeaders(clearCookies);
3130
3459
  headers.set("content-type", res.headers.get("content-type") ?? "application/json");
3131
3460
  return new Response(res.body, { status: res.status, headers });
@@ -3395,7 +3724,7 @@ function oauthController(store, config, bus) {
3395
3724
  });
3396
3725
  return setApiResponse6(HTTP6.UNAUTHORIZED, "APPLE_AUTH_FAILED", "Invalid Apple identity token");
3397
3726
  }
3398
- const tokenHash = createHash4("sha256").update(identityToken).digest("hex");
3727
+ const tokenHash = createHash5("sha256").update(identityToken).digest("hex");
3399
3728
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3400
3729
  const firstUse = await consumedTokens.consumeOnce(tokenHash, new Date(expMs));
3401
3730
  if (!firstUse) {
@@ -3445,7 +3774,7 @@ function oauthController(store, config, bus) {
3445
3774
  });
3446
3775
  return setApiResponse6(HTTP6.UNAUTHORIZED, "GOOGLE_AUTH_FAILED", "Invalid Google ID token");
3447
3776
  }
3448
- const tokenHash = createHash4("sha256").update(idToken).digest("hex");
3777
+ const tokenHash = createHash5("sha256").update(idToken).digest("hex");
3449
3778
  const expMs = typeof claims.exp === "number" ? claims.exp * 1e3 : Date.now() + 5 * 60 * 1e3;
3450
3779
  if (!await consumedTokens.consumeOnce(tokenHash, new Date(expMs))) {
3451
3780
  await loginEvents.recordSafe({
@@ -3534,6 +3863,11 @@ function buildAuthRoutes(store, config, bus, locales) {
3534
3863
  // refuses when it would leave the account with no way to sign in.
3535
3864
  R("unlinkOauth", "DELETE", "/auth/oauth/:provider", requireAuth, user.unlinkOauth),
3536
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),
3537
3871
  R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
3538
3872
  // Security surfaces (Protected; the caller's own login history + sessions).
3539
3873
  // The literal /others route is registered before /:id so it isn't
@@ -3737,8 +4071,11 @@ function withSession(store, config) {
3737
4071
  if (payload.sid && !await sessions.aliveBySid(payload.sid)) {
3738
4072
  return next();
3739
4073
  }
3740
- const user = await users.findById(payload.sub);
3741
- 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) {
3742
4079
  return next();
3743
4080
  }
3744
4081
  Object.assign(ctx, {
@@ -3913,7 +4250,7 @@ var AuthModule = class {
3913
4250
  name = "@fonderie/auth";
3914
4251
  // Baked in at build time by tsup.base, so the operator's Modules page can
3915
4252
  // say what is actually deployed rather than 'not reported'.
3916
- version = "7.23.0";
4253
+ version = "7.24.0";
3917
4254
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3918
4255
  describeAdmin() {
3919
4256
  return { routes: describeAuthAdminRoutes(this.store, this.bus) };
@@ -4101,6 +4438,29 @@ inmediato y cambia tu contrase\xF1a \u2014 otra persona podr\xEDa iniciar sesi\x
4101
4438
  {{provider}} ya no se puede usar para iniciar sesi\xF3n. Tu correo electr\xF3nico y tu contrase\xF1a siguen funcionando.
4102
4439
 
4103
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.`
4104
4464
  }
4105
4465
  };
4106
4466
 
@@ -4261,6 +4621,29 @@ pourrait se connecter \xE0 votre place.`
4261
4621
  {{provider}} ne peut plus \xEAtre utilis\xE9 pour vous connecter. Votre adresse e-mail et votre mot de passe fonctionnent toujours.
4262
4622
 
4263
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.`
4264
4647
  }
4265
4648
  };
4266
4649
 
@@ -4415,6 +4798,29 @@ var ZH_HANS_TEMPLATES = {
4415
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
4416
4799
 
4417
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`
4418
4824
  }
4419
4825
  };
4420
4826
 
@@ -4569,6 +4975,29 @@ var ZH_HANT_TEMPLATES = {
4569
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
4570
4976
 
4571
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`
4572
5001
  }
4573
5002
  };
4574
5003
 
@@ -4727,6 +5156,29 @@ change your password \u2014 someone else may be able to sign in as you.`
4727
5156
  {{provider}} can no longer be used to sign in. Your email and password still work.
4728
5157
 
4729
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.`
4730
5182
  }
4731
5183
  };
4732
5184
  var DEFAULT_TEMPLATES = withTranslations(EN_TEMPLATES, {
@@ -4752,7 +5204,10 @@ var SAMPLE_PAYLOADS = {
4752
5204
  [MESSAGE_KEYS.mfaDisabled]: {},
4753
5205
  [MESSAGE_KEYS.mfaBackupCodesRegenerated]: {},
4754
5206
  [MESSAGE_KEYS.emailChanged]: { newEmail: "new@example.com" },
4755
- [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]: {}
4756
5211
  };
4757
5212
 
4758
5213
  // src/middlewares/require-auth.ts