@fonderie/auth 7.4.0 → 7.6.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 CHANGED
@@ -150,6 +150,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
150
150
  | POST | `/auth/mfa/disable` | `requireAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.disable` |
151
151
  | POST | `/auth/mfa/setup` | `requireAuth → requireEmailLogin → requireVerified → mfa.setup` |
152
152
  | POST | `/auth/mfa/verify` | `ipLimit('mfaVerify') → requireAnyAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.verify` |
153
+ | DELETE | `/auth/oauth/:provider` | `requireAuth → user.unlinkOauth` |
153
154
  | GET | `/auth/providers` | `async () => setApiResponse(HTTP.OK, 'AUTH_PROVIDERS', 'Sign-in methods available here', { providers: [...config.providers], })` |
154
155
  | POST | `/auth/refresh` | `validate(refreshSchema) → auth.refresh` |
155
156
  | POST | `/auth/register` | `ipLimit('register') → validate(registerSchema) → auth.register` |
@@ -28,6 +28,7 @@ interface IUser {
28
28
  mfaEnabled: boolean;
29
29
  passwordHash: string | null;
30
30
  emailVerifiedAt: Date | null;
31
+ provider: string | null;
31
32
  }
32
33
 
33
34
  interface ISession {
@@ -99,11 +100,11 @@ interface IDataExportContributor {
99
100
 
100
101
  const AUTH_CONFIG_KEYS: { sessionDuration: string; verificationCooldown: string; mfa: string; requireVerification: string; }
101
102
 
102
- const MESSAGE_KEYS: { readonly emailRegistration: "email-registration"; readonly emailVerification: "email-verification"; readonly passwordReset: "password-reset"; readonly phoneOtp: "phone-otp"; readonly mfaEnabled: "mfa-enabled"; readonly mfaDisabled: "mfa-disabled"; readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated"; readonly emailChanged: "email-changed"; readonly phoneChanged: "phone-changed"; }
103
+ const MESSAGE_KEYS: { readonly emailRegistration: "email-registration"; readonly emailVerification: "email-verification"; readonly passwordReset: "password-reset"; readonly phoneOtp: "phone-otp"; readonly mfaEnabled: "mfa-enabled"; readonly mfaDisabled: "mfa-disabled"; readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated"; readonly emailChanged: "email-changed"; readonly phoneChanged: "phone-changed"; readonly passwordRevoked: "password-revoked"; readonly oauthRegistration: "oauth-registration"; readonly oauthLinked: "oauth-linked"; readonly oauthUnlinked: "oauth-unlinked"; }
103
104
 
104
105
  type AuthMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];
105
106
 
106
- const DEFAULT_TEMPLATES: { "email-registration": { subject: string; html: string; text: string; }; "email-verification": { subject: string; html: string; text: string; }; "password-reset": { subject: string; html: string; text: string; }; "phone-otp": { text: string; }; "mfa-enabled": { subject: string; html: string; text: string; }; "mfa-disabled": { subject: string; html: string; text: string; }; "mfa-backup-codes-regenerated": { subject: string; html: string; text: string; }; "email-changed": { subject: string; html: string; text: string; }; "phone-changed": { subject: string; html: string; text: string; }; }
107
+ const DEFAULT_TEMPLATES: { "email-registration": { subject: string; html: string; text: string; }; "email-verification": { subject: string; html: string; text: string; }; "password-reset": { subject: string; html: string; text: string; }; "phone-otp": { text: string; }; "mfa-enabled": { subject: string; html: string; text: string; }; "mfa-disabled": { subject: string; html: string; text: string; }; "mfa-backup-codes-regenerated": { subject: string; html: string; text: string; }; "email-changed": { subject: string; html: string; text: string; }; "phone-changed": { subject: string; html: string; text: string; }; "password-revoked": { subject: string; html: string; text: string; }; "oauth-registration": { subject: string; html: string; text: string; }; "oauth-linked": { subject: string; html: string; text: string; }; "oauth-unlinked": { subject: string; html: string; text: string; }; }
107
108
 
108
109
  interface IUserDTO {
109
110
  id: string;
@@ -118,6 +119,8 @@ interface IUserDTO {
118
119
  isEmailVerified: boolean;
119
120
  isPhoneVerified: boolean;
120
121
  mfaEnabled: boolean;
122
+ provider: string;
123
+ hasPassword: boolean;
121
124
  suspended: boolean;
122
125
  whitelist: boolean;
123
126
  ipWhitelist: string[];
@@ -75,6 +75,8 @@ function toUserDTO(user, phoneVerified = false) {
75
75
  isEmailVerified: user.emailVerifiedAt !== null,
76
76
  isPhoneVerified: phoneVerified,
77
77
  mfaEnabled: (0, import_core.booleanOrFalse)(user.mfaEnabled),
78
+ provider: (0, import_core.stringOrEmpty)(user.provider),
79
+ hasPassword: user.passwordHash !== null && user.passwordHash !== void 0,
78
80
  suspended: (0, import_core.booleanOrFalse)(user.suspended),
79
81
  whitelist: (0, import_core.booleanOrFalse)(user.whitelist),
80
82
  ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAA2D;AAwB3D,IAAM,sBAAwC;AAAA,EAC7C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe,EAAE,OAAO,MAAM,OAAO,MAAM,KAAK,OAAO,MAAM,MAAM;AAAA,EACnE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,YAAY;AACb;AAMA,SAAS,iBAAiB,OAAoD;AAC7E,QAAM,MAAiC,CAAC;AACxC,MAAI,OAAO,MAAM,WAAW,SAAU,KAAI,SAAS,MAAM;AACzD,MAAI,OAAO,MAAM,aAAa,SAAU,KAAI,WAAW,MAAM;AAC7D,MAAI,OAAO,MAAM,gBAAgB,SAAU,KAAI,cAAc,MAAM;AACnE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,SAAO;AACR;AAEA,SAAS,mBAAmB,OAA4D;AACvF,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAAkD,CAAC;AACzD,aAAW,OAAO,CAAC,SAAS,SAAS,OAAO,MAAM,GAAY;AAC7D,UAAM,IAAK,MAAkC,GAAG;AAChD,QAAI,OAAO,MAAM,UAAW,KAAI,GAAG,IAAI;AAAA,EACxC;AACA,SAAO;AACR;AAEO,SAAS,UAAU,MAAa,gBAAgB,OAAiB;AACvE,QAAM,QAAQ,KAAK,eAAgB,CAAC;AACpC,QAAM,UAAU,iBAAiB,KAAK;AACtC,SAAO;AAAA,IACN,QAAI,2BAAc,KAAK,EAAE;AAAA,IACzB,WAAO,2BAAc,KAAK,KAAK;AAAA,IAC/B,eAAW,2BAAc,KAAK,SAAS;AAAA,IACvC,cAAU,2BAAc,KAAK,QAAQ;AAAA,IACrC,WAAO,2BAAc,KAAK,KAAK;AAAA,IAC/B,qBAAiB,2BAAc,KAAK,eAAe;AAAA,IACnD,UAAU,OAAO,KAAK,aAAa,YAAY,KAAK,WAAW;AAAA,IAC/D,eAAW,yBAAY,KAAK,SAAS;AAAA,IACrC,aAAa;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,MACH,eAAe;AAAA,QACd,GAAG,oBAAoB;AAAA,QACvB,GAAG,mBAAmB,MAAM,aAAa;AAAA,MAC1C;AAAA,MACA,QAAQ,KAAK,UAAU,QAAQ,UAAU;AAAA,MACzC,UAAU,KAAK,YAAY,QAAQ,YAAY;AAAA,IAChD;AAAA,IACA,iBAAiB,KAAK,oBAAoB;AAAA,IAC1C,iBAAiB;AAAA,IACjB,gBAAY,4BAAe,KAAK,UAAU;AAAA,IAC1C,eAAW,4BAAe,KAAK,SAAS;AAAA,IACxC,eAAW,4BAAe,KAAK,SAAS;AAAA,IACxC,aAAa,MAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,cAAc,CAAC;AAAA,IACnE,eAAW,yBAAY,KAAK,SAAS;AAAA,IACrC,eAAW,yBAAY,KAAK,SAAS;AAAA,EACtC;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\t// OAuth provider linked to this account ('google', 'apple'), or '' if none.\n\tprovider: string;\n\t// Whether a password is set. Together with `provider` this is everything a\n\t// settings screen needs to render sign-in methods AND to know that\n\t// disconnecting the provider is possible — an account with no password has\n\t// no other credential, so unlinking is refused (409 PASSWORD_REQUIRED).\n\t// Exposed as a boolean precisely so the hash never can be.\n\thasPassword: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tprovider: stringOrEmpty(user.provider),\n\t\thasPassword: user.passwordHash !== null && user.passwordHash !== undefined,\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAA2D;AAgC3D,IAAM,sBAAwC;AAAA,EAC7C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe,EAAE,OAAO,MAAM,OAAO,MAAM,KAAK,OAAO,MAAM,MAAM;AAAA,EACnE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,YAAY;AACb;AAMA,SAAS,iBAAiB,OAAoD;AAC7E,QAAM,MAAiC,CAAC;AACxC,MAAI,OAAO,MAAM,WAAW,SAAU,KAAI,SAAS,MAAM;AACzD,MAAI,OAAO,MAAM,aAAa,SAAU,KAAI,WAAW,MAAM;AAC7D,MAAI,OAAO,MAAM,gBAAgB,SAAU,KAAI,cAAc,MAAM;AACnE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,SAAO;AACR;AAEA,SAAS,mBAAmB,OAA4D;AACvF,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAAkD,CAAC;AACzD,aAAW,OAAO,CAAC,SAAS,SAAS,OAAO,MAAM,GAAY;AAC7D,UAAM,IAAK,MAAkC,GAAG;AAChD,QAAI,OAAO,MAAM,UAAW,KAAI,GAAG,IAAI;AAAA,EACxC;AACA,SAAO;AACR;AAEO,SAAS,UAAU,MAAa,gBAAgB,OAAiB;AACvE,QAAM,QAAQ,KAAK,eAAgB,CAAC;AACpC,QAAM,UAAU,iBAAiB,KAAK;AACtC,SAAO;AAAA,IACN,QAAI,2BAAc,KAAK,EAAE;AAAA,IACzB,WAAO,2BAAc,KAAK,KAAK;AAAA,IAC/B,eAAW,2BAAc,KAAK,SAAS;AAAA,IACvC,cAAU,2BAAc,KAAK,QAAQ;AAAA,IACrC,WAAO,2BAAc,KAAK,KAAK;AAAA,IAC/B,qBAAiB,2BAAc,KAAK,eAAe;AAAA,IACnD,UAAU,OAAO,KAAK,aAAa,YAAY,KAAK,WAAW;AAAA,IAC/D,eAAW,yBAAY,KAAK,SAAS;AAAA,IACrC,aAAa;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,MACH,eAAe;AAAA,QACd,GAAG,oBAAoB;AAAA,QACvB,GAAG,mBAAmB,MAAM,aAAa;AAAA,MAC1C;AAAA,MACA,QAAQ,KAAK,UAAU,QAAQ,UAAU;AAAA,MACzC,UAAU,KAAK,YAAY,QAAQ,YAAY;AAAA,IAChD;AAAA,IACA,iBAAiB,KAAK,oBAAoB;AAAA,IAC1C,iBAAiB;AAAA,IACjB,gBAAY,4BAAe,KAAK,UAAU;AAAA,IAC1C,cAAU,2BAAc,KAAK,QAAQ;AAAA,IACrC,aAAa,KAAK,iBAAiB,QAAQ,KAAK,iBAAiB;AAAA,IACjE,eAAW,4BAAe,KAAK,SAAS;AAAA,IACxC,eAAW,4BAAe,KAAK,SAAS;AAAA,IACxC,aAAa,MAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,cAAc,CAAC;AAAA,IACnE,eAAW,yBAAY,KAAK,SAAS;AAAA,IACrC,eAAW,yBAAY,KAAK,SAAS;AAAA,EACtC;AACD;","names":[]}
@@ -13,6 +13,8 @@ interface IUserDTO {
13
13
  isEmailVerified: boolean;
14
14
  isPhoneVerified: boolean;
15
15
  mfaEnabled: boolean;
16
+ provider: string;
17
+ hasPassword: boolean;
16
18
  suspended: boolean;
17
19
  whitelist: boolean;
18
20
  ipWhitelist: string[];
@@ -13,6 +13,8 @@ interface IUserDTO {
13
13
  isEmailVerified: boolean;
14
14
  isPhoneVerified: boolean;
15
15
  mfaEnabled: boolean;
16
+ provider: string;
17
+ hasPassword: boolean;
16
18
  suspended: boolean;
17
19
  whitelist: boolean;
18
20
  ipWhitelist: string[];
package/dist/dtos/user.js CHANGED
@@ -51,6 +51,8 @@ function toUserDTO(user, phoneVerified = false) {
51
51
  isEmailVerified: user.emailVerifiedAt !== null,
52
52
  isPhoneVerified: phoneVerified,
53
53
  mfaEnabled: booleanOrFalse(user.mfaEnabled),
54
+ provider: stringOrEmpty(user.provider),
55
+ hasPassword: user.passwordHash !== null && user.passwordHash !== void 0,
54
56
  suspended: booleanOrFalse(user.suspended),
55
57
  whitelist: booleanOrFalse(user.whitelist),
56
58
  ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";AAAA,SAAS,eAAe,gBAAgB,mBAAmB;AAwB3D,IAAM,sBAAwC;AAAA,EAC7C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe,EAAE,OAAO,MAAM,OAAO,MAAM,KAAK,OAAO,MAAM,MAAM;AAAA,EACnE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,YAAY;AACb;AAMA,SAAS,iBAAiB,OAAoD;AAC7E,QAAM,MAAiC,CAAC;AACxC,MAAI,OAAO,MAAM,WAAW,SAAU,KAAI,SAAS,MAAM;AACzD,MAAI,OAAO,MAAM,aAAa,SAAU,KAAI,WAAW,MAAM;AAC7D,MAAI,OAAO,MAAM,gBAAgB,SAAU,KAAI,cAAc,MAAM;AACnE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,SAAO;AACR;AAEA,SAAS,mBAAmB,OAA4D;AACvF,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAAkD,CAAC;AACzD,aAAW,OAAO,CAAC,SAAS,SAAS,OAAO,MAAM,GAAY;AAC7D,UAAM,IAAK,MAAkC,GAAG;AAChD,QAAI,OAAO,MAAM,UAAW,KAAI,GAAG,IAAI;AAAA,EACxC;AACA,SAAO;AACR;AAEO,SAAS,UAAU,MAAa,gBAAgB,OAAiB;AACvE,QAAM,QAAQ,KAAK,eAAgB,CAAC;AACpC,QAAM,UAAU,iBAAiB,KAAK;AACtC,SAAO;AAAA,IACN,IAAI,cAAc,KAAK,EAAE;AAAA,IACzB,OAAO,cAAc,KAAK,KAAK;AAAA,IAC/B,WAAW,cAAc,KAAK,SAAS;AAAA,IACvC,UAAU,cAAc,KAAK,QAAQ;AAAA,IACrC,OAAO,cAAc,KAAK,KAAK;AAAA,IAC/B,iBAAiB,cAAc,KAAK,eAAe;AAAA,IACnD,UAAU,OAAO,KAAK,aAAa,YAAY,KAAK,WAAW;AAAA,IAC/D,WAAW,YAAY,KAAK,SAAS;AAAA,IACrC,aAAa;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,MACH,eAAe;AAAA,QACd,GAAG,oBAAoB;AAAA,QACvB,GAAG,mBAAmB,MAAM,aAAa;AAAA,MAC1C;AAAA,MACA,QAAQ,KAAK,UAAU,QAAQ,UAAU;AAAA,MACzC,UAAU,KAAK,YAAY,QAAQ,YAAY;AAAA,IAChD;AAAA,IACA,iBAAiB,KAAK,oBAAoB;AAAA,IAC1C,iBAAiB;AAAA,IACjB,YAAY,eAAe,KAAK,UAAU;AAAA,IAC1C,WAAW,eAAe,KAAK,SAAS;AAAA,IACxC,WAAW,eAAe,KAAK,SAAS;AAAA,IACxC,aAAa,MAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,cAAc,CAAC;AAAA,IACnE,WAAW,YAAY,KAAK,SAAS;AAAA,IACrC,WAAW,YAAY,KAAK,SAAS;AAAA,EACtC;AACD;","names":[]}
1
+ {"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\t// OAuth provider linked to this account ('google', 'apple'), or '' if none.\n\tprovider: string;\n\t// Whether a password is set. Together with `provider` this is everything a\n\t// settings screen needs to render sign-in methods AND to know that\n\t// disconnecting the provider is possible — an account with no password has\n\t// no other credential, so unlinking is refused (409 PASSWORD_REQUIRED).\n\t// Exposed as a boolean precisely so the hash never can be.\n\thasPassword: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tprovider: stringOrEmpty(user.provider),\n\t\thasPassword: user.passwordHash !== null && user.passwordHash !== undefined,\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";AAAA,SAAS,eAAe,gBAAgB,mBAAmB;AAgC3D,IAAM,sBAAwC;AAAA,EAC7C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe,EAAE,OAAO,MAAM,OAAO,MAAM,KAAK,OAAO,MAAM,MAAM;AAAA,EACnE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,YAAY;AACb;AAMA,SAAS,iBAAiB,OAAoD;AAC7E,QAAM,MAAiC,CAAC;AACxC,MAAI,OAAO,MAAM,WAAW,SAAU,KAAI,SAAS,MAAM;AACzD,MAAI,OAAO,MAAM,aAAa,SAAU,KAAI,WAAW,MAAM;AAC7D,MAAI,OAAO,MAAM,gBAAgB,SAAU,KAAI,cAAc,MAAM;AACnE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,SAAO;AACR;AAEA,SAAS,mBAAmB,OAA4D;AACvF,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAAkD,CAAC;AACzD,aAAW,OAAO,CAAC,SAAS,SAAS,OAAO,MAAM,GAAY;AAC7D,UAAM,IAAK,MAAkC,GAAG;AAChD,QAAI,OAAO,MAAM,UAAW,KAAI,GAAG,IAAI;AAAA,EACxC;AACA,SAAO;AACR;AAEO,SAAS,UAAU,MAAa,gBAAgB,OAAiB;AACvE,QAAM,QAAQ,KAAK,eAAgB,CAAC;AACpC,QAAM,UAAU,iBAAiB,KAAK;AACtC,SAAO;AAAA,IACN,IAAI,cAAc,KAAK,EAAE;AAAA,IACzB,OAAO,cAAc,KAAK,KAAK;AAAA,IAC/B,WAAW,cAAc,KAAK,SAAS;AAAA,IACvC,UAAU,cAAc,KAAK,QAAQ;AAAA,IACrC,OAAO,cAAc,KAAK,KAAK;AAAA,IAC/B,iBAAiB,cAAc,KAAK,eAAe;AAAA,IACnD,UAAU,OAAO,KAAK,aAAa,YAAY,KAAK,WAAW;AAAA,IAC/D,WAAW,YAAY,KAAK,SAAS;AAAA,IACrC,aAAa;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,MACH,eAAe;AAAA,QACd,GAAG,oBAAoB;AAAA,QACvB,GAAG,mBAAmB,MAAM,aAAa;AAAA,MAC1C;AAAA,MACA,QAAQ,KAAK,UAAU,QAAQ,UAAU;AAAA,MACzC,UAAU,KAAK,YAAY,QAAQ,YAAY;AAAA,IAChD;AAAA,IACA,iBAAiB,KAAK,oBAAoB;AAAA,IAC1C,iBAAiB;AAAA,IACjB,YAAY,eAAe,KAAK,UAAU;AAAA,IAC1C,UAAU,cAAc,KAAK,QAAQ;AAAA,IACrC,aAAa,KAAK,iBAAiB,QAAQ,KAAK,iBAAiB;AAAA,IACjE,WAAW,eAAe,KAAK,SAAS;AAAA,IACxC,WAAW,eAAe,KAAK,SAAS;AAAA,IACxC,aAAa,MAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,cAAc,CAAC;AAAA,IACnE,WAAW,YAAY,KAAK,SAAS;AAAA,IACrC,WAAW,YAAY,KAAK,SAAS;AAAA,EACtC;AACD;","names":[]}
package/dist/index.cjs CHANGED
@@ -296,7 +296,21 @@ var MESSAGE_KEYS = {
296
296
  mfaDisabled: "mfa-disabled",
297
297
  mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated",
298
298
  emailChanged: "email-changed",
299
- phoneChanged: "phone-changed"
299
+ phoneChanged: "phone-changed",
300
+ // Changing HOW an account can be signed into is a security event for the
301
+ // account's owner, who may not be the person doing it. Both directions are
302
+ // notified for the same reason password changes are.
303
+ // A password was REVOKED because the account it sat on had never been
304
+ // verified and an OAuth identity proved ownership of the mailbox. The
305
+ // recipient is now known to own that address, so this reaches the right
306
+ // person — and it is the only way they learn a password stopped working.
307
+ passwordRevoked: "password-revoked",
308
+ // Welcome for an account CREATED by an OAuth sign-in. Distinct from
309
+ // emailRegistration, which carries a verification PIN — an OAuth signup
310
+ // arrives already verified by the provider and has nothing to confirm.
311
+ oauthRegistration: "oauth-registration",
312
+ oauthLinked: "oauth-linked",
313
+ oauthUnlinked: "oauth-unlinked"
300
314
  };
301
315
  var EVENT_KEYS = {
302
316
  userRegistered: "fonderie.user.registered",
@@ -528,6 +542,8 @@ function toUserDTO(user, phoneVerified = false) {
528
542
  isEmailVerified: user.emailVerifiedAt !== null,
529
543
  isPhoneVerified: phoneVerified,
530
544
  mfaEnabled: (0, import_core3.booleanOrFalse)(user.mfaEnabled),
545
+ provider: (0, import_core3.stringOrEmpty)(user.provider),
546
+ hasPassword: user.passwordHash !== null && user.passwordHash !== void 0,
531
547
  suspended: (0, import_core3.booleanOrFalse)(user.suspended),
532
548
  whitelist: (0, import_core3.booleanOrFalse)(user.whitelist),
533
549
  ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
@@ -555,6 +571,7 @@ var USER_COLUMNS = `
555
571
  ip_whitelist AS "ipWhitelist",
556
572
  mfa_enabled AS "mfaEnabled",
557
573
  email_verified_at AS "emailVerifiedAt",
574
+ provider,
558
575
  deleted_at AS "deletedAt",
559
576
  created_at AS "createdAt",
560
577
  updated_at AS "updatedAt"
@@ -632,6 +649,31 @@ var UserModel = class {
632
649
  );
633
650
  return row ?? null;
634
651
  }
652
+ /**
653
+ * Clear the OAuth provider, but ONLY when the account can still be signed
654
+ * into afterwards.
655
+ *
656
+ * An account created through Google has no password. Unlinking it without
657
+ * checking would not be "removing a sign-in method" — it would be locking
658
+ * the owner out permanently, which is account deletion wearing a friendlier
659
+ * label. The guard lives in the WHERE clause so the check and the write are
660
+ * one atomic statement: a password cannot be removed between them.
661
+ *
662
+ * Returns false when the account has no password (nothing was changed) so
663
+ * the caller can say why rather than reporting a silent success.
664
+ */
665
+ async clearProvider(id) {
666
+ const rows = await this.store.query(
667
+ `UPDATE fonderie_users
668
+ SET provider = NULL, provider_id = NULL
669
+ WHERE id = $1
670
+ AND password_hash IS NOT NULL
671
+ AND provider IS NOT NULL
672
+ RETURNING id`,
673
+ [id]
674
+ );
675
+ return rows.length > 0;
676
+ }
635
677
  async updatePassword(id, passwordHash) {
636
678
  await this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
637
679
  passwordHash,
@@ -732,14 +774,71 @@ var UserModel = class {
732
774
  );
733
775
  return row?.mfa_secret ?? null;
734
776
  }
777
+ /**
778
+ * Link an OAuth identity to the account owning `email`, creating it if there
779
+ * is none.
780
+ *
781
+ * Returns what HAPPENED, not just who it happened to. Three outcomes hide
782
+ * behind one upsert, and callers must tell them apart:
783
+ *
784
+ * • `inserted` — a brand-new account. Whoever provisions on signup (the
785
+ * personal workspace, a wallet, a welcome email) must run, exactly as
786
+ * it does for a password registration. This is the case that was
787
+ * previously invisible: the upsert returned an id and looked identical
788
+ * to a returning user signing in, so OAuth signups silently skipped
789
+ * every signup side effect.
790
+ * • `previousProvider` differs — an existing account gained, or switched,
791
+ * a way to sign in. A security event for the owner.
792
+ * • `previousProvider` is the same — an ordinary login. Nothing to do, and
793
+ * notifying here would email the user on every single sign-in.
794
+ *
795
+ * The prior state is read in a CTE of the SAME statement, so it sees the
796
+ * snapshot from before the insert. Reading it in a separate query would
797
+ * race two concurrent logins into both seeing "new".
798
+ *
799
+ * SECURITY — linking is BY EMAIL, so this merges into whatever row already
800
+ * holds the address. If that row was never verified, its password was never
801
+ * proven to belong to the mailbox; anyone can register an address they do
802
+ * not own. The provider HAS proven ownership. So an unverified password is
803
+ * revoked at the moment of linking: without that, linking sets
804
+ * email_verified_at and the earlier registrant is left holding a working
805
+ * password on an account now treated as verified.
806
+ *
807
+ * The legitimate user loses nothing but a password reset — they own the
808
+ * mailbox. Someone who does not own it cannot receive that mail.
809
+ */
735
810
  async upsertByProvider(email2, provider, providerId) {
736
811
  const [row] = await this.store.query(
737
- `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
738
- VALUES ($1, now(), $2, $3)
739
- ON CONFLICT (email) DO UPDATE
740
- SET provider = $2, provider_id = $3,
741
- email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
742
- RETURNING id`,
812
+ `WITH prior AS (
813
+ SELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password
814
+ FROM fonderie_users WHERE email = $1
815
+ ), upserted AS (
816
+ INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
817
+ VALUES ($1, now(), $2, $3)
818
+ ON CONFLICT (email) DO UPDATE
819
+ SET provider = $2, provider_id = $3,
820
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),
821
+ -- A password on an UNVERIFIED account is a claim, not a
822
+ -- credential: nobody ever proved that mailbox belongs to
823
+ -- whoever set it. The provider has now proven it belongs to
824
+ -- the person signing in. Drop the unproven claim rather than
825
+ -- promote it \u2014 otherwise linking marks the account verified
826
+ -- and silently hands the earlier registrant a working
827
+ -- password on a now-trusted account.
828
+ password_hash = CASE
829
+ WHEN fonderie_users.email_verified_at IS NULL THEN NULL
830
+ ELSE fonderie_users.password_hash
831
+ END
832
+ RETURNING id
833
+ )
834
+ SELECT upserted.id,
835
+ NOT EXISTS (SELECT 1 FROM prior) AS inserted,
836
+ (SELECT provider FROM prior) AS "previousProvider",
837
+ -- True only when a password was actually revoked, so the
838
+ -- caller can tell the owner their sign-in method changed.
839
+ COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)
840
+ AS "clearedUnverifiedPassword"
841
+ FROM upserted`,
743
842
  [email2, provider, providerId]
744
843
  );
745
844
  return row ?? null;
@@ -2232,6 +2331,50 @@ function userController(store, config, bus) {
2232
2331
  await sessions.deleteByUser(ctx.user.id);
2233
2332
  return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2234
2333
  },
2334
+ /**
2335
+ * Remove an OAuth provider from the account.
2336
+ *
2337
+ * Refuses when the account has no password, because unlinking would
2338
+ * then leave NO way to sign in — that is account deletion, not a
2339
+ * settings toggle, and a user clicking "disconnect Google" is not
2340
+ * asking for it. The check is inside the UPDATE's WHERE clause, so a
2341
+ * password cannot disappear between the check and the write.
2342
+ *
2343
+ * Notifies the account's email either way: changing how an account can
2344
+ * be signed into is a security event for its owner, who is not
2345
+ * necessarily the person doing it.
2346
+ */
2347
+ unlinkOauth: async (ctx) => {
2348
+ const provider = String(ctx.meta["params"]?.["provider"] ?? "");
2349
+ const current = await users.findById(ctx.user.id);
2350
+ if (!current?.provider) {
2351
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2352
+ }
2353
+ if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
2354
+ return (0, import_core8.setApiResponse)(
2355
+ import_core8.HTTP.NOT_FOUND,
2356
+ "NOT_LINKED",
2357
+ `This account is not linked to ${provider}.`
2358
+ );
2359
+ }
2360
+ const removed = await users.clearProvider(ctx.user.id);
2361
+ if (!removed) {
2362
+ return (0, import_core8.setApiResponse)(
2363
+ import_core8.HTTP.CONFLICT,
2364
+ "PASSWORD_REQUIRED",
2365
+ "Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
2366
+ );
2367
+ }
2368
+ if (ctx.user.email) {
2369
+ await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
2370
+ type: MESSAGE_KEYS.oauthUnlinked,
2371
+ locale: ctx.user.locale,
2372
+ data: { provider: current.provider },
2373
+ recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2374
+ }));
2375
+ }
2376
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2377
+ },
2235
2378
  deleteMe: async (ctx) => {
2236
2379
  const userId = ctx.user.id;
2237
2380
  await users.softDelete(userId);
@@ -2292,6 +2435,7 @@ function userController(store, config, bus) {
2292
2435
  var import_node_crypto7 = require("crypto");
2293
2436
  var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
2294
2437
  var import_core9 = require("@fonderie/core");
2438
+ var import_events4 = require("@fonderie/events");
2295
2439
 
2296
2440
  // src/models/consumed-token.model.ts
2297
2441
  var ConsumedTokenModel = class {
@@ -2392,11 +2536,68 @@ async function verifyAppleIdToken(idToken, opts) {
2392
2536
  }
2393
2537
  }
2394
2538
  var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2395
- function oauthController(store, config) {
2539
+ function oauthController(store, config, bus) {
2396
2540
  const users = new UserModel(store);
2397
2541
  const sessions = new SessionModel(store);
2398
2542
  const loginEvents = new LoginEventModel(store);
2399
2543
  const consumedTokens = new ConsumedTokenModel(store);
2544
+ const announceOAuthUpsert = async (ctx, upserted, provider, user) => {
2545
+ if (!bus) return;
2546
+ const reqId = ctx.meta["requestId"];
2547
+ const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
2548
+ const label = provider.charAt(0).toUpperCase() + provider.slice(1);
2549
+ const recipient = { email: user.email, phone: null, deviceToken: null };
2550
+ if (upserted.clearedUnverifiedPassword && user.email) {
2551
+ await (0, import_core9.background)(bus.emit(
2552
+ import_events4.NOTIFICATION_EVENT,
2553
+ {
2554
+ type: MESSAGE_KEYS.passwordRevoked,
2555
+ locale: user.locale,
2556
+ data: { provider: label },
2557
+ recipient
2558
+ },
2559
+ reqOpts
2560
+ ));
2561
+ }
2562
+ if (upserted.inserted) {
2563
+ await (0, import_core9.background)(bus.emit(
2564
+ EVENT_KEYS.userRegistered,
2565
+ {
2566
+ userId: upserted.id,
2567
+ email: user.email,
2568
+ firstName: user.firstName,
2569
+ lastName: user.lastName,
2570
+ loginMethod: provider
2571
+ },
2572
+ reqOpts
2573
+ ));
2574
+ if (user.email) {
2575
+ await (0, import_core9.background)(bus.emit(
2576
+ import_events4.NOTIFICATION_EVENT,
2577
+ {
2578
+ type: MESSAGE_KEYS.oauthRegistration,
2579
+ locale: user.locale,
2580
+ data: { provider: label, appName: config.appName ?? "Fonderie" },
2581
+ recipient
2582
+ },
2583
+ reqOpts
2584
+ ));
2585
+ }
2586
+ return;
2587
+ }
2588
+ if (upserted.previousProvider !== provider && user.email) {
2589
+ await (0, import_core9.background)(bus.emit(
2590
+ import_events4.NOTIFICATION_EVENT,
2591
+ {
2592
+ type: MESSAGE_KEYS.oauthLinked,
2593
+ locale: user.locale,
2594
+ data: { provider: label },
2595
+ recipient
2596
+ },
2597
+ reqOpts
2598
+ ));
2599
+ }
2600
+ };
2400
2601
  const completeAppleLogin = async (ctx, claims) => {
2401
2602
  const meta = requestMeta(ctx);
2402
2603
  if (!claims.email) {
@@ -2425,6 +2626,7 @@ function oauthController(store, config) {
2425
2626
  if (!fullUser) {
2426
2627
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2427
2628
  }
2629
+ await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2428
2630
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2429
2631
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2430
2632
  await loginEvents.recordSafe({
@@ -2575,6 +2777,7 @@ function oauthController(store, config) {
2575
2777
  if (!fullUser) {
2576
2778
  return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2577
2779
  }
2780
+ await announceOAuthUpsert(ctx, upserted, "google", fullUser);
2578
2781
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
2579
2782
  loginMethod: "google"
2580
2783
  });
@@ -2739,7 +2942,7 @@ function oauthController(store, config) {
2739
2942
  function buildAuthRoutes(store, config, bus) {
2740
2943
  const user = userController(store, config, bus);
2741
2944
  const auth = authController(store, config, bus);
2742
- const oauth = oauthController(store, config);
2945
+ const oauth = oauthController(store, config, bus);
2743
2946
  const mfa = mfaController(store, config, config.appName ?? "Fonderie", bus);
2744
2947
  const verifyGate = config.requireVerification ? import_middlewares2.requireVerified : (_ctx, next) => next();
2745
2948
  const passthrough = (_ctx, next) => next();
@@ -2797,6 +3000,9 @@ function buildAuthRoutes(store, config, bus) {
2797
3000
  R("updateEmail", "PUT", "/users/email", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(updateEmailSchema), user.updateEmail),
2798
3001
  R("updatePhone", "PUT", "/users/phone", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(updatePhoneSchema), user.updatePhone),
2799
3002
  R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
3003
+ // Disconnect an OAuth provider. requireAuth only — the controller
3004
+ // refuses when it would leave the account with no way to sign in.
3005
+ R("unlinkOauth", "DELETE", "/auth/oauth/:provider", import_middlewares2.requireAuth, user.unlinkOauth),
2800
3006
  R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
2801
3007
  R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
2802
3008
  // Security surfaces (Protected; the caller's own login history + sessions).
@@ -3109,9 +3315,66 @@ If you made this change, you're all set. If not, contact support right away \u20
3109
3315
  The phone number on your account was just updated.
3110
3316
 
3111
3317
  If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
3318
+ },
3319
+ [MESSAGE_KEYS.passwordRevoked]: {
3320
+ subject: "The password on your account was removed",
3321
+ html: `<h1>Your password was removed</h1>
3322
+ <p>You just signed in with <strong>{{provider}}</strong>. A password had been set on this account, but this email address had never been confirmed &mdash; so we could not tell who set it, and it has been removed.</p>
3323
+ <p>Signing in with {{provider}} works as before. If you want a password as well, set one from your account settings.</p>
3324
+ <p class="muted">If you never set a password here, someone else may have tried to register with your address. Nothing further is needed &mdash; the account is yours.</p>`,
3325
+ text: `Your password was removed
3326
+
3327
+ You just signed in with {{provider}}. A password had been set on this account,
3328
+ but this email address had never been confirmed \u2014 so we could not tell who set
3329
+ it, and it has been removed.
3330
+
3331
+ Signing in with {{provider}} works as before. If you want a password as well,
3332
+ set one from your account settings.
3333
+
3334
+ If you never set a password here, someone else may have tried to register with
3335
+ your address. Nothing further is needed \u2014 the account is yours.`
3336
+ },
3337
+ [MESSAGE_KEYS.oauthRegistration]: {
3338
+ subject: "Welcome to {{appName}}",
3339
+ html: `<h1>Welcome to {{appName}}</h1>
3340
+ <p>Your account was created using <strong>{{provider}}</strong>. Sign in any time with the same {{provider}} account &mdash; there is no password to remember.</p>
3341
+ <p class="muted">If you did not create this account, contact support.</p>`,
3342
+ text: `Welcome
3343
+
3344
+ Your account was created using {{provider}}. Sign in any time with the same
3345
+ {{provider}} account \u2014 there is no password to remember.
3346
+
3347
+ If you did not create this account, contact support.`
3348
+ },
3349
+ [MESSAGE_KEYS.oauthLinked]: {
3350
+ subject: "A new sign-in method was added to your account",
3351
+ html: `<h1>{{provider}} sign-in was added</h1>
3352
+ <p>Your account can now also be signed into with <strong>{{provider}}</strong>.</p>
3353
+ <p class="muted">If you did this, nothing more is needed. If not, contact support right away and change your password &mdash; someone else may be able to sign in as you.</p>`,
3354
+ text: `{{provider}} sign-in was added
3355
+
3356
+ Your account can now also be signed into with {{provider}}.
3357
+
3358
+ If you did this, nothing more is needed. If not, contact support right away and
3359
+ change your password \u2014 someone else may be able to sign in as you.`
3360
+ },
3361
+ [MESSAGE_KEYS.oauthUnlinked]: {
3362
+ subject: "A sign-in method was removed from your account",
3363
+ html: `<h1>{{provider}} sign-in was removed</h1>
3364
+ <p><strong>{{provider}}</strong> can no longer be used to sign in. Your email and password still work.</p>
3365
+ <p class="muted">If you did this, you&rsquo;re all set. If not, contact support right away.</p>`,
3366
+ text: `{{provider}} sign-in was removed
3367
+
3368
+ {{provider}} can no longer be used to sign in. Your email and password still work.
3369
+
3370
+ If you did this, you're all set. If not, contact support right away.`
3112
3371
  }
3113
3372
  };
3114
3373
  var SAMPLE_PAYLOADS = {
3374
+ [MESSAGE_KEYS.passwordRevoked]: { provider: "Google" },
3375
+ [MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },
3376
+ [MESSAGE_KEYS.oauthLinked]: { provider: "Google" },
3377
+ [MESSAGE_KEYS.oauthUnlinked]: { provider: "Google" },
3115
3378
  [MESSAGE_KEYS.emailRegistration]: { firstName: "", pin: "123456" },
3116
3379
  [MESSAGE_KEYS.emailVerification]: { firstName: "Ada", pin: "123456" },
3117
3380
  [MESSAGE_KEYS.passwordReset]: {