@fonderie/auth 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-DxsmQjXX.cjs';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, M as MESSAGE_KEYS, f as buildAuthAccountLimiter, g as buildAuthIpLimiter, w as withSession } from './session-DxsmQjXX.cjs';
5
+ import { I as IAuthConfig } from './session-CTSCI09F.cjs';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.cjs';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.cjs';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -54,10 +54,15 @@ declare const updateProfileSchema: z.ZodObject<{
54
54
  declare const updatePreferencesSchema: z.ZodObject<{
55
55
  locale: z.ZodOptional<z.ZodString>;
56
56
  timezone: z.ZodOptional<z.ZodString>;
57
- notifications: z.ZodOptional<z.ZodUnknown>;
58
- emailDigest: z.ZodOptional<z.ZodUnknown>;
59
- dateFormat: z.ZodOptional<z.ZodUnknown>;
60
- timeFormat: z.ZodOptional<z.ZodUnknown>;
57
+ notifications: z.ZodOptional<z.ZodObject<{
58
+ email: z.ZodOptional<z.ZodBoolean>;
59
+ inApp: z.ZodOptional<z.ZodBoolean>;
60
+ sms: z.ZodOptional<z.ZodBoolean>;
61
+ push: z.ZodOptional<z.ZodBoolean>;
62
+ }, z.core.$strip>>;
63
+ emailDigest: z.ZodOptional<z.ZodString>;
64
+ dateFormat: z.ZodOptional<z.ZodString>;
65
+ timeFormat: z.ZodOptional<z.ZodString>;
61
66
  }, z.core.$strip>;
62
67
  declare const updateEmailSchema: z.ZodObject<{
63
68
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
@@ -118,6 +123,18 @@ declare function importUser(store: IStoreAdapter, user: IImportUser): Promise<{
118
123
  id: string;
119
124
  }>;
120
125
 
126
+ interface IPurgeOptions {
127
+ olderThanDays: number;
128
+ }
129
+ declare function purgeSoftDeletedUsers(store: IStoreAdapter, { olderThanDays }: IPurgeOptions): Promise<number>;
130
+ interface IUserRetentionScheduleOptions extends IPurgeOptions {
131
+ intervalMs?: number;
132
+ onPurge?: (deleted: number) => void;
133
+ }
134
+ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentionScheduleOptions): {
135
+ stop: () => void;
136
+ };
137
+
121
138
  declare function validateAuthConfig(config: IAuthConfig): void;
122
139
 
123
- export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, schemas, validateAuthConfig };
140
+ export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-DxsmQjXX.js';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, M as MESSAGE_KEYS, f as buildAuthAccountLimiter, g as buildAuthIpLimiter, w as withSession } from './session-DxsmQjXX.js';
5
+ import { I as IAuthConfig } from './session-CTSCI09F.js';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CTSCI09F.js';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.js';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -54,10 +54,15 @@ declare const updateProfileSchema: z.ZodObject<{
54
54
  declare const updatePreferencesSchema: z.ZodObject<{
55
55
  locale: z.ZodOptional<z.ZodString>;
56
56
  timezone: z.ZodOptional<z.ZodString>;
57
- notifications: z.ZodOptional<z.ZodUnknown>;
58
- emailDigest: z.ZodOptional<z.ZodUnknown>;
59
- dateFormat: z.ZodOptional<z.ZodUnknown>;
60
- timeFormat: z.ZodOptional<z.ZodUnknown>;
57
+ notifications: z.ZodOptional<z.ZodObject<{
58
+ email: z.ZodOptional<z.ZodBoolean>;
59
+ inApp: z.ZodOptional<z.ZodBoolean>;
60
+ sms: z.ZodOptional<z.ZodBoolean>;
61
+ push: z.ZodOptional<z.ZodBoolean>;
62
+ }, z.core.$strip>>;
63
+ emailDigest: z.ZodOptional<z.ZodString>;
64
+ dateFormat: z.ZodOptional<z.ZodString>;
65
+ timeFormat: z.ZodOptional<z.ZodString>;
61
66
  }, z.core.$strip>;
62
67
  declare const updateEmailSchema: z.ZodObject<{
63
68
  email: z.ZodPipe<z.ZodString, z.ZodEmail>;
@@ -118,6 +123,18 @@ declare function importUser(store: IStoreAdapter, user: IImportUser): Promise<{
118
123
  id: string;
119
124
  }>;
120
125
 
126
+ interface IPurgeOptions {
127
+ olderThanDays: number;
128
+ }
129
+ declare function purgeSoftDeletedUsers(store: IStoreAdapter, { olderThanDays }: IPurgeOptions): Promise<number>;
130
+ interface IUserRetentionScheduleOptions extends IPurgeOptions {
131
+ intervalMs?: number;
132
+ onPurge?: (deleted: number) => void;
133
+ }
134
+ declare function startUserRetention(store: IStoreAdapter, options: IUserRetentionScheduleOptions): {
135
+ stop: () => void;
136
+ };
137
+
121
138
  declare function validateAuthConfig(config: IAuthConfig): void;
122
139
 
123
- export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, schemas, validateAuthConfig };
140
+ export { AuthModule, type ChangePasswordInput, IAuthConfig, type IImportUser, type IPurgeOptions, type IUserRetentionScheduleOptions, type LoginInput, type RegisterInput, type ResetPasswordInput, importUser, normalizeEmail, normalizeEmailSafe, purgeSoftDeletedUsers, schemas, startUserRetention, validateAuthConfig };
package/dist/index.js CHANGED
@@ -165,10 +165,18 @@ var updateProfileSchema = z.object({
165
165
  var updatePreferencesSchema = z.object({
166
166
  locale: z.string().max(35).optional(),
167
167
  timezone: z.string().max(64).optional(),
168
- notifications: z.unknown().optional(),
169
- emailDigest: z.unknown().optional(),
170
- dateFormat: z.unknown().optional(),
171
- timeFormat: z.unknown().optional()
168
+ // Typed at last — these four accepted `unknown`, letting null and
169
+ // arbitrary JSON into stored preferences that the user DTO then served
170
+ // against string-typed client fields.
171
+ notifications: z.object({
172
+ email: z.boolean().optional(),
173
+ inApp: z.boolean().optional(),
174
+ sms: z.boolean().optional(),
175
+ push: z.boolean().optional()
176
+ }).optional(),
177
+ emailDigest: z.string().max(20).optional(),
178
+ dateFormat: z.string().max(30).optional(),
179
+ timeFormat: z.string().max(30).optional()
172
180
  }).refine(
173
181
  (o) => Object.values(o).some((v) => v !== void 0),
174
182
  "Provide at least one preference field"
@@ -284,7 +292,7 @@ function verifyToken(token, config) {
284
292
  }
285
293
 
286
294
  // src/services/mfa.ts
287
- import { createHmac, randomBytes } from "crypto";
295
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
288
296
  var STEP = 30;
289
297
  var DRIFT = 1;
290
298
  var DIGITS = 6;
@@ -355,10 +363,16 @@ function generateTotpUri(email2, secret, issuer) {
355
363
  });
356
364
  return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email2)}?${params}`;
357
365
  }
366
+ function safeCodeEqual(a, b) {
367
+ const bufA = Buffer.from(a);
368
+ const bufB = Buffer.from(b);
369
+ if (bufA.length !== bufB.length) return false;
370
+ return timingSafeEqual(bufA, bufB);
371
+ }
358
372
  function verifyTotpToken(token, secret) {
359
373
  const t = timeCounter();
360
374
  for (let i = -DRIFT; i <= DRIFT; i++) {
361
- if (hotp(secret, t + i) === token) {
375
+ if (safeCodeEqual(hotp(secret, t + i), token)) {
362
376
  return true;
363
377
  }
364
378
  }
@@ -371,6 +385,41 @@ function generateBackupCodes(count = 8) {
371
385
  // src/controllers/mfa.controller.ts
372
386
  init_password();
373
387
 
388
+ // src/services/mfa-crypto.ts
389
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "crypto";
390
+ var PREFIX = "mfa.v1:";
391
+ function makeMfaCipher(keyHex) {
392
+ if (!keyHex) {
393
+ return { encrypt: (plain) => plain, decrypt: (stored) => stored };
394
+ }
395
+ const key = Buffer.from(keyHex, "hex");
396
+ if (key.length !== 32) {
397
+ throw new Error("[auth] mfaSecretKey must be 32 bytes (64 hex chars, e.g. `openssl rand -hex 32`)");
398
+ }
399
+ return {
400
+ encrypt(plain) {
401
+ const iv = randomBytes2(12);
402
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
403
+ const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
404
+ const tag = cipher.getAuthTag();
405
+ return `${PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
406
+ },
407
+ decrypt(stored) {
408
+ if (!stored.startsWith(PREFIX)) return stored;
409
+ const [ivHex, tagHex, dataHex] = stored.slice(PREFIX.length).split(":");
410
+ if (!ivHex || !tagHex || !dataHex) {
411
+ throw new Error("[auth] malformed mfa secret ciphertext");
412
+ }
413
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
414
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
415
+ return Buffer.concat([
416
+ decipher.update(Buffer.from(dataHex, "hex")),
417
+ decipher.final()
418
+ ]).toString("utf8");
419
+ }
420
+ };
421
+ }
422
+
374
423
  // src/dtos/user.ts
375
424
  import { stringOrEmpty, booleanOrFalse } from "@fonderie/core";
376
425
  var DEFAULT_PREFERENCES = {
@@ -381,8 +430,27 @@ var DEFAULT_PREFERENCES = {
381
430
  dateFormat: "MM/DD/YYYY",
382
431
  timeFormat: "hh:mm A"
383
432
  };
433
+ function cleanPreferences(prefs) {
434
+ const out = {};
435
+ if (typeof prefs.locale === "string") out.locale = prefs.locale;
436
+ if (typeof prefs.timezone === "string") out.timezone = prefs.timezone;
437
+ if (typeof prefs.emailDigest === "string") out.emailDigest = prefs.emailDigest;
438
+ if (typeof prefs.dateFormat === "string") out.dateFormat = prefs.dateFormat;
439
+ if (typeof prefs.timeFormat === "string") out.timeFormat = prefs.timeFormat;
440
+ return out;
441
+ }
442
+ function cleanNotifications(value) {
443
+ if (!value || typeof value !== "object") return {};
444
+ const out = {};
445
+ for (const key of ["email", "inApp", "sms", "push"]) {
446
+ const v = value[key];
447
+ if (typeof v === "boolean") out[key] = v;
448
+ }
449
+ return out;
450
+ }
384
451
  function toUserDTO(user, phoneVerified = false) {
385
452
  const prefs = user.preferences ?? {};
453
+ const cleaned = cleanPreferences(prefs);
386
454
  return {
387
455
  id: stringOrEmpty(user.id),
388
456
  email: stringOrEmpty(user.email),
@@ -394,9 +462,13 @@ function toUserDTO(user, phoneVerified = false) {
394
462
  lastLogin: user.lastLogin instanceof Date ? user.lastLogin.toISOString() : "",
395
463
  preferences: {
396
464
  ...DEFAULT_PREFERENCES,
397
- ...prefs,
398
- locale: user.locale || prefs.locale || "en-US",
399
- timezone: user.timezone || prefs.timezone || "UTC"
465
+ ...cleaned,
466
+ notifications: {
467
+ ...DEFAULT_PREFERENCES.notifications,
468
+ ...cleanNotifications(prefs.notifications)
469
+ },
470
+ locale: user.locale || cleaned.locale || "en-US",
471
+ timezone: user.timezone || cleaned.timezone || "UTC"
400
472
  },
401
473
  isEmailVerified: user.emailVerifiedAt !== null,
402
474
  isPhoneVerified: phoneVerified,
@@ -427,7 +499,6 @@ var USER_COLUMNS = `
427
499
  whitelist,
428
500
  ip_whitelist AS "ipWhitelist",
429
501
  mfa_enabled AS "mfaEnabled",
430
- mfa_secret AS "mfaSecret",
431
502
  email_verified_at AS "emailVerifiedAt",
432
503
  deleted_at AS "deletedAt",
433
504
  created_at AS "createdAt",
@@ -636,6 +707,25 @@ var SessionModel = class {
636
707
  async delete(token) {
637
708
  await this.store.query(`DELETE FROM fonderie_sessions WHERE token = $1`, [token]);
638
709
  }
710
+ // Revoke every session for a user (e.g. on password change). Access tokens
711
+ // bound to these sessions via the sid claim die on their next request.
712
+ async deleteByUser(userId) {
713
+ await this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);
714
+ }
715
+ // Session metadata for a user (no tokens) — for the data-export / SAR bundle.
716
+ async listByUser(userId) {
717
+ return this.store.query(
718
+ `SELECT id,
719
+ user_agent AS "userAgent",
720
+ ip_address AS "ipAddress",
721
+ created_at AS "createdAt",
722
+ expires_at AS "expiresAt"
723
+ FROM fonderie_sessions
724
+ WHERE user_id = $1
725
+ ORDER BY created_at DESC`,
726
+ [userId]
727
+ );
728
+ }
639
729
  async exists(token) {
640
730
  const rows = await this.store.query(
641
731
  `SELECT id FROM fonderie_sessions WHERE token = $1 AND expires_at > now()`,
@@ -693,6 +783,7 @@ function mfaController(store, config, issuer, bus) {
693
783
  const users = new UserModel(store);
694
784
  const sessions = new SessionModel(store);
695
785
  const backupCodes = new BackupCodeModel(store);
786
+ const mfaCipher = makeMfaCipher(config.mfaSecretKey);
696
787
  return {
697
788
  // ── 1. Setup ───────────────────────────────────────────────
698
789
  setup: async (ctx) => {
@@ -702,7 +793,7 @@ function mfaController(store, config, issuer, bus) {
702
793
  const codeHashes = await Promise.all(plainCodes.map((c) => hashPassword(c)));
703
794
  const qr = await QRCode.toDataURL(uri);
704
795
  await Promise.all([
705
- users.saveMfaPendingSecret(ctx.user.id, secret),
796
+ users.saveMfaPendingSecret(ctx.user.id, mfaCipher.encrypt(secret)),
706
797
  backupCodes.replace(ctx.user.id, codeHashes)
707
798
  ]);
708
799
  return setApiResponse2(
@@ -724,7 +815,8 @@ function mfaController(store, config, issuer, bus) {
724
815
  if (typeof token !== "string") {
725
816
  return setApiResponse2(HTTP2.UNPROCESSABLE, "INVALID_PARAMETER", "token is required");
726
817
  }
727
- const pendingSecret = await users.getMfaPendingSecret(ctx.user.id);
818
+ const pendingCipher = await users.getMfaPendingSecret(ctx.user.id);
819
+ const pendingSecret = pendingCipher ? mfaCipher.decrypt(pendingCipher) : null;
728
820
  if (pendingSecret) {
729
821
  if (!verifyTotpToken(token, pendingSecret)) {
730
822
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
@@ -771,10 +863,11 @@ function mfaController(store, config, issuer, bus) {
771
863
  "Use the mfaToken from the login response"
772
864
  );
773
865
  }
774
- const secret = await users.getMfaSecret(ctx.user.id);
775
- if (!secret) {
866
+ const storedSecret = await users.getMfaSecret(ctx.user.id);
867
+ if (!storedSecret) {
776
868
  return setApiResponse2(HTTP2.BAD_REQUEST, "MFA_NOT_CONFIGURED", "MFA not configured");
777
869
  }
870
+ const secret = mfaCipher.decrypt(storedSecret);
778
871
  if (!verifyTotpToken(token, secret)) {
779
872
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid MFA token");
780
873
  }
@@ -783,7 +876,8 @@ function mfaController(store, config, issuer, bus) {
783
876
  }
784
877
  }
785
878
  const { accessToken, refreshToken, sid } = issueTokenPair(ctx.user.id, config, {
786
- loginMethod: ctx.user.loginMethod
879
+ loginMethod: ctx.user.loginMethod,
880
+ phoneVerified: ctx.user.phoneVerified
787
881
  });
788
882
  await sessions.create(ctx.user.id, refreshToken, refreshTokenExpiry(refreshToken), sid);
789
883
  const fullUser = await users.findById(ctx.user.id);
@@ -796,7 +890,7 @@ function mfaController(store, config, issuer, bus) {
796
890
  explanation: "MFA verified successfully.",
797
891
  result: {
798
892
  tokens: { access: accessToken, refresh: refreshToken },
799
- user: toUserDTO(fullUser)
893
+ user: toUserDTO(fullUser, ctx.user.phoneVerified)
800
894
  }
801
895
  },
802
896
  {
@@ -815,7 +909,8 @@ function mfaController(store, config, issuer, bus) {
815
909
  if (!ctx.user.mfaEnabled) {
816
910
  return setApiResponse2(HTTP2.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
817
911
  }
818
- const secret = await users.getMfaSecret(ctx.user.id);
912
+ const storedSecret = await users.getMfaSecret(ctx.user.id);
913
+ const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
819
914
  if (!secret || !verifyTotpToken(token, secret)) {
820
915
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
821
916
  }
@@ -844,7 +939,8 @@ function mfaController(store, config, issuer, bus) {
844
939
  if (!user || !user.mfaEnabled) {
845
940
  return setApiResponse2(HTTP2.BAD_REQUEST, "MFA_NOT_ENABLED", "MFA is not enabled");
846
941
  }
847
- const secret = user.mfaSecret;
942
+ const storedSecret = await users.getMfaSecret(ctx.user.id);
943
+ const secret = storedSecret ? mfaCipher.decrypt(storedSecret) : null;
848
944
  if (!secret || !verifyTotpToken(token, secret)) {
849
945
  return setApiResponse2(HTTP2.UNAUTHORIZED, "INVALID_CODE", "Invalid TOTP code");
850
946
  }
@@ -1629,6 +1725,7 @@ function isValidPhone2(phone2) {
1629
1725
  }
1630
1726
  function userController(store, config, bus) {
1631
1727
  const users = new UserModel(store);
1728
+ const sessions = new SessionModel(store);
1632
1729
  const emailVerif = new EmailVerificationModel(store);
1633
1730
  const phoneVerif = new PhoneVerificationModel(store);
1634
1731
  return {
@@ -1803,6 +1900,7 @@ function userController(store, config, bus) {
1803
1900
  }
1804
1901
  const hash = await hashPassword2(newPassword);
1805
1902
  await users.updatePassword(ctx.user.id, hash);
1903
+ await sessions.deleteByUser(ctx.user.id);
1806
1904
  return setApiResponse4(HTTP4.OK, "PASSWORD_CHANGED", "Password updated successfully.");
1807
1905
  },
1808
1906
  deleteMe: async (ctx) => {
@@ -1822,6 +1920,42 @@ function userController(store, config, bus) {
1822
1920
  headers: cookieHeaders(clearedTokenCookies(config))
1823
1921
  }
1824
1922
  );
1923
+ },
1924
+ // Subject Access Request — the authenticated user's own data as a portable
1925
+ // JSON bundle. Only auth-owned data, and only safe fields: no password hash,
1926
+ // no MFA secret, no session tokens (session metadata only).
1927
+ exportMe: async (ctx) => {
1928
+ const user = await users.findById(ctx.user.id);
1929
+ if (!user) return setApiResponse4(HTTP4.NOT_FOUND, "NOT_FOUND", "User not found");
1930
+ const sessionRows = await sessions.listByUser(ctx.user.id);
1931
+ const bundle = {
1932
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
1933
+ profile: toUserDTO(user, ctx.user.phoneVerified),
1934
+ sessions: sessionRows.map((s) => ({
1935
+ id: s.id,
1936
+ userAgent: s.userAgent,
1937
+ ipAddress: s.ipAddress,
1938
+ createdAt: s.createdAt instanceof Date ? s.createdAt.toISOString() : s.createdAt,
1939
+ expiresAt: s.expiresAt instanceof Date ? s.expiresAt.toISOString() : s.expiresAt
1940
+ })),
1941
+ security: {
1942
+ mfaEnabled: user.mfaEnabled === true,
1943
+ emailVerified: user.emailVerifiedAt !== null
1944
+ }
1945
+ };
1946
+ const contributors = config.dataExportContributors ?? [];
1947
+ if (contributors.length > 0) {
1948
+ const modules = {};
1949
+ for (const c of contributors) {
1950
+ try {
1951
+ modules[c.name] = await c.collect(ctx.user.id);
1952
+ } catch {
1953
+ modules[c.name] = null;
1954
+ }
1955
+ }
1956
+ bundle["modules"] = modules;
1957
+ }
1958
+ return setApiResponse4(HTTP4.OK, "DATA_EXPORT", "Your account data.", bundle);
1825
1959
  }
1826
1960
  };
1827
1961
  }
@@ -1966,6 +2100,7 @@ function buildAuthRoutes(store, config, bus) {
1966
2100
  R("updatePhone", "PUT", "/users/phone", requireAuth, verifyGate, validate(updatePhoneSchema), user.updatePhone),
1967
2101
  R("changePassword", "PUT", "/users/password", requireAuth, validate(changePasswordSchema), user.changePassword),
1968
2102
  R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
2103
+ R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
1969
2104
  // MFA (email sessions only — requireVerified is always enforced here
1970
2105
  // because MFA is a security feature and email verification is meaningful)
1971
2106
  R("mfaSetup", "POST", "/auth/mfa/setup", requireAuth, requireEmailLogin, requireVerified, mfa.setup),
@@ -2007,10 +2142,40 @@ function collectAuthConfigProblems(config) {
2007
2142
  if (config.secureCookies === false) {
2008
2143
  problems.push({
2009
2144
  module: MODULE,
2010
- severity: "warning",
2145
+ severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
2011
2146
  message: "secureCookies is false \u2014 auth cookies may be sent over non-HTTPS connections in production"
2012
2147
  });
2013
2148
  }
2149
+ if (config.google) {
2150
+ const clientSecret = config.google.clientSecret ?? "";
2151
+ if (!clientSecret || !config.google.clientId || !config.google.redirectUri) {
2152
+ problems.push({
2153
+ module: MODULE,
2154
+ severity: "error",
2155
+ message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
2156
+ });
2157
+ } else if (PLACEHOLDER_SECRET.test(clientSecret)) {
2158
+ problems.push({
2159
+ module: MODULE,
2160
+ severity: "error",
2161
+ message: "google.clientSecret looks like a placeholder or dev-default value"
2162
+ });
2163
+ }
2164
+ }
2165
+ if (config.mfa && !config.mfaSecretKey) {
2166
+ problems.push({
2167
+ module: MODULE,
2168
+ severity: process.env["NODE_ENV"] === "production" ? "error" : "warning",
2169
+ message: "mfa is enabled without mfaSecretKey \u2014 TOTP secrets are stored plaintext at rest; set a 32-byte key (openssl rand -hex 32)"
2170
+ });
2171
+ }
2172
+ if (config.mfaSecretKey && !/^[0-9a-fA-F]{64}$/.test(config.mfaSecretKey)) {
2173
+ problems.push({
2174
+ module: MODULE,
2175
+ severity: "error",
2176
+ message: "mfaSecretKey must be 64 hex characters (32 bytes, e.g. `openssl rand -hex 32`)"
2177
+ });
2178
+ }
2014
2179
  return problems;
2015
2180
  }
2016
2181
  function validateAuthConfig(config) {
@@ -2131,6 +2296,41 @@ async function importUser(store, user) {
2131
2296
  if (!row) throw new Error("[auth] importUser: insert returned no row");
2132
2297
  return row;
2133
2298
  }
2299
+
2300
+ // src/services/retention.ts
2301
+ async function purgeSoftDeletedUsers(store, { olderThanDays }) {
2302
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
2303
+ throw new Error("[auth] purgeSoftDeletedUsers: olderThanDays must be a non-negative number");
2304
+ }
2305
+ const rows = await store.query(
2306
+ `DELETE FROM fonderie_users
2307
+ WHERE deleted_at IS NOT NULL
2308
+ AND deleted_at < now() - make_interval(days => $1)
2309
+ RETURNING id`,
2310
+ [olderThanDays]
2311
+ );
2312
+ return rows.length;
2313
+ }
2314
+ function startUserRetention(store, options) {
2315
+ const intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1e3;
2316
+ let stopped = false;
2317
+ const run = async () => {
2318
+ if (stopped) return;
2319
+ try {
2320
+ const deleted = await purgeSoftDeletedUsers(store, { olderThanDays: options.olderThanDays });
2321
+ options.onPurge?.(deleted);
2322
+ } catch (err) {
2323
+ console.error("[auth] scheduled user retention purge failed:", err);
2324
+ }
2325
+ };
2326
+ const timer = setInterval(run, intervalMs);
2327
+ if (typeof timer.unref === "function") timer.unref();
2328
+ void run();
2329
+ return { stop: () => {
2330
+ stopped = true;
2331
+ clearInterval(timer);
2332
+ } };
2333
+ }
2134
2334
  export {
2135
2335
  AUTH_CONFIG_KEYS,
2136
2336
  AuthModule,
@@ -2140,8 +2340,10 @@ export {
2140
2340
  importUser,
2141
2341
  normalizeEmail,
2142
2342
  normalizeEmailSafe,
2343
+ purgeSoftDeletedUsers,
2143
2344
  requireAuth2 as requireAuth,
2144
2345
  schemas_exports as schemas,
2346
+ startUserRetention,
2145
2347
  toUserDTO,
2146
2348
  validate,
2147
2349
  validateAuthConfig,