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