@jskit-ai/users-core 0.1.64 → 0.1.66

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 (56) hide show
  1. package/package.descriptor.mjs +14 -65
  2. package/package.json +10 -10
  3. package/src/server/UsersCoreServiceProvider.js +18 -2
  4. package/src/server/accountNotifications/accountNotificationsActions.js +3 -5
  5. package/src/server/accountNotifications/accountNotificationsService.js +3 -2
  6. package/src/server/accountNotifications/bootAccountNotificationsRoutes.js +12 -11
  7. package/src/server/accountPreferences/accountPreferencesActions.js +3 -5
  8. package/src/server/accountPreferences/accountPreferencesService.js +3 -2
  9. package/src/server/accountPreferences/bootAccountPreferencesRoutes.js +12 -11
  10. package/src/server/accountProfile/accountProfileActions.js +15 -32
  11. package/src/server/accountProfile/accountProfileService.js +9 -8
  12. package/src/server/accountProfile/bootAccountProfileRoutes.js +25 -19
  13. package/src/server/accountSecurity/accountSecurityActions.js +21 -16
  14. package/src/server/accountSecurity/accountSecurityService.js +16 -6
  15. package/src/server/accountSecurity/bootAccountSecurityRoutes.js +52 -40
  16. package/src/server/common/formatters/accountSettingsResponseFormatter.js +8 -18
  17. package/src/server/common/registerCommonRepositories.js +5 -2
  18. package/src/server/common/repositories/userProfilesRepository.js +227 -88
  19. package/src/server/common/repositories/userSettingsRepository.js +108 -100
  20. package/src/server/common/support/accountSettingsJsonApiTransport.js +10 -0
  21. package/src/server/usersBootstrapContributor.js +13 -32
  22. package/src/shared/resources/accountSettingsSchemas.js +83 -0
  23. package/src/shared/resources/userProfileResource.js +146 -126
  24. package/src/shared/resources/userSettingsResource.js +376 -353
  25. package/templates/packages/users/package.descriptor.mjs +4 -5
  26. package/templates/packages/users/package.json +0 -1
  27. package/templates/packages/users/src/server/UsersProvider.js +23 -24
  28. package/templates/packages/users/src/server/actions.js +26 -28
  29. package/templates/packages/users/src/server/registerRoutes.js +29 -15
  30. package/templates/packages/users/src/server/repository.js +35 -28
  31. package/templates/packages/users/src/server/service.js +20 -15
  32. package/templates/packages/users/src/shared/userResource.js +55 -68
  33. package/templates/packages/users-workspace/package.descriptor.mjs +4 -5
  34. package/templates/packages/users-workspace/src/server/UsersProvider.js +23 -24
  35. package/templates/packages/users-workspace/src/server/actions.js +28 -28
  36. package/templates/packages/users-workspace/src/server/registerRoutes.js +34 -16
  37. package/test/accountSecurityService.test.js +32 -0
  38. package/test/providerLifecycle.test.js +63 -0
  39. package/test/registerCommonRepositories.test.js +28 -8
  40. package/test/repositoryContracts.test.js +177 -28
  41. package/test/resourcesCanonical.test.js +18 -11
  42. package/test/userSettingsInternalResource.test.js +8 -0
  43. package/test/userSettingsResource.test.js +24 -7
  44. package/test/usersBootstrapContributor.test.js +40 -1
  45. package/test/usersPackageScaffoldContract.test.js +70 -3
  46. package/test/usersRouteRequestInputValidator.test.js +92 -23
  47. package/test/usersRouteResources.test.js +28 -18
  48. package/src/server/common/resources/userProfilesResource.js +0 -203
  49. package/src/server/common/validators/authenticatedUserValidator.js +0 -43
  50. package/src/shared/resources/resolveGlobalArrayRegistry.js +0 -6
  51. package/src/shared/resources/userSettingsFields.js +0 -76
  52. package/templates/packages/main/src/shared/resources/userSettingsFields.js +0 -138
  53. package/templates/packages/users/src/server/actionIds.js +0 -6
  54. package/templates/packages/users/src/server/listConfig.js +0 -16
  55. package/test/settingsFieldRegistriesSingleton.test.js +0 -14
  56. package/test-support/registerDefaultSettingsFields.js +0 -2
@@ -1,156 +1,164 @@
1
1
  import {
2
- normalizeDbRecordId,
3
2
  normalizeRecordId,
4
- toIsoString,
5
3
  nowDb,
6
4
  isDuplicateEntryError,
7
5
  createWithTransaction
8
6
  } from "./repositoryUtils.js";
9
- import { DEFAULT_USER_SETTINGS } from "../../../shared/settings.js";
10
7
  import {
11
- userSettingsFields
12
- } from "../../../shared/resources/userSettingsFields.js";
13
-
14
- function mapRow(row) {
15
- if (!row) {
16
- return null;
17
- }
18
-
19
- const mapped = {
20
- userId: normalizeDbRecordId(row.user_id, { fallback: "" }),
21
- passwordSignInEnabled: row.password_sign_in_enabled == null ? true : Boolean(row.password_sign_in_enabled),
22
- passwordSetupRequired: row.password_setup_required == null ? false : Boolean(row.password_setup_required),
23
- createdAt: toIsoString(row.created_at),
24
- updatedAt: toIsoString(row.updated_at)
25
- };
8
+ createJsonApiInputRecord,
9
+ createJsonRestContext,
10
+ simplifyJsonApiDocument
11
+ } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
12
+ import { DEFAULT_USER_SETTINGS } from "../../../shared/settings.js";
26
13
 
27
- for (const field of userSettingsFields) {
28
- const column = field.repository.column;
29
- const value = Object.hasOwn(row, column)
30
- ? row[column]
31
- : field.resolveDefault({
32
- settings: mapped,
33
- row
34
- });
35
- mapped[field.key] = field.normalizeOutput(value, {
36
- settings: mapped,
37
- row
38
- });
14
+ const RESOURCE_TYPE = "userSettings";
15
+ const USER_SETTINGS_PATCH_FIELDS = Object.freeze([
16
+ "theme",
17
+ "locale",
18
+ "timeZone",
19
+ "dateFormat",
20
+ "numberFormat",
21
+ "currencyCode",
22
+ "avatarSize",
23
+ "productUpdates",
24
+ "accountActivity",
25
+ "securityAlerts",
26
+ "passwordSignInEnabled",
27
+ "passwordSetupRequired"
28
+ ]);
29
+
30
+ function pickPatchFields(source = {}) {
31
+ const patch = {};
32
+
33
+ for (const fieldName of USER_SETTINGS_PATCH_FIELDS) {
34
+ if (Object.hasOwn(source, fieldName)) {
35
+ patch[fieldName] = source[fieldName];
36
+ }
39
37
  }
40
38
 
41
- return mapped;
39
+ return patch;
42
40
  }
43
41
 
44
- function normalizeBoolean(value, fallback = false) {
45
- if (value === undefined) {
46
- return fallback;
47
- }
48
- return value === true;
49
- }
50
-
51
- function createInsertPayload(userId) {
52
- const normalizedUserId = normalizeRecordId(userId, { fallback: null });
53
- if (!normalizedUserId) {
54
- throw new TypeError("userSettingsRepository requires a valid user id.");
55
- }
56
-
57
- const payload = {
58
- user_id: normalizedUserId,
59
- password_sign_in_enabled: DEFAULT_USER_SETTINGS.passwordSignInEnabled,
60
- password_setup_required: DEFAULT_USER_SETTINGS.passwordSetupRequired,
61
- created_at: nowDb(),
62
- updated_at: nowDb()
42
+ function createDefaultUserSettingsCreatePayload(userId) {
43
+ return {
44
+ id: userId,
45
+ theme: DEFAULT_USER_SETTINGS.theme,
46
+ locale: DEFAULT_USER_SETTINGS.locale,
47
+ timeZone: DEFAULT_USER_SETTINGS.timeZone,
48
+ dateFormat: DEFAULT_USER_SETTINGS.dateFormat,
49
+ numberFormat: DEFAULT_USER_SETTINGS.numberFormat,
50
+ currencyCode: DEFAULT_USER_SETTINGS.currencyCode,
51
+ avatarSize: DEFAULT_USER_SETTINGS.avatarSize,
52
+ productUpdates: DEFAULT_USER_SETTINGS.productUpdates,
53
+ accountActivity: DEFAULT_USER_SETTINGS.accountActivity,
54
+ securityAlerts: DEFAULT_USER_SETTINGS.securityAlerts,
55
+ passwordSignInEnabled: DEFAULT_USER_SETTINGS.passwordSignInEnabled,
56
+ passwordSetupRequired: DEFAULT_USER_SETTINGS.passwordSetupRequired
63
57
  };
64
-
65
- const resolvedDefaults = {};
66
- for (const field of userSettingsFields) {
67
- const defaultValue = field.resolveDefault({
68
- settings: resolvedDefaults
69
- });
70
- payload[field.repository.column] = field.normalizeInput(defaultValue, {
71
- payload: resolvedDefaults,
72
- settings: resolvedDefaults
73
- });
74
- resolvedDefaults[field.key] = field.normalizeOutput(defaultValue, {
75
- settings: resolvedDefaults
76
- });
77
- }
78
-
79
- return payload;
80
58
  }
81
59
 
82
- function createRepository(knex) {
60
+ function createRepository({ api, knex } = {}) {
61
+ if (!api?.resources?.userSettings) {
62
+ throw new TypeError("userSettingsRepository requires json-rest-api userSettings resource.");
63
+ }
83
64
  if (typeof knex !== "function") {
84
65
  throw new TypeError("userSettingsRepository requires knex.");
85
66
  }
86
67
  const withTransaction = createWithTransaction(knex);
87
68
 
69
+ async function queryFirst(filters = {}, options = {}) {
70
+ const result = await api.resources.userSettings.query(
71
+ {
72
+ queryParams: {
73
+ filters
74
+ },
75
+ transaction: options?.trx || null,
76
+ simplified: false
77
+ },
78
+ createJsonRestContext(options?.context || null)
79
+ );
80
+
81
+ const rows = simplifyJsonApiDocument(result);
82
+ return Array.isArray(rows) ? rows[0] || null : null;
83
+ }
84
+
88
85
  async function findByUserId(userId, options = {}) {
89
- const client = options?.trx || knex;
90
86
  const normalizedUserId = normalizeRecordId(userId, { fallback: null });
91
87
  if (!normalizedUserId) {
92
88
  return null;
93
89
  }
94
90
 
95
- const row = await client("user_settings").where({ user_id: normalizedUserId }).first();
96
- return mapRow(row);
91
+ return queryFirst({ id: normalizedUserId }, options);
97
92
  }
98
93
 
99
94
  async function ensureForUserId(userId, options = {}) {
100
- const client = options?.trx || knex;
101
95
  const normalizedUserId = normalizeRecordId(userId, { fallback: null });
102
96
  if (!normalizedUserId) {
103
97
  throw new TypeError("userSettingsRepository.ensureForUserId requires a valid user id.");
104
98
  }
105
99
 
106
- const existing = await findByUserId(normalizedUserId, { trx: client });
100
+ const existing = await findByUserId(normalizedUserId, options);
107
101
  if (existing) {
108
102
  return existing;
109
103
  }
110
104
 
111
105
  try {
112
- await client("user_settings").insert(createInsertPayload(normalizedUserId));
106
+ await api.resources.userSettings.post(
107
+ {
108
+ inputRecord: createJsonApiInputRecord(
109
+ RESOURCE_TYPE,
110
+ createDefaultUserSettingsCreatePayload(normalizedUserId),
111
+ {
112
+ id: normalizedUserId
113
+ }
114
+ ),
115
+ transaction: options?.trx || null,
116
+ simplified: false
117
+ },
118
+ createJsonRestContext(options?.context || null)
119
+ );
113
120
  } catch (error) {
114
121
  if (!isDuplicateEntryError(error)) {
115
122
  throw error;
116
123
  }
117
124
  }
118
125
 
119
- return findByUserId(normalizedUserId, { trx: client });
126
+ return findByUserId(normalizedUserId, options);
120
127
  }
121
128
 
122
129
  async function patchUserSettings(userId, patch = {}, options = {}) {
123
- const client = options?.trx || knex;
124
130
  const normalizedUserId = normalizeRecordId(userId, { fallback: null });
125
131
  if (!normalizedUserId) {
126
132
  throw new TypeError("userSettingsRepository.patchUserSettings requires a valid user id.");
127
133
  }
128
134
 
129
- const ensured = await ensureForUserId(normalizedUserId, { trx: client });
135
+ await ensureForUserId(normalizedUserId, options);
130
136
  const source = patch && typeof patch === "object" ? patch : {};
137
+ const updatePayload = pickPatchFields(source);
131
138
 
132
- const dbPatch = {
133
- updated_at: nowDb()
134
- };
135
-
136
- for (const field of userSettingsFields) {
137
- if (!Object.hasOwn(source, field.key)) {
138
- continue;
139
- }
140
- dbPatch[field.repository.column] = field.normalizeInput(source[field.key], {
141
- payload: source,
142
- settings: ensured
143
- });
139
+ if (Object.keys(updatePayload).length < 1) {
140
+ return findByUserId(normalizedUserId, options);
144
141
  }
145
142
 
146
- if (Object.hasOwn(source, "passwordSignInEnabled")) {
147
- dbPatch.password_sign_in_enabled = normalizeBoolean(source.passwordSignInEnabled, ensured.passwordSignInEnabled);
148
- }
149
- if (Object.hasOwn(source, "passwordSetupRequired")) {
150
- dbPatch.password_setup_required = normalizeBoolean(source.passwordSetupRequired, ensured.passwordSetupRequired);
151
- }
152
- await client("user_settings").where({ user_id: normalizedUserId }).update(dbPatch);
153
- return findByUserId(normalizedUserId, { trx: client });
143
+ await api.resources.userSettings.patch(
144
+ {
145
+ inputRecord: createJsonApiInputRecord(
146
+ RESOURCE_TYPE,
147
+ {
148
+ ...updatePayload,
149
+ updatedAt: nowDb()
150
+ },
151
+ {
152
+ id: normalizedUserId
153
+ }
154
+ ),
155
+ transaction: options?.trx || null,
156
+ simplified: false
157
+ },
158
+ createJsonRestContext(options?.context || null)
159
+ );
160
+
161
+ return findByUserId(normalizedUserId, options);
154
162
  }
155
163
 
156
164
  async function updatePreferences(userId, patch = {}, options = {}) {
@@ -166,9 +174,9 @@ function createRepository(knex) {
166
174
  userId,
167
175
  {
168
176
  passwordSignInEnabled: enabled,
169
- passwordSetupRequired: Object.hasOwn(options, "passwordSetupRequired")
170
- ? options.passwordSetupRequired
171
- : undefined
177
+ ...(Object.hasOwn(options, "passwordSetupRequired")
178
+ ? { passwordSetupRequired: options.passwordSetupRequired }
179
+ : {})
172
180
  },
173
181
  options
174
182
  );
@@ -190,4 +198,4 @@ function createRepository(knex) {
190
198
  });
191
199
  }
192
200
 
193
- export { createRepository, mapRow };
201
+ export { createRepository };
@@ -0,0 +1,10 @@
1
+ function resolveAccountSettingsResourceId(_record, context = {}) {
2
+ const userId = context?.request?.user?.id;
3
+ if (userId == null || String(userId).trim() === "") {
4
+ throw new Error("JSON:API account settings response requires request.user.id.");
5
+ }
6
+
7
+ return userId;
8
+ }
9
+
10
+ export { resolveAccountSettingsResourceId };
@@ -1,10 +1,9 @@
1
1
  import { AppError } from "@jskit-ai/kernel/server/runtime";
2
2
  import { requireServiceMethod } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
3
3
  import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
4
- import { normalizeObject } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { normalizeBoolean, normalizeObject } from "@jskit-ai/kernel/shared/support/normalize";
5
5
  import { accountAvatarFormatter } from "./common/formatters/accountAvatarFormatter.js";
6
- import { authenticatedUserValidator } from "./common/validators/authenticatedUserValidator.js";
7
- import { userSettingsFields } from "../shared/resources/userSettingsFields.js";
6
+ import { USER_SETTINGS_BOOTSTRAP_KEYS } from "../shared/resources/userSettingsResource.js";
8
7
 
9
8
  function getOAuthProviderCatalogPayload(authService) {
10
9
  if (!authService || typeof authService.getOAuthProviderCatalog !== "function") {
@@ -31,28 +30,20 @@ function getOAuthProviderCatalogPayload(authService) {
31
30
  };
32
31
  }
33
32
 
34
- function normalizeBoolean(value, fallback) {
35
- if (typeof value === "boolean") {
36
- return value;
33
+ function resolveBooleanConfigValue(value, fallback) {
34
+ if (value === undefined || value === null || value === "") {
35
+ return fallback;
37
36
  }
38
- if (typeof value === "string") {
39
- const normalized = normalizeLowerText(value);
40
- if (normalized === "true") {
41
- return true;
42
- }
43
- if (normalized === "false") {
44
- return false;
45
- }
46
- }
47
- return fallback;
37
+
38
+ return normalizeBoolean(value);
48
39
  }
49
40
 
50
41
  function resolveAppState(appConfig = {}) {
51
42
  const features = {
52
- assistantEnabled: normalizeBoolean(appConfig.assistantEnabled, false),
43
+ assistantEnabled: resolveBooleanConfigValue(appConfig.assistantEnabled, false),
53
44
  assistantRequiredPermission: normalizeText(appConfig.assistantRequiredPermission),
54
- socialEnabled: normalizeBoolean(appConfig.socialEnabled, false),
55
- socialFederationEnabled: normalizeBoolean(appConfig.socialFederationEnabled, false)
45
+ socialEnabled: resolveBooleanConfigValue(appConfig.socialEnabled, false),
46
+ socialFederationEnabled: resolveBooleanConfigValue(appConfig.socialFederationEnabled, false)
56
47
  };
57
48
 
58
49
  return {
@@ -79,18 +70,8 @@ function mapUserSettingsBootstrap(settings = {}) {
79
70
  const source = settings && typeof settings === "object" ? settings : {};
80
71
  const mapped = {};
81
72
 
82
- for (const field of userSettingsFields) {
83
- if (field.includeInBootstrap === false) {
84
- continue;
85
- }
86
- const rawValue = Object.hasOwn(source, field.key)
87
- ? source[field.key]
88
- : field.resolveDefault({
89
- settings: source
90
- });
91
- mapped[field.key] = field.normalizeOutput(rawValue, {
92
- settings: source
93
- });
73
+ for (const fieldKey of USER_SETTINGS_BOOTSTRAP_KEYS) {
74
+ mapped[fieldKey] = source[fieldKey];
94
75
  }
95
76
 
96
77
  return mapped;
@@ -130,7 +111,7 @@ function createUsersBootstrapContributor({
130
111
  throw new AppError(503, "Authentication service temporarily unavailable. Please retry.");
131
112
  }
132
113
 
133
- const normalizedUser = authenticatedUserValidator.normalize(authResult?.authenticated ? authResult?.profile : null);
114
+ const normalizedUser = authResult?.authenticated === true ? authResult?.profile || null : null;
134
115
  const inheritedSurfaceAccess = normalizeObject(existingPayload?.surfaceAccess);
135
116
  let payload = createAnonymousBootstrapPayload({
136
117
  appState,
@@ -0,0 +1,83 @@
1
+ import { createSchema } from "json-rest-schema";
2
+
3
+ const accountAvatarOutputSchema = createSchema({
4
+ uploadedUrl: { type: "string", required: true, nullable: true },
5
+ gravatarUrl: { type: "string", required: true, minLength: 1 },
6
+ effectiveUrl: { type: "string", required: true, minLength: 1 },
7
+ hasUploadedAvatar: { type: "boolean", required: true },
8
+ size: { type: "number", required: true, min: 1 },
9
+ version: { type: "string", required: true, nullable: true }
10
+ });
11
+
12
+ const userProfileOutputSchema = createSchema({
13
+ displayName: { type: "string", required: true },
14
+ email: { type: "string", required: true },
15
+ emailManagedBy: { type: "string", required: true },
16
+ emailChangeFlow: { type: "string", required: true },
17
+ avatar: {
18
+ type: "object",
19
+ required: true,
20
+ schema: accountAvatarOutputSchema
21
+ }
22
+ });
23
+
24
+ const accountSecurityMfaSchema = createSchema({
25
+ status: { type: "string", required: true },
26
+ enrolled: { type: "boolean", required: true },
27
+ methods: {
28
+ type: "array",
29
+ required: true,
30
+ items: { type: "string", minLength: 1 }
31
+ }
32
+ });
33
+
34
+ const accountSecuritySessionsSchema = createSchema({
35
+ canSignOutOtherDevices: { type: "boolean", required: true }
36
+ });
37
+
38
+ const accountSecurityAuthPolicySchema = createSchema({
39
+ minimumEnabledMethods: { type: "integer", required: true, min: 1 },
40
+ enabledMethodsCount: { type: "integer", required: true, min: 0 }
41
+ });
42
+
43
+ const accountSecurityAuthMethodSchema = createSchema({
44
+ id: { type: "string", required: true },
45
+ kind: { type: "string", required: true },
46
+ provider: { type: "string", required: true, nullable: true },
47
+ label: { type: "string", required: true },
48
+ configured: { type: "boolean", required: true },
49
+ enabled: { type: "boolean", required: true },
50
+ canEnable: { type: "boolean", required: true },
51
+ canDisable: { type: "boolean", required: true },
52
+ supportsSecretUpdate: { type: "boolean", required: true },
53
+ requiresCurrentPassword: { type: "boolean", required: true }
54
+ });
55
+
56
+ const accountSecurityStatusSchema = createSchema({
57
+ mfa: {
58
+ type: "object",
59
+ required: true,
60
+ schema: accountSecurityMfaSchema
61
+ },
62
+ sessions: {
63
+ type: "object",
64
+ required: true,
65
+ schema: accountSecuritySessionsSchema
66
+ },
67
+ authPolicy: {
68
+ type: "object",
69
+ required: true,
70
+ schema: accountSecurityAuthPolicySchema
71
+ },
72
+ authMethods: {
73
+ type: "array",
74
+ required: true,
75
+ items: accountSecurityAuthMethodSchema
76
+ }
77
+ });
78
+
79
+ export {
80
+ accountAvatarOutputSchema,
81
+ userProfileOutputSchema,
82
+ accountSecurityStatusSchema
83
+ };