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