@molecule/api-resource-user 1.0.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.
Files changed (164) hide show
  1. package/LICENSE +115 -0
  2. package/dist/authorization.d.ts +107 -0
  3. package/dist/authorization.d.ts.map +1 -0
  4. package/dist/authorization.js +375 -0
  5. package/dist/authorization.js.map +1 -0
  6. package/dist/authorizers/auth.d.ts +8 -0
  7. package/dist/authorizers/auth.d.ts.map +1 -0
  8. package/dist/authorizers/auth.js +19 -0
  9. package/dist/authorizers/auth.js.map +1 -0
  10. package/dist/authorizers/authSelf.d.ts +14 -0
  11. package/dist/authorizers/authSelf.d.ts.map +1 -0
  12. package/dist/authorizers/authSelf.js +31 -0
  13. package/dist/authorizers/authSelf.js.map +1 -0
  14. package/dist/authorizers/index.d.ts +5 -0
  15. package/dist/authorizers/index.d.ts.map +1 -0
  16. package/dist/authorizers/index.js +5 -0
  17. package/dist/authorizers/index.js.map +1 -0
  18. package/dist/authorizers/rateLimit.d.ts +84 -0
  19. package/dist/authorizers/rateLimit.d.ts.map +1 -0
  20. package/dist/authorizers/rateLimit.js +144 -0
  21. package/dist/authorizers/rateLimit.js.map +1 -0
  22. package/dist/authorizers/requireWebhookAuthenticity.d.ts +23 -0
  23. package/dist/authorizers/requireWebhookAuthenticity.d.ts.map +1 -0
  24. package/dist/authorizers/requireWebhookAuthenticity.js +76 -0
  25. package/dist/authorizers/requireWebhookAuthenticity.js.map +1 -0
  26. package/dist/browser-guard.d.ts +2 -0
  27. package/dist/browser-guard.d.ts.map +1 -0
  28. package/dist/browser-guard.js +19 -0
  29. package/dist/browser-guard.js.map +1 -0
  30. package/dist/handlers/create.d.ts +52 -0
  31. package/dist/handlers/create.d.ts.map +1 -0
  32. package/dist/handlers/create.js +208 -0
  33. package/dist/handlers/create.js.map +1 -0
  34. package/dist/handlers/del.d.ts +30 -0
  35. package/dist/handlers/del.d.ts.map +1 -0
  36. package/dist/handlers/del.js +55 -0
  37. package/dist/handlers/del.js.map +1 -0
  38. package/dist/handlers/forgotPassword.d.ts +34 -0
  39. package/dist/handlers/forgotPassword.d.ts.map +1 -0
  40. package/dist/handlers/forgotPassword.js +119 -0
  41. package/dist/handlers/forgotPassword.js.map +1 -0
  42. package/dist/handlers/index.d.ts +16 -0
  43. package/dist/handlers/index.d.ts.map +1 -0
  44. package/dist/handlers/index.js +16 -0
  45. package/dist/handlers/index.js.map +1 -0
  46. package/dist/handlers/logIn.d.ts +91 -0
  47. package/dist/handlers/logIn.d.ts.map +1 -0
  48. package/dist/handlers/logIn.js +223 -0
  49. package/dist/handlers/logIn.js.map +1 -0
  50. package/dist/handlers/logInOAuth.d.ts +66 -0
  51. package/dist/handlers/logInOAuth.d.ts.map +1 -0
  52. package/dist/handlers/logInOAuth.js +462 -0
  53. package/dist/handlers/logInOAuth.js.map +1 -0
  54. package/dist/handlers/logout.d.ts +22 -0
  55. package/dist/handlers/logout.d.ts.map +1 -0
  56. package/dist/handlers/logout.js +57 -0
  57. package/dist/handlers/logout.js.map +1 -0
  58. package/dist/handlers/oauthAuthorize.d.ts +44 -0
  59. package/dist/handlers/oauthAuthorize.d.ts.map +1 -0
  60. package/dist/handlers/oauthAuthorize.js +141 -0
  61. package/dist/handlers/oauthAuthorize.js.map +1 -0
  62. package/dist/handlers/payments/handlePaymentNotification.d.ts +28 -0
  63. package/dist/handlers/payments/handlePaymentNotification.d.ts.map +1 -0
  64. package/dist/handlers/payments/handlePaymentNotification.js +217 -0
  65. package/dist/handlers/payments/handlePaymentNotification.js.map +1 -0
  66. package/dist/handlers/payments/registry.d.ts +13 -0
  67. package/dist/handlers/payments/registry.d.ts.map +1 -0
  68. package/dist/handlers/payments/registry.js +13 -0
  69. package/dist/handlers/payments/registry.js.map +1 -0
  70. package/dist/handlers/payments/verifyPayment.d.ts +37 -0
  71. package/dist/handlers/payments/verifyPayment.d.ts.map +1 -0
  72. package/dist/handlers/payments/verifyPayment.js +383 -0
  73. package/dist/handlers/payments/verifyPayment.js.map +1 -0
  74. package/dist/handlers/read.d.ts +17 -0
  75. package/dist/handlers/read.d.ts.map +1 -0
  76. package/dist/handlers/read.js +60 -0
  77. package/dist/handlers/read.js.map +1 -0
  78. package/dist/handlers/readSelf.d.ts +23 -0
  79. package/dist/handlers/readSelf.d.ts.map +1 -0
  80. package/dist/handlers/readSelf.js +77 -0
  81. package/dist/handlers/readSelf.js.map +1 -0
  82. package/dist/handlers/resetPassword.d.ts +40 -0
  83. package/dist/handlers/resetPassword.d.ts.map +1 -0
  84. package/dist/handlers/resetPassword.js +147 -0
  85. package/dist/handlers/resetPassword.js.map +1 -0
  86. package/dist/handlers/update.d.ts +28 -0
  87. package/dist/handlers/update.d.ts.map +1 -0
  88. package/dist/handlers/update.js +250 -0
  89. package/dist/handlers/update.js.map +1 -0
  90. package/dist/handlers/updatePassword.d.ts +35 -0
  91. package/dist/handlers/updatePassword.d.ts.map +1 -0
  92. package/dist/handlers/updatePassword.js +113 -0
  93. package/dist/handlers/updatePassword.js.map +1 -0
  94. package/dist/handlers/updatePlan.d.ts +41 -0
  95. package/dist/handlers/updatePlan.d.ts.map +1 -0
  96. package/dist/handlers/updatePlan.js +242 -0
  97. package/dist/handlers/updatePlan.js.map +1 -0
  98. package/dist/handlers/verifyTwoFactor.d.ts +46 -0
  99. package/dist/handlers/verifyTwoFactor.d.ts.map +1 -0
  100. package/dist/handlers/verifyTwoFactor.js +162 -0
  101. package/dist/handlers/verifyTwoFactor.js.map +1 -0
  102. package/dist/i18n.d.ts +10 -0
  103. package/dist/i18n.d.ts.map +1 -0
  104. package/dist/i18n.js +25 -0
  105. package/dist/i18n.js.map +1 -0
  106. package/dist/index.d.ts +88 -0
  107. package/dist/index.d.ts.map +1 -0
  108. package/dist/index.js +88 -0
  109. package/dist/index.js.map +1 -0
  110. package/dist/requestHandlerMap.d.ts +44 -0
  111. package/dist/requestHandlerMap.d.ts.map +1 -0
  112. package/dist/requestHandlerMap.js +68 -0
  113. package/dist/requestHandlerMap.js.map +1 -0
  114. package/dist/resource.d.ts +22 -0
  115. package/dist/resource.d.ts.map +1 -0
  116. package/dist/resource.js +23 -0
  117. package/dist/resource.js.map +1 -0
  118. package/dist/routes.d.ts +44 -0
  119. package/dist/routes.d.ts.map +1 -0
  120. package/dist/routes.js +121 -0
  121. package/dist/routes.js.map +1 -0
  122. package/dist/schema.d.ts +193 -0
  123. package/dist/schema.d.ts.map +1 -0
  124. package/dist/schema.js +229 -0
  125. package/dist/schema.js.map +1 -0
  126. package/dist/secrets.d.ts +16 -0
  127. package/dist/secrets.d.ts.map +1 -0
  128. package/dist/secrets.js +28 -0
  129. package/dist/secrets.js.map +1 -0
  130. package/dist/types.d.ts +14 -0
  131. package/dist/types.d.ts.map +1 -0
  132. package/dist/types.js +9 -0
  133. package/dist/types.js.map +1 -0
  134. package/dist/utilities/getPlan.d.ts +19 -0
  135. package/dist/utilities/getPlan.d.ts.map +1 -0
  136. package/dist/utilities/getPlan.js +26 -0
  137. package/dist/utilities/getPlan.js.map +1 -0
  138. package/dist/utilities/hashResetToken.d.ts +16 -0
  139. package/dist/utilities/hashResetToken.d.ts.map +1 -0
  140. package/dist/utilities/hashResetToken.js +19 -0
  141. package/dist/utilities/hashResetToken.js.map +1 -0
  142. package/dist/utilities/index.d.ts +5 -0
  143. package/dist/utilities/index.d.ts.map +1 -0
  144. package/dist/utilities/index.js +5 -0
  145. package/dist/utilities/index.js.map +1 -0
  146. package/dist/utilities/invalidateEntitlements.d.ts +24 -0
  147. package/dist/utilities/invalidateEntitlements.d.ts.map +1 -0
  148. package/dist/utilities/invalidateEntitlements.js +41 -0
  149. package/dist/utilities/invalidateEntitlements.js.map +1 -0
  150. package/dist/utilities/normalizeEmail.d.ts +22 -0
  151. package/dist/utilities/normalizeEmail.d.ts.map +1 -0
  152. package/dist/utilities/normalizeEmail.js +28 -0
  153. package/dist/utilities/normalizeEmail.js.map +1 -0
  154. package/dist/utilities/notify.d.ts +21 -0
  155. package/dist/utilities/notify.d.ts.map +1 -0
  156. package/dist/utilities/notify.js +39 -0
  157. package/dist/utilities/notify.js.map +1 -0
  158. package/dist/utilities/stripSensitiveUserColumns.d.ts +25 -0
  159. package/dist/utilities/stripSensitiveUserColumns.d.ts.map +1 -0
  160. package/dist/utilities/stripSensitiveUserColumns.js +43 -0
  161. package/dist/utilities/stripSensitiveUserColumns.js.map +1 -0
  162. package/package.json +94 -0
  163. package/setup/users.sql +22 -0
  164. package/setup/usersSecrets.sql +9 -0
@@ -0,0 +1,147 @@
1
+ import crypto from 'node:crypto';
2
+ import { get, getAnalytics, getLogger } from '@molecule/api-bond';
3
+ import { findOne, updateById } from '@molecule/api-database';
4
+ import { t } from '@molecule/api-i18n';
5
+ import { hash } from '@molecule/api-password';
6
+ import { invalidateAllDeviceExistsCache } from '../authorization.js';
7
+ import { hashResetToken } from '../utilities/hashResetToken.js';
8
+ const analytics = getAnalytics();
9
+ const logger = getLogger();
10
+ const RESET_TOKEN_TTL_MS = 1000 * 60 * 60;
11
+ /**
12
+ * Confirms a password reset by validating the one-time token previously generated by
13
+ * `forgotPassword`, then hashing and storing the new password and clearing the token.
14
+ *
15
+ * Token TTL is one hour. The handler responds with 400 for missing fields, 400 for
16
+ * passwords that fail length validation (matching `updatePassword`), and 401 for
17
+ * tokens that are unknown, expired, or already consumed.
18
+ *
19
+ * On success, all existing device sessions are invalidated to force a fresh login,
20
+ * mirroring the behavior of `updatePassword`.
21
+ *
22
+ * @param resource - The user resource configuration (name, tableName, schema).
23
+ * @param resource.tableName - The database table name for users.
24
+ * @returns A request handler that responds with `{ statusCode: 200, body: { success: true } }` on success.
25
+ */
26
+ export const resetPassword = ({ name: _name, tableName, schema: _schema }) => {
27
+ return async (req) => {
28
+ try {
29
+ const { token, password } = req.body;
30
+ if (!token) {
31
+ return {
32
+ statusCode: 400,
33
+ body: {
34
+ error: t('user.error.tokenRequired'),
35
+ errorKey: 'user.error.tokenRequired',
36
+ },
37
+ };
38
+ }
39
+ if (!password) {
40
+ return {
41
+ statusCode: 400,
42
+ body: {
43
+ error: t('user.error.newPasswordRequired'),
44
+ errorKey: 'user.error.newPasswordRequired',
45
+ },
46
+ };
47
+ }
48
+ if (password.length < 8) {
49
+ return {
50
+ statusCode: 400,
51
+ body: {
52
+ error: t('user.error.passwordTooShort', undefined, {
53
+ defaultValue: 'Password must be at least 8 characters',
54
+ }),
55
+ errorKey: 'user.error.passwordTooShort',
56
+ },
57
+ };
58
+ }
59
+ if (password.length > 1024) {
60
+ return {
61
+ statusCode: 400,
62
+ body: {
63
+ error: t('user.error.passwordTooLong', undefined, {
64
+ defaultValue: 'Password must be at most 1024 characters',
65
+ }),
66
+ errorKey: 'user.error.passwordTooLong',
67
+ },
68
+ };
69
+ }
70
+ // The stored value is sha256(token); look up + compare by that digest.
71
+ const tokenHash = hashResetToken(token);
72
+ const secrets = await findOne(`${tableName}Secrets`, [
73
+ { field: 'passwordResetToken', operator: '=', value: tokenHash },
74
+ ]);
75
+ if (!secrets || !secrets.passwordResetToken || !secrets.passwordResetTokenAt) {
76
+ return {
77
+ statusCode: 401,
78
+ body: {
79
+ error: t('user.error.invalidToken'),
80
+ errorKey: 'user.error.invalidToken',
81
+ },
82
+ };
83
+ }
84
+ // Constant-time comparison to defeat timing attacks even though the lookup
85
+ // already matched — defense in depth.
86
+ const a = Buffer.from(tokenHash, 'utf-8');
87
+ const b = Buffer.from(secrets.passwordResetToken, 'utf-8');
88
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
89
+ return {
90
+ statusCode: 401,
91
+ body: {
92
+ error: t('user.error.invalidToken'),
93
+ errorKey: 'user.error.invalidToken',
94
+ },
95
+ };
96
+ }
97
+ const tokenAge = Date.now() - new Date(secrets.passwordResetTokenAt).getTime();
98
+ if (!Number.isFinite(tokenAge) || tokenAge > RESET_TOKEN_TTL_MS || tokenAge < 0) {
99
+ return {
100
+ statusCode: 401,
101
+ body: {
102
+ error: t('user.error.invalidToken'),
103
+ errorKey: 'user.error.invalidToken',
104
+ },
105
+ };
106
+ }
107
+ const userId = secrets.id;
108
+ if (!userId) {
109
+ return {
110
+ statusCode: 500,
111
+ body: {
112
+ error: t('user.error.failedToUpdatePassword'),
113
+ errorKey: 'user.error.failedToUpdatePassword',
114
+ },
115
+ };
116
+ }
117
+ const passwordHash = await hash(password);
118
+ await updateById(`${tableName}Secrets`, userId, {
119
+ passwordHash,
120
+ passwordResetToken: null,
121
+ passwordResetTokenAt: null,
122
+ });
123
+ try {
124
+ await get('device')?.deleteByUserId(userId);
125
+ // Evict this process's positive device-exists cache so no just-deleted
126
+ // device keeps authenticating for the cache TTL — immediate revocation.
127
+ invalidateAllDeviceExistsCache();
128
+ }
129
+ catch (error) {
130
+ logger.warn('Failed to invalidate sessions after password reset', { userId, error });
131
+ }
132
+ analytics.track({ name: 'user.password_reset_completed', userId }).catch(() => { });
133
+ return { statusCode: 200, body: { success: true } };
134
+ }
135
+ catch (error) {
136
+ logger.error(error);
137
+ return {
138
+ statusCode: 500,
139
+ body: {
140
+ error: t('user.error.failedToUpdatePassword'),
141
+ errorKey: 'user.error.failedToUpdatePassword',
142
+ },
143
+ };
144
+ }
145
+ };
146
+ };
147
+ //# sourceMappingURL=resetPassword.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resetPassword.js","sourceRoot":"","sources":["../../src/handlers/resetPassword.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ import type { MoleculeRequest } from '@molecule/api-resource';
2
+ import type * as types from '../types.js';
3
+ /**
4
+ * Updates a user's profile fields (username, name, email). Validates username format
5
+ * (alphanumeric, max 255 chars), email format, and uniqueness of both fields against
6
+ * other users before delegating to the resource update utility.
7
+ * @param resource - The user resource configuration (name, tableName, schema).
8
+ * @param resource.name - The resource name.
9
+ * @param resource.tableName - The database table name for users.
10
+ * @param resource.schema - The validation schema for user properties.
11
+ * @returns A request handler that responds with the updated user props on success.
12
+ */
13
+ export declare const update: ({ name, tableName, schema: _schema }: types.Resource) => (req: MoleculeRequest) => Promise<{
14
+ statusCode: number;
15
+ body: {
16
+ error: string;
17
+ errorKey: string;
18
+ props?: undefined;
19
+ };
20
+ } | {
21
+ statusCode: number;
22
+ body: {
23
+ props: Pick<import("@molecule/api-resource/types").Props, "updatedAt"> & Record<string, unknown>;
24
+ error?: undefined;
25
+ errorKey?: undefined;
26
+ };
27
+ }>;
28
+ //# sourceMappingURL=update.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/handlers/update.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAI7D,OAAO,KAAK,KAAK,KAAK,MAAM,aAAa,CAAA;AA0BzC;;;;;;;;;GASG;AACH,eAAO,MAAM,MAAM,GAAI,sCAAsC,KAAK,CAAC,QAAQ,MAO3D,KAAK,eAAe;;;;;aArBL,CAAA;;;;;;;;;EAyP9B,CAAA"}
@@ -0,0 +1,250 @@
1
+ import { getAnalytics, getLogger } from '@molecule/api-bond';
2
+ import { findOne } from '@molecule/api-database';
3
+ const logger = getLogger();
4
+ const analytics = getAnalytics();
5
+ import { t } from '@molecule/api-i18n';
6
+ import { compare } from '@molecule/api-password';
7
+ import { update as resourceUpdate } from '@molecule/api-resource';
8
+ import { MAX_AVATAR_LENGTH, MAX_BIO_LENGTH, propsSchema, updatePropsSchema } from '../schema.js';
9
+ import { normalizeEmail } from '../utilities/normalizeEmail.js';
10
+ /**
11
+ * Extended update schema that also permits the application-specific
12
+ * `oauthData` bag to flow through the standard `PATCH /api/users/:id`
13
+ * endpoint. Apps frequently store per-user UI/feature preferences inside
14
+ * `oauthData` (it is the only freeform JSON field on the user row), so
15
+ * the update handler needs to accept and persist it.
16
+ *
17
+ * Username/name/email retain their existing validation paths above.
18
+ */
19
+ const extendedUpdatePropsSchema = updatePropsSchema.extend({
20
+ oauthData: propsSchema.shape.oauthData,
21
+ // emailVerified is permitted through the schema ONLY so the handler can RESET it
22
+ // to false when the email changes (server-set below). It is NEVER copied from
23
+ // req.body — the same discipline as twoFactorEnabled — so a caller cannot
24
+ // self-verify by sending `emailVerified: true`.
25
+ emailVerified: propsSchema.shape.emailVerified,
26
+ // [M6-1] twoFactorEnabled is deliberately NOT writable here. The generic profile update is
27
+ // gated only by authSelf (no TOTP step-up), so allowing it would let a caller PATCH 2FA off
28
+ // without the current second factor — defeating verifyTwoFactor's disable step-up. ALL 2FA
29
+ // state transitions must go through verifyTwoFactor (enable/disable), which verifies the
30
+ // live TOTP token and updates the secret atomically.
31
+ });
32
+ /**
33
+ * Updates a user's profile fields (username, name, email). Validates username format
34
+ * (alphanumeric, max 255 chars), email format, and uniqueness of both fields against
35
+ * other users before delegating to the resource update utility.
36
+ * @param resource - The user resource configuration (name, tableName, schema).
37
+ * @param resource.name - The resource name.
38
+ * @param resource.tableName - The database table name for users.
39
+ * @param resource.schema - The validation schema for user properties.
40
+ * @returns A request handler that responds with the updated user props on success.
41
+ */
42
+ export const update = ({ name, tableName, schema: _schema }) => {
43
+ const updateResource = resourceUpdate({
44
+ name,
45
+ tableName,
46
+ schema: extendedUpdatePropsSchema,
47
+ });
48
+ return async (req) => {
49
+ try {
50
+ const id = req.params.id;
51
+ const props = {};
52
+ if (req.body.username !== undefined) {
53
+ props.username = String(req.body.username)
54
+ .replace(/[^a-zA-Z0-9]/g, '')
55
+ .substring(0, 255);
56
+ if (!props.username) {
57
+ return {
58
+ statusCode: 400,
59
+ body: {
60
+ error: t('user.error.usernameCannotBeEmpty'),
61
+ errorKey: 'user.error.usernameCannotBeEmpty',
62
+ },
63
+ };
64
+ }
65
+ // Check if username is taken by another user.
66
+ const existing = await findOne(tableName, [
67
+ { field: 'username', operator: '=', value: props.username },
68
+ { field: 'id', operator: '!=', value: id },
69
+ ]);
70
+ if (existing) {
71
+ return {
72
+ statusCode: 403,
73
+ body: {
74
+ error: t('user.error.usernameUnavailable'),
75
+ errorKey: 'user.error.usernameUnavailable',
76
+ },
77
+ };
78
+ }
79
+ }
80
+ if (req.body.name !== undefined) {
81
+ props.name = String(req.body.name).substring(0, 255);
82
+ }
83
+ if (req.body.email !== undefined) {
84
+ let nextEmail;
85
+ if (req.body.email === '' || req.body.email === null) {
86
+ nextEmail = null;
87
+ }
88
+ else {
89
+ // Normalize (lowercase) on store to match signup + the login/reset
90
+ // lookups — otherwise a profile email change stores a mixed-case value
91
+ // that those normalized lookups can't find, and lets a case-variant
92
+ // duplicate slip past the uniqueness check below.
93
+ nextEmail = (normalizeEmail(String(req.body.email)) ?? '').substring(0, 1023);
94
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nextEmail)) {
95
+ return {
96
+ statusCode: 400,
97
+ body: { error: t('user.error.emailInvalid'), errorKey: 'user.error.emailInvalid' },
98
+ };
99
+ }
100
+ // Check if email is taken by another user.
101
+ const existing = await findOne(tableName, [
102
+ { field: 'email', operator: '=', value: nextEmail },
103
+ { field: 'id', operator: '!=', value: id },
104
+ ]);
105
+ if (existing) {
106
+ return {
107
+ statusCode: 403,
108
+ body: {
109
+ error: t('user.error.emailAlreadyRegistered'),
110
+ errorKey: 'user.error.emailAlreadyRegistered',
111
+ },
112
+ };
113
+ }
114
+ }
115
+ const current = await findOne(tableName, [
116
+ { field: 'id', operator: '=', value: id },
117
+ ]);
118
+ const changingEmail = !current || (current.email ?? null) !== nextEmail;
119
+ if (changingEmail) {
120
+ // Re-authentication gate: the email is the account-recovery anchor
121
+ // (password reset goes to it), so changing OR clearing it with a
122
+ // hijacked session must not be silent. Accounts WITH a password must
123
+ // present it (same contract + i18n keys as updatePassword); OAuth-only
124
+ // accounts have no password to present, so their (provider-backed)
125
+ // session remains the credential.
126
+ const secrets = await findOne(`${tableName}Secrets`, [
127
+ { field: 'id', operator: '=', value: id },
128
+ ]);
129
+ if (secrets?.passwordHash) {
130
+ const currentPassword = typeof req.body.currentPassword === 'string' ? req.body.currentPassword : '';
131
+ if (!currentPassword) {
132
+ return {
133
+ statusCode: 400,
134
+ body: {
135
+ error: t('user.error.currentPasswordRequired'),
136
+ errorKey: 'user.error.currentPasswordRequired',
137
+ },
138
+ };
139
+ }
140
+ if (!(await compare(currentPassword, secrets.passwordHash))) {
141
+ return {
142
+ statusCode: 403,
143
+ body: {
144
+ error: t('user.error.currentPasswordIncorrect'),
145
+ errorKey: 'user.error.currentPasswordIncorrect',
146
+ },
147
+ };
148
+ }
149
+ }
150
+ // Changing the address invalidates the verified flag — the new email is
151
+ // unproven, and leaving emailVerified=true would let a caller point a
152
+ // "verified" email anywhere (poisoning password-reset and email-gated
153
+ // access). Reset ONLY on a real change so an unchanged profile save does
154
+ // not needlessly un-verify. Server-set — never read from req.body.
155
+ props.emailVerified = false;
156
+ }
157
+ props.email = nextEmail;
158
+ }
159
+ if (req.body.avatar !== undefined) {
160
+ // `null` or '' clears the avatar; anything else is coerced to a string and length-capped.
161
+ if (req.body.avatar === null || req.body.avatar === '') {
162
+ props.avatar = null;
163
+ }
164
+ else {
165
+ const avatar = String(req.body.avatar);
166
+ if (avatar.length > MAX_AVATAR_LENGTH) {
167
+ return {
168
+ statusCode: 400,
169
+ body: {
170
+ error: t('user.error.avatarTooLarge', { max: MAX_AVATAR_LENGTH }, {
171
+ defaultValue: 'Avatar must be {{max}} characters or fewer.',
172
+ }),
173
+ errorKey: 'user.error.avatarTooLarge',
174
+ },
175
+ };
176
+ }
177
+ props.avatar = avatar;
178
+ }
179
+ }
180
+ if (req.body.bio !== undefined) {
181
+ // `null` clears the bio; otherwise coerce to a string and length-cap (empty string is allowed).
182
+ if (req.body.bio === null) {
183
+ props.bio = null;
184
+ }
185
+ else {
186
+ const bio = String(req.body.bio);
187
+ if (bio.length > MAX_BIO_LENGTH) {
188
+ return {
189
+ statusCode: 400,
190
+ body: {
191
+ error: t('user.error.bioTooLong', { max: MAX_BIO_LENGTH }, {
192
+ defaultValue: 'Bio must be {{max}} characters or fewer.',
193
+ }),
194
+ errorKey: 'user.error.bioTooLong',
195
+ },
196
+ };
197
+ }
198
+ props.bio = bio;
199
+ }
200
+ }
201
+ // [M6-1] twoFactorEnabled is intentionally NOT copied from the request body — see the
202
+ // extendedUpdatePropsSchema note above. 2FA toggles go through verifyTwoFactor only.
203
+ if (req.body.oauthData !== undefined) {
204
+ if (req.body.oauthData === null || typeof req.body.oauthData !== 'object') {
205
+ return {
206
+ statusCode: 400,
207
+ body: {
208
+ error: t('user.error.oauthDataInvalid', undefined, {
209
+ defaultValue: 'oauthData must be an object.',
210
+ }),
211
+ errorKey: 'user.error.oauthDataInvalid',
212
+ },
213
+ };
214
+ }
215
+ props.oauthData = req.body.oauthData;
216
+ }
217
+ if (Object.keys(props).length === 0) {
218
+ return {
219
+ statusCode: 400,
220
+ body: {
221
+ error: t('user.error.noUpdatableFields', undefined, {
222
+ defaultValue: 'No updatable fields were provided.',
223
+ }),
224
+ errorKey: 'user.error.noUpdatableFields',
225
+ },
226
+ };
227
+ }
228
+ const result = await updateResource({ id: id, props });
229
+ analytics
230
+ .track({
231
+ name: 'user.profile_updated',
232
+ userId: id,
233
+ properties: { fields: Object.keys(props) },
234
+ })
235
+ .catch(() => { });
236
+ return result;
237
+ }
238
+ catch (error) {
239
+ logger.error(error);
240
+ return {
241
+ statusCode: 500,
242
+ body: {
243
+ error: t('user.error.failedToUpdateUser'),
244
+ errorKey: 'user.error.failedToUpdateUser',
245
+ },
246
+ };
247
+ }
248
+ };
249
+ };
250
+ //# sourceMappingURL=update.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update.js","sourceRoot":"","sources":["../../src/handlers/update.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import type { MoleculeRequest } from '@molecule/api-resource';
2
+ import type * as types from '../types.js';
3
+ /** Request body for password update, with current password verification and new password. */
4
+ export interface UpdatePasswordRequest extends MoleculeRequest {
5
+ body: {
6
+ currentPassword?: string;
7
+ newPassword?: string;
8
+ };
9
+ }
10
+ /**
11
+ * Updates a user's password. If the user already has a password hash, the current password
12
+ * must be provided and verified first. The new password is hashed via `@molecule/api-password`
13
+ * and stored in the secrets table.
14
+ * @param resource - The user resource configuration (name, tableName, schema).
15
+ * @param resource.name - The resource name.
16
+ * @param resource.tableName - The database table name for users.
17
+ * @param resource.schema - The validation schema for user properties.
18
+ * @returns A request handler that responds with `{ statusCode: 200, body: { success: true } }` on success.
19
+ */
20
+ export declare const updatePassword: ({ name: _name, tableName, schema: _schema }: types.Resource) => (req: UpdatePasswordRequest) => Promise<{
21
+ statusCode: number;
22
+ body: {
23
+ error: string;
24
+ errorKey: string;
25
+ success?: undefined;
26
+ };
27
+ } | {
28
+ statusCode: number;
29
+ body: {
30
+ success: boolean;
31
+ error?: undefined;
32
+ errorKey?: undefined;
33
+ };
34
+ }>;
35
+ //# sourceMappingURL=updatePassword.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updatePassword.d.ts","sourceRoot":"","sources":["../../src/handlers/updatePassword.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAG7D,OAAO,KAAK,KAAK,KAAK,MAAM,aAAa,CAAA;AAKzC,6FAA6F;AAC7F,MAAM,WAAW,qBAAsB,SAAQ,eAAe;IAC5D,IAAI,EAAE;QACJ,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,WAAW,CAAC,EAAE,MAAM,CAAA;KACrB,CAAA;CACF;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,GAAI,6CAA6C,KAAK,CAAC,QAAQ,MAC1E,KAAK,qBAAqB;;;;;;;;;;;;;;EAqGzC,CAAA"}
@@ -0,0 +1,113 @@
1
+ import { get, getAnalytics, getLogger } from '@molecule/api-bond';
2
+ import { findById, updateById } from '@molecule/api-database';
3
+ import { t } from '@molecule/api-i18n';
4
+ import { compare, hash } from '@molecule/api-password';
5
+ import { invalidateAllDeviceExistsCache } from '../authorization.js';
6
+ const analytics = getAnalytics();
7
+ const logger = getLogger();
8
+ /**
9
+ * Updates a user's password. If the user already has a password hash, the current password
10
+ * must be provided and verified first. The new password is hashed via `@molecule/api-password`
11
+ * and stored in the secrets table.
12
+ * @param resource - The user resource configuration (name, tableName, schema).
13
+ * @param resource.name - The resource name.
14
+ * @param resource.tableName - The database table name for users.
15
+ * @param resource.schema - The validation schema for user properties.
16
+ * @returns A request handler that responds with `{ statusCode: 200, body: { success: true } }` on success.
17
+ */
18
+ export const updatePassword = ({ name: _name, tableName, schema: _schema }) => {
19
+ return async (req) => {
20
+ try {
21
+ const id = req.params.id;
22
+ const { currentPassword, newPassword } = req.body;
23
+ if (!newPassword) {
24
+ return {
25
+ statusCode: 400,
26
+ body: {
27
+ error: t('user.error.newPasswordRequired'),
28
+ errorKey: 'user.error.newPasswordRequired',
29
+ },
30
+ };
31
+ }
32
+ if (newPassword.length < 8) {
33
+ return {
34
+ statusCode: 400,
35
+ body: {
36
+ error: t('user.error.passwordTooShort', undefined, {
37
+ defaultValue: 'Password must be at least 8 characters',
38
+ }),
39
+ errorKey: 'user.error.passwordTooShort',
40
+ },
41
+ };
42
+ }
43
+ if (newPassword.length > 1024) {
44
+ return {
45
+ statusCode: 400,
46
+ body: {
47
+ error: t('user.error.passwordTooLong', undefined, {
48
+ defaultValue: 'Password must be at most 1024 characters',
49
+ }),
50
+ errorKey: 'user.error.passwordTooLong',
51
+ },
52
+ };
53
+ }
54
+ // Get current password hash.
55
+ const secrets = await findById(`${tableName}Secrets`, id);
56
+ // If user has a password, verify the current one.
57
+ if (secrets?.passwordHash) {
58
+ if (!currentPassword) {
59
+ return {
60
+ statusCode: 400,
61
+ body: {
62
+ error: t('user.error.currentPasswordRequired'),
63
+ errorKey: 'user.error.currentPasswordRequired',
64
+ },
65
+ };
66
+ }
67
+ const isValid = await compare(currentPassword, secrets.passwordHash);
68
+ if (!isValid) {
69
+ return {
70
+ statusCode: 403,
71
+ body: {
72
+ error: t('user.error.currentPasswordIncorrect'),
73
+ errorKey: 'user.error.currentPasswordIncorrect',
74
+ },
75
+ };
76
+ }
77
+ }
78
+ // Hash and store the new password, and clear any outstanding reset token.
79
+ const passwordHash = await hash(newPassword);
80
+ await updateById(`${tableName}Secrets`, id, {
81
+ passwordHash,
82
+ passwordResetToken: null,
83
+ passwordResetTokenAt: null,
84
+ });
85
+ // Invalidate all existing sessions by deleting all devices for this user.
86
+ // This forces re-login on every device, preventing compromised sessions
87
+ // from persisting after a password change.
88
+ try {
89
+ await get('device')?.deleteByUserId(id);
90
+ // Evict this process's positive device-exists cache so no just-deleted
91
+ // device keeps authenticating for the cache TTL — immediate revocation.
92
+ invalidateAllDeviceExistsCache();
93
+ }
94
+ catch (error) {
95
+ // Non-critical — password was already changed successfully
96
+ logger.warn('Failed to invalidate sessions after password change', { error, userId: id });
97
+ }
98
+ analytics.track({ name: 'user.password_changed', userId: id }).catch(() => { });
99
+ return { statusCode: 200, body: { success: true } };
100
+ }
101
+ catch (error) {
102
+ logger.error(error);
103
+ return {
104
+ statusCode: 500,
105
+ body: {
106
+ error: t('user.error.failedToUpdatePassword'),
107
+ errorKey: 'user.error.failedToUpdatePassword',
108
+ },
109
+ };
110
+ }
111
+ };
112
+ };
113
+ //# sourceMappingURL=updatePassword.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updatePassword.js","sourceRoot":"","sources":["../../src/handlers/updatePassword.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,41 @@
1
+ import type { MoleculeRequest } from '@molecule/api-resource';
2
+ import type * as types from '../types.js';
3
+ /** Request body for plan update, containing the target plan key. */
4
+ export interface UpdatePlanRequest extends MoleculeRequest {
5
+ body: {
6
+ planKey?: string;
7
+ };
8
+ }
9
+ /**
10
+ * Updates a user's subscription plan. Uses the bonded PlanService to look up plan metadata and
11
+ * delegates to the appropriate bonded PaymentProvider based on the plan's `platformKey`. Handles
12
+ * cross-platform subscription cancellation when switching providers. Returns a checkout URL
13
+ * (status 201) for new subscriptions, or directly updates the user record for in-place changes
14
+ * and free plan downgrades.
15
+ * @param resource - The user resource configuration (name, tableName, schema).
16
+ * @param resource.name - The resource name.
17
+ * @param resource.tableName - The database table name for users.
18
+ * @param resource.schema - The validation schema for user properties.
19
+ * @returns A request handler for plan updates.
20
+ */
21
+ export declare const updatePlan: ({ name, tableName, schema: _schema }: types.Resource) => (req: UpdatePlanRequest) => Promise<{
22
+ statusCode: number;
23
+ body: {
24
+ error: string;
25
+ errorKey: string;
26
+ props?: undefined;
27
+ };
28
+ } | {
29
+ statusCode: number;
30
+ body: {
31
+ props: Pick<import("@molecule/api-resource/types").Props, "updatedAt"> & Record<string, unknown>;
32
+ error?: undefined;
33
+ errorKey?: undefined;
34
+ };
35
+ } | {
36
+ statusCode: number;
37
+ body: {
38
+ checkoutUrl: string;
39
+ };
40
+ }>;
41
+ //# sourceMappingURL=updatePlan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updatePlan.d.ts","sourceRoot":"","sources":["../../src/handlers/updatePlan.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAI7D,OAAO,KAAK,KAAK,KAAK,MAAM,aAAa,CAAA;AAMzC,oEAAoE;AACpE,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,IAAI,EAAE;QACJ,OAAO,CAAC,EAAE,MAAM,CAAA;KACjB,CAAA;CACF;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,UAAU,GAAI,sCAAsC,KAAK,CAAC,QAAQ,MAO/D,KAAK,iBAAiB;;;;;aAAL,CAAC;;;;;;;;;;;;;;EAuOjC,CAAA"}