@jskit-ai/users-core 0.1.4

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 (148) hide show
  1. package/package.descriptor.mjs +464 -0
  2. package/package.json +35 -0
  3. package/src/server/UsersCoreServiceProvider.js +74 -0
  4. package/src/server/accountNotifications/accountNotificationsActions.js +39 -0
  5. package/src/server/accountNotifications/accountNotificationsService.js +41 -0
  6. package/src/server/accountNotifications/bootAccountNotificationsRoutes.js +41 -0
  7. package/src/server/accountNotifications/registerAccountNotifications.js +39 -0
  8. package/src/server/accountPreferences/accountPreferencesActions.js +39 -0
  9. package/src/server/accountPreferences/accountPreferencesService.js +41 -0
  10. package/src/server/accountPreferences/bootAccountPreferencesRoutes.js +41 -0
  11. package/src/server/accountPreferences/registerAccountPreferences.js +39 -0
  12. package/src/server/accountProfile/accountProfileActions.js +137 -0
  13. package/src/server/accountProfile/accountProfileService.js +124 -0
  14. package/src/server/accountProfile/avatarService.js +141 -0
  15. package/src/server/accountProfile/avatarStorageService.js +132 -0
  16. package/src/server/accountProfile/bootAccountProfileRoutes.js +166 -0
  17. package/src/server/accountProfile/registerAccountProfile.js +62 -0
  18. package/src/server/accountProfile/registerAvatarMultipartSupport.js +43 -0
  19. package/src/server/accountSecurity/accountSecurityActions.js +144 -0
  20. package/src/server/accountSecurity/accountSecurityService.js +103 -0
  21. package/src/server/accountSecurity/bootAccountSecurityRoutes.js +183 -0
  22. package/src/server/accountSecurity/registerAccountSecurity.js +31 -0
  23. package/src/server/common/README.md +21 -0
  24. package/src/server/common/contributors/README.md +11 -0
  25. package/src/server/common/contributors/workspaceActionContextContributor.js +79 -0
  26. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +34 -0
  27. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +79 -0
  28. package/src/server/common/diTokens.js +21 -0
  29. package/src/server/common/formatters/README.md +11 -0
  30. package/src/server/common/formatters/accountAvatarFormatter.js +42 -0
  31. package/src/server/common/formatters/accountSecurityStatusFormatter.js +71 -0
  32. package/src/server/common/formatters/accountSettingsResponseFormatter.js +62 -0
  33. package/src/server/common/formatters/workspaceFormatter.js +46 -0
  34. package/src/server/common/registerCommonRepositories.js +45 -0
  35. package/src/server/common/registerSharedApi.js +9 -0
  36. package/src/server/common/repositories/README.md +24 -0
  37. package/src/server/common/repositories/repositoryUtils.js +50 -0
  38. package/src/server/common/repositories/userProfilesRepository.js +251 -0
  39. package/src/server/common/repositories/userSettingsRepository.js +179 -0
  40. package/src/server/common/repositories/workspaceInvitesRepository.js +172 -0
  41. package/src/server/common/repositories/workspaceMembershipsRepository.js +157 -0
  42. package/src/server/common/repositories/workspacesRepository.js +183 -0
  43. package/src/server/common/routes/README.md +11 -0
  44. package/src/server/common/services/README.md +12 -0
  45. package/src/server/common/services/accountContextService.js +31 -0
  46. package/src/server/common/services/authProfileSyncService.js +128 -0
  47. package/src/server/common/services/workspaceContextService.js +270 -0
  48. package/src/server/common/support/deepFreeze.js +17 -0
  49. package/src/server/common/support/realtimeServiceEvents.js +94 -0
  50. package/src/server/common/support/resolveActionUser.js +11 -0
  51. package/src/server/common/support/workspaceRoutePaths.js +17 -0
  52. package/src/server/common/validators/README.md +11 -0
  53. package/src/server/common/validators/authenticatedUserValidator.js +42 -0
  54. package/src/server/common/validators/routeParamsValidator.js +62 -0
  55. package/src/server/consoleSettings/bootConsoleSettingsRoutes.js +64 -0
  56. package/src/server/consoleSettings/consoleService.js +36 -0
  57. package/src/server/consoleSettings/consoleSettingsActions.js +55 -0
  58. package/src/server/consoleSettings/consoleSettingsRepository.js +111 -0
  59. package/src/server/consoleSettings/consoleSettingsService.js +40 -0
  60. package/src/server/consoleSettings/registerConsoleSettings.js +57 -0
  61. package/src/server/registerWorkspaceBootstrap.js +36 -0
  62. package/src/server/registerWorkspaceCore.js +95 -0
  63. package/src/server/support/resolveWorkspace.js +16 -0
  64. package/src/server/support/workspaceActionSurfaces.js +135 -0
  65. package/src/server/support/workspaceInvitationsPolicy.js +45 -0
  66. package/src/server/support/workspaceRouteInput.js +22 -0
  67. package/src/server/workspaceBootstrapContributor.js +401 -0
  68. package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +73 -0
  69. package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +19 -0
  70. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +65 -0
  71. package/src/server/workspaceMembers/bootWorkspaceMembers.js +238 -0
  72. package/src/server/workspaceMembers/registerWorkspaceMembers.js +112 -0
  73. package/src/server/workspaceMembers/workspaceMembersActions.js +186 -0
  74. package/src/server/workspaceMembers/workspaceMembersService.js +210 -0
  75. package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +63 -0
  76. package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +128 -0
  77. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +74 -0
  78. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +137 -0
  79. package/src/server/workspaceSettings/bootWorkspaceSettings.js +77 -0
  80. package/src/server/workspaceSettings/registerWorkspaceSettings.js +67 -0
  81. package/src/server/workspaceSettings/workspaceSettingsActions.js +72 -0
  82. package/src/server/workspaceSettings/workspaceSettingsRepository.js +135 -0
  83. package/src/server/workspaceSettings/workspaceSettingsService.js +65 -0
  84. package/src/shared/events/usersEvents.js +19 -0
  85. package/src/shared/index.js +91 -0
  86. package/src/shared/operationMessages.js +16 -0
  87. package/src/shared/resources/consoleSettingsFields.js +55 -0
  88. package/src/shared/resources/consoleSettingsResource.js +139 -0
  89. package/src/shared/resources/resolveGlobalArrayRegistry.js +6 -0
  90. package/src/shared/resources/userProfileResource.js +148 -0
  91. package/src/shared/resources/userSettingsFields.js +71 -0
  92. package/src/shared/resources/userSettingsResource.js +416 -0
  93. package/src/shared/resources/workspaceMembersResource.js +352 -0
  94. package/src/shared/resources/workspacePendingInvitationsResource.js +87 -0
  95. package/src/shared/resources/workspaceResource.js +149 -0
  96. package/src/shared/resources/workspaceSettingsFields.js +60 -0
  97. package/src/shared/resources/workspaceSettingsResource.js +178 -0
  98. package/src/shared/roles.js +136 -0
  99. package/src/shared/settings.js +31 -0
  100. package/src/shared/support/usersApiPaths.js +34 -0
  101. package/src/shared/support/usersVisibility.js +45 -0
  102. package/src/shared/support/workspacePathModel.js +145 -0
  103. package/src/shared/tenancyMode.js +35 -0
  104. package/src/shared/tenancyProfile.js +73 -0
  105. package/templates/config/workspaceRoles.js +30 -0
  106. package/templates/migrations/users_core_console_owner.cjs +39 -0
  107. package/templates/migrations/users_core_initial.cjs +118 -0
  108. package/templates/migrations/users_core_profile_username.cjs +98 -0
  109. package/templates/packages/main/src/shared/resources/consoleSettingsFields.js +11 -0
  110. package/templates/packages/main/src/shared/resources/userSettingsFields.js +138 -0
  111. package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +105 -0
  112. package/test/authProfileSyncService.test.js +119 -0
  113. package/test/avatarService.test.js +114 -0
  114. package/test/avatarStorageService.test.js +61 -0
  115. package/test/consoleService.test.js +57 -0
  116. package/test/consoleSettingsService.test.js +86 -0
  117. package/test/exportsContract.test.js +38 -0
  118. package/test/registerAvatarMultipartSupport.test.js +64 -0
  119. package/test/registerServiceRealtimeEvents.test.js +160 -0
  120. package/test/registerWorkspaceDirectory.test.js +26 -0
  121. package/test/registerWorkspaceSettings.test.js +44 -0
  122. package/test/resourcesCanonical.test.js +90 -0
  123. package/test/roles.test.js +74 -0
  124. package/test/settingsFieldRegistriesSingleton.test.js +24 -0
  125. package/test/tenancyProfile.test.js +67 -0
  126. package/test/userSettingsResource.test.js +31 -0
  127. package/test/usersApiPaths.test.js +31 -0
  128. package/test/usersRouteRequestInputValidator.test.js +556 -0
  129. package/test/usersRouteResources.test.js +113 -0
  130. package/test/usersRouteValidators.test.js +49 -0
  131. package/test/usersVisibility.test.js +22 -0
  132. package/test/workspaceActionContextContributor.test.js +251 -0
  133. package/test/workspaceActionSurfaces.test.js +105 -0
  134. package/test/workspaceAuthPolicyContextResolver.test.js +119 -0
  135. package/test/workspaceBootstrapContributor.test.js +466 -0
  136. package/test/workspaceInvitationsPolicy.test.js +71 -0
  137. package/test/workspaceInvitesRepository.test.js +111 -0
  138. package/test/workspaceMembersService.test.js +400 -0
  139. package/test/workspacePathModel.test.js +93 -0
  140. package/test/workspacePendingInvitationsResource.test.js +38 -0
  141. package/test/workspacePendingInvitationsService.test.js +151 -0
  142. package/test/workspaceRouteVisibilityResolver.test.js +83 -0
  143. package/test/workspaceService.test.js +480 -0
  144. package/test/workspaceSettingsActions.test.js +42 -0
  145. package/test/workspaceSettingsRepository.test.js +156 -0
  146. package/test/workspaceSettingsResource.test.js +156 -0
  147. package/test/workspaceSettingsService.test.js +120 -0
  148. package/test-support/registerDefaultSettingsFields.js +3 -0
@@ -0,0 +1,30 @@
1
+ export const workspaceRoles = {};
2
+
3
+ workspaceRoles.defaultInviteRole = "member";
4
+ workspaceRoles.roles = {};
5
+
6
+ workspaceRoles.roles.owner = {
7
+ assignable: false,
8
+ permissions: []
9
+ };
10
+ workspaceRoles.roles.owner.permissions.push("*");
11
+
12
+ workspaceRoles.roles.admin = {
13
+ assignable: true,
14
+ permissions: []
15
+ };
16
+ workspaceRoles.roles.admin.permissions.push(
17
+ "workspace.roles.view",
18
+ "workspace.settings.view",
19
+ "workspace.settings.update",
20
+ "workspace.members.view",
21
+ "workspace.members.invite",
22
+ "workspace.members.manage",
23
+ "workspace.invites.revoke"
24
+ );
25
+
26
+ workspaceRoles.roles.member = {
27
+ assignable: true,
28
+ permissions: []
29
+ };
30
+ workspaceRoles.roles.member.permissions.push("workspace.settings.view");
@@ -0,0 +1,39 @@
1
+ // JSKIT_MIGRATION_ID: users-core-console-owner
2
+
3
+ /**
4
+ * @param {import('knex').Knex} knex
5
+ */
6
+ exports.up = async function up(knex) {
7
+ const hasConsoleSettingsTable = await knex.schema.hasTable("console_settings");
8
+ if (!hasConsoleSettingsTable) {
9
+ return;
10
+ }
11
+
12
+ const hasOwnerUserId = await knex.schema.hasColumn("console_settings", "owner_user_id");
13
+ if (hasOwnerUserId) {
14
+ return;
15
+ }
16
+
17
+ await knex.schema.alterTable("console_settings", (table) => {
18
+ table.integer("owner_user_id").unsigned().nullable();
19
+ });
20
+ };
21
+
22
+ /**
23
+ * @param {import('knex').Knex} knex
24
+ */
25
+ exports.down = async function down(knex) {
26
+ const hasConsoleSettingsTable = await knex.schema.hasTable("console_settings");
27
+ if (!hasConsoleSettingsTable) {
28
+ return;
29
+ }
30
+
31
+ const hasOwnerUserId = await knex.schema.hasColumn("console_settings", "owner_user_id");
32
+ if (!hasOwnerUserId) {
33
+ return;
34
+ }
35
+
36
+ await knex.schema.alterTable("console_settings", (table) => {
37
+ table.dropColumn("owner_user_id");
38
+ });
39
+ };
@@ -0,0 +1,118 @@
1
+ // JSKIT_MIGRATION_ID: users-core-initial-schema
2
+
3
+ /**
4
+ * @param {import('knex').Knex} knex
5
+ */
6
+ exports.up = async function up(knex) {
7
+ await knex.schema.createTable("user_profiles", (table) => {
8
+ table.increments("id").primary();
9
+ table.string("auth_provider", 64).notNullable();
10
+ table.string("auth_provider_user_id", 191).notNullable();
11
+ table.string("email", 255).notNullable();
12
+ table.string("username", 120).notNullable();
13
+ table.string("display_name", 160).notNullable();
14
+ table.string("avatar_storage_key", 512).nullable();
15
+ table.string("avatar_version", 64).nullable();
16
+ table.timestamp("avatar_updated_at", { useTz: false }).nullable();
17
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
18
+ table.unique(["auth_provider", "auth_provider_user_id"], "uq_user_profiles_identity");
19
+ table.unique(["email"], "uq_user_profiles_email");
20
+ table.unique(["username"], "uq_user_profiles_username");
21
+ });
22
+
23
+ await knex.schema.createTable("workspaces", (table) => {
24
+ table.increments("id").primary();
25
+ table.string("slug", 120).notNullable().unique();
26
+ table.string("name", 160).notNullable();
27
+ table.integer("owner_user_id").unsigned().notNullable().references("id").inTable("user_profiles").onDelete("CASCADE");
28
+ table.boolean("is_personal").notNullable().defaultTo(true);
29
+ table.string("avatar_url", 512).notNullable().defaultTo("");
30
+ table.string("color", 7).notNullable().defaultTo("#0F6B54");
31
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
32
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
33
+ table.timestamp("deleted_at", { useTz: false }).nullable();
34
+ });
35
+
36
+ await knex.schema.createTable("workspace_memberships", (table) => {
37
+ table.increments("id").primary();
38
+ table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
39
+ table.integer("user_id").unsigned().notNullable().references("id").inTable("user_profiles").onDelete("CASCADE");
40
+ table.string("role_id", 64).notNullable().defaultTo("member");
41
+ table.string("status", 32).notNullable().defaultTo("active");
42
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
43
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
44
+ table.unique(["workspace_id", "user_id"], "uq_workspace_memberships_workspace_user");
45
+ });
46
+
47
+ await knex.schema.createTable("workspace_settings", (table) => {
48
+ table.integer("workspace_id").unsigned().primary().references("id").inTable("workspaces").onDelete("CASCADE");
49
+ table.string("name", 160).notNullable().defaultTo("Workspace");
50
+ table.string("avatar_url", 512).notNullable().defaultTo("");
51
+ table.string("color", 7).notNullable().defaultTo("#0F6B54");
52
+ table.boolean("invites_enabled").notNullable().defaultTo(true);
53
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
54
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
55
+ });
56
+
57
+ await knex.schema.createTable("workspace_invites", (table) => {
58
+ table.increments("id").primary();
59
+ table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
60
+ table.string("email", 255).notNullable();
61
+ table.string("role_id", 64).notNullable().defaultTo("member");
62
+ table.string("status", 32).notNullable().defaultTo("pending");
63
+ table.string("token_hash", 191).notNullable();
64
+ table.integer("invited_by_user_id").unsigned().nullable().references("id").inTable("user_profiles").onDelete("SET NULL");
65
+ table.timestamp("expires_at", { useTz: false }).nullable();
66
+ table.timestamp("accepted_at", { useTz: false }).nullable();
67
+ table.timestamp("revoked_at", { useTz: false }).nullable();
68
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
69
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
70
+ table.unique(["token_hash"], "uq_workspace_invites_token_hash");
71
+ table.index(["workspace_id", "status"], "idx_workspace_invites_workspace_status");
72
+ });
73
+
74
+ await knex.schema.createTable("user_settings", (table) => {
75
+ table.integer("user_id").unsigned().primary().references("id").inTable("user_profiles").onDelete("CASCADE");
76
+ table.integer("last_active_workspace_id").unsigned().nullable().references("id").inTable("workspaces").onDelete("SET NULL");
77
+ table.string("theme", 32).notNullable().defaultTo("system");
78
+ table.string("locale", 24).notNullable().defaultTo("en");
79
+ table.string("time_zone", 64).notNullable().defaultTo("UTC");
80
+ table.string("date_format", 32).notNullable().defaultTo("yyyy-mm-dd");
81
+ table.string("number_format", 32).notNullable().defaultTo("1,234.56");
82
+ table.string("currency_code", 3).notNullable().defaultTo("USD");
83
+ table.integer("avatar_size").notNullable().defaultTo(64);
84
+ table.boolean("password_sign_in_enabled").notNullable().defaultTo(true);
85
+ table.boolean("password_setup_required").notNullable().defaultTo(false);
86
+ table.boolean("notify_product_updates").notNullable().defaultTo(true);
87
+ table.boolean("notify_account_activity").notNullable().defaultTo(true);
88
+ table.boolean("notify_security_alerts").notNullable().defaultTo(true);
89
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
90
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
91
+ });
92
+
93
+ await knex.schema.createTable("console_settings", (table) => {
94
+ table.integer("id").primary();
95
+ table.integer("owner_user_id").unsigned().nullable();
96
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
97
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
98
+ });
99
+
100
+ await knex("console_settings").insert({
101
+ id: 1,
102
+ created_at: knex.fn.now(),
103
+ updated_at: knex.fn.now()
104
+ });
105
+ };
106
+
107
+ /**
108
+ * @param {import('knex').Knex} knex
109
+ */
110
+ exports.down = async function down(knex) {
111
+ await knex.schema.dropTableIfExists("console_settings");
112
+ await knex.schema.dropTableIfExists("user_settings");
113
+ await knex.schema.dropTableIfExists("workspace_invites");
114
+ await knex.schema.dropTableIfExists("workspace_settings");
115
+ await knex.schema.dropTableIfExists("workspace_memberships");
116
+ await knex.schema.dropTableIfExists("workspaces");
117
+ await knex.schema.dropTableIfExists("user_profiles");
118
+ };
@@ -0,0 +1,98 @@
1
+ // JSKIT_MIGRATION_ID: users-core-profile-username
2
+
3
+ const USERNAME_MAX_LENGTH = 120;
4
+
5
+ function normalizeUsername(value) {
6
+ return String(value || "")
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .slice(0, USERNAME_MAX_LENGTH);
12
+ }
13
+
14
+ function usernameBaseFromEmail(email) {
15
+ const normalizedEmail = String(email || "").trim().toLowerCase();
16
+ const localPart = normalizedEmail.includes("@") ? normalizedEmail.split("@")[0] : normalizedEmail;
17
+ const username = normalizeUsername(localPart);
18
+ return username || "user";
19
+ }
20
+
21
+ function buildUsernameCandidate(baseUsername, suffix) {
22
+ const normalizedBase = normalizeUsername(baseUsername) || "user";
23
+ if (suffix < 1) {
24
+ return normalizedBase;
25
+ }
26
+
27
+ const suffixText = `-${suffix + 1}`;
28
+ const allowedBaseLength = USERNAME_MAX_LENGTH - suffixText.length;
29
+ const trimmedBase = normalizedBase.slice(0, allowedBaseLength);
30
+ return `${trimmedBase}${suffixText}`;
31
+ }
32
+
33
+ function resolveUniqueUsername(baseUsername, usedUsernames) {
34
+ for (let suffix = 0; suffix < 1000; suffix += 1) {
35
+ const candidate = buildUsernameCandidate(baseUsername, suffix);
36
+ if (!usedUsernames.has(candidate)) {
37
+ return candidate;
38
+ }
39
+ }
40
+ throw new Error("Unable to generate unique username for migration.");
41
+ }
42
+
43
+ /**
44
+ * @param {import('knex').Knex} knex
45
+ */
46
+ exports.up = async function up(knex) {
47
+ const hasUserProfilesTable = await knex.schema.hasTable("user_profiles");
48
+ if (!hasUserProfilesTable) {
49
+ return;
50
+ }
51
+
52
+ const hasUsername = await knex.schema.hasColumn("user_profiles", "username");
53
+ if (hasUsername) {
54
+ return;
55
+ }
56
+
57
+ await knex.schema.alterTable("user_profiles", (table) => {
58
+ table.string("username", USERNAME_MAX_LENGTH).nullable();
59
+ });
60
+
61
+ const profiles = await knex("user_profiles").select(["id", "email"]).orderBy("id", "asc");
62
+ const usedUsernames = new Set();
63
+
64
+ for (const profile of profiles) {
65
+ const nextUsername = resolveUniqueUsername(usernameBaseFromEmail(profile.email), usedUsernames);
66
+ usedUsernames.add(nextUsername);
67
+ await knex("user_profiles").where({ id: Number(profile.id) }).update({
68
+ username: nextUsername
69
+ });
70
+ }
71
+
72
+ await knex.schema.alterTable("user_profiles", (table) => {
73
+ table.string("username", USERNAME_MAX_LENGTH).notNullable().alter();
74
+ });
75
+
76
+ await knex.schema.alterTable("user_profiles", (table) => {
77
+ table.unique(["username"], "uq_user_profiles_username");
78
+ });
79
+ };
80
+
81
+ /**
82
+ * @param {import('knex').Knex} knex
83
+ */
84
+ exports.down = async function down(knex) {
85
+ const hasUserProfilesTable = await knex.schema.hasTable("user_profiles");
86
+ if (!hasUserProfilesTable) {
87
+ return;
88
+ }
89
+
90
+ const hasUsername = await knex.schema.hasColumn("user_profiles", "username");
91
+ if (!hasUsername) {
92
+ return;
93
+ }
94
+
95
+ await knex.schema.alterTable("user_profiles", (table) => {
96
+ table.dropColumn("username");
97
+ });
98
+ };
@@ -0,0 +1,11 @@
1
+ // @jskit-contract users.settings-fields.console.v1
2
+ // Append-only settings field registrations for console settings.
3
+
4
+ import {
5
+ defineField,
6
+ resetConsoleSettingsFields
7
+ } from "@jskit-ai/users-core/shared/resources/consoleSettingsFields";
8
+
9
+ resetConsoleSettingsFields();
10
+
11
+ void defineField;
@@ -0,0 +1,138 @@
1
+ import { Type } from "typebox";
2
+ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
3
+ import { DEFAULT_USER_SETTINGS } from "@jskit-ai/users-core/shared/settings";
4
+ import {
5
+ USER_SETTINGS_SECTIONS,
6
+ defineField,
7
+ resetUserSettingsFields
8
+ } from "@jskit-ai/users-core/shared/resources/userSettingsFields";
9
+
10
+ function normalizePositiveInteger(value, fallback) {
11
+ const numericValue = Number(value);
12
+ if (Number.isInteger(numericValue) && numericValue > 0) {
13
+ return numericValue;
14
+ }
15
+ return Number(fallback);
16
+ }
17
+
18
+ resetUserSettingsFields();
19
+
20
+ defineField({
21
+ key: "theme",
22
+ section: USER_SETTINGS_SECTIONS.PREFERENCES,
23
+ dbColumn: "theme",
24
+ required: true,
25
+ inputSchema: Type.String({ minLength: 1, maxLength: 32 }),
26
+ outputSchema: Type.String({ minLength: 1, maxLength: 32 }),
27
+ normalizeInput: (value) => normalizeText(value),
28
+ normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.theme,
29
+ resolveDefault: () => DEFAULT_USER_SETTINGS.theme
30
+ });
31
+
32
+ defineField({
33
+ key: "locale",
34
+ section: USER_SETTINGS_SECTIONS.PREFERENCES,
35
+ dbColumn: "locale",
36
+ required: true,
37
+ inputSchema: Type.String({ minLength: 1, maxLength: 24 }),
38
+ outputSchema: Type.String({ minLength: 1, maxLength: 24 }),
39
+ normalizeInput: (value) => normalizeLowerText(value),
40
+ normalizeOutput: (value) => normalizeLowerText(value) || DEFAULT_USER_SETTINGS.locale,
41
+ resolveDefault: () => DEFAULT_USER_SETTINGS.locale
42
+ });
43
+
44
+ defineField({
45
+ key: "timeZone",
46
+ section: USER_SETTINGS_SECTIONS.PREFERENCES,
47
+ dbColumn: "time_zone",
48
+ required: true,
49
+ inputSchema: Type.String({ minLength: 1, maxLength: 64 }),
50
+ outputSchema: Type.String({ minLength: 1, maxLength: 64 }),
51
+ normalizeInput: (value) => normalizeText(value),
52
+ normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.timeZone,
53
+ resolveDefault: () => DEFAULT_USER_SETTINGS.timeZone
54
+ });
55
+
56
+ defineField({
57
+ key: "dateFormat",
58
+ section: USER_SETTINGS_SECTIONS.PREFERENCES,
59
+ dbColumn: "date_format",
60
+ required: true,
61
+ inputSchema: Type.String({ minLength: 1, maxLength: 32 }),
62
+ outputSchema: Type.String({ minLength: 1, maxLength: 32 }),
63
+ normalizeInput: (value) => normalizeText(value),
64
+ normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.dateFormat,
65
+ resolveDefault: () => DEFAULT_USER_SETTINGS.dateFormat
66
+ });
67
+
68
+ defineField({
69
+ key: "numberFormat",
70
+ section: USER_SETTINGS_SECTIONS.PREFERENCES,
71
+ dbColumn: "number_format",
72
+ required: true,
73
+ inputSchema: Type.String({ minLength: 1, maxLength: 32 }),
74
+ outputSchema: Type.String({ minLength: 1, maxLength: 32 }),
75
+ normalizeInput: (value) => normalizeText(value),
76
+ normalizeOutput: (value) => normalizeText(value) || DEFAULT_USER_SETTINGS.numberFormat,
77
+ resolveDefault: () => DEFAULT_USER_SETTINGS.numberFormat
78
+ });
79
+
80
+ defineField({
81
+ key: "currencyCode",
82
+ section: USER_SETTINGS_SECTIONS.PREFERENCES,
83
+ dbColumn: "currency_code",
84
+ required: true,
85
+ inputSchema: Type.String({ minLength: 3, maxLength: 3, pattern: "^[A-Za-z]{3}$" }),
86
+ outputSchema: Type.String({ minLength: 3, maxLength: 3, pattern: "^[A-Z]{3}$" }),
87
+ normalizeInput: (value) => normalizeText(value).toUpperCase(),
88
+ normalizeOutput: (value) => normalizeText(value).toUpperCase() || DEFAULT_USER_SETTINGS.currencyCode,
89
+ resolveDefault: () => DEFAULT_USER_SETTINGS.currencyCode
90
+ });
91
+
92
+ defineField({
93
+ key: "avatarSize",
94
+ section: USER_SETTINGS_SECTIONS.PREFERENCES,
95
+ dbColumn: "avatar_size",
96
+ required: true,
97
+ inputSchema: Type.Integer({ minimum: 1 }),
98
+ outputSchema: Type.Integer({ minimum: 1 }),
99
+ normalizeInput: (value) => Number(value),
100
+ normalizeOutput: (value) => normalizePositiveInteger(value, DEFAULT_USER_SETTINGS.avatarSize),
101
+ resolveDefault: () => DEFAULT_USER_SETTINGS.avatarSize
102
+ });
103
+
104
+ defineField({
105
+ key: "productUpdates",
106
+ section: USER_SETTINGS_SECTIONS.NOTIFICATIONS,
107
+ dbColumn: "notify_product_updates",
108
+ required: true,
109
+ inputSchema: Type.Boolean(),
110
+ outputSchema: Type.Boolean(),
111
+ normalizeInput: (value) => value,
112
+ normalizeOutput: (value) => Boolean(value),
113
+ resolveDefault: () => DEFAULT_USER_SETTINGS.productUpdates
114
+ });
115
+
116
+ defineField({
117
+ key: "accountActivity",
118
+ section: USER_SETTINGS_SECTIONS.NOTIFICATIONS,
119
+ dbColumn: "notify_account_activity",
120
+ required: true,
121
+ inputSchema: Type.Boolean(),
122
+ outputSchema: Type.Boolean(),
123
+ normalizeInput: (value) => value,
124
+ normalizeOutput: (value) => Boolean(value),
125
+ resolveDefault: () => DEFAULT_USER_SETTINGS.accountActivity
126
+ });
127
+
128
+ defineField({
129
+ key: "securityAlerts",
130
+ section: USER_SETTINGS_SECTIONS.NOTIFICATIONS,
131
+ dbColumn: "notify_security_alerts",
132
+ required: true,
133
+ inputSchema: Type.Boolean(),
134
+ outputSchema: Type.Boolean(),
135
+ normalizeInput: (value) => value,
136
+ normalizeOutput: (value) => Boolean(value),
137
+ resolveDefault: () => DEFAULT_USER_SETTINGS.securityAlerts
138
+ });
@@ -0,0 +1,105 @@
1
+ // @jskit-contract users.settings-fields.workspace.v1
2
+ // Append-only settings field registrations for workspace settings.
3
+
4
+ import { Type } from "typebox";
5
+ import { normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
6
+ import { coerceWorkspaceColor } from "@jskit-ai/users-core/shared/settings";
7
+ import {
8
+ defineField,
9
+ resetWorkspaceSettingsFields
10
+ } from "@jskit-ai/users-core/shared/resources/workspaceSettingsFields";
11
+
12
+ function normalizeAvatarUrl(value) {
13
+ const avatarUrl = normalizeText(value);
14
+ if (!avatarUrl) {
15
+ return "";
16
+ }
17
+ if (!avatarUrl.startsWith("http://") && !avatarUrl.startsWith("https://")) {
18
+ return null;
19
+ }
20
+ try {
21
+ return new URL(avatarUrl).toString();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function normalizeHexColor(value) {
28
+ const color = normalizeText(value);
29
+ return /^#[0-9A-Fa-f]{6}$/.test(color) ? color.toUpperCase() : null;
30
+ }
31
+
32
+ resetWorkspaceSettingsFields();
33
+
34
+ defineField({
35
+ key: "name",
36
+ dbColumn: "name",
37
+ required: true,
38
+ inputSchema: Type.String({
39
+ minLength: 1,
40
+ maxLength: 160,
41
+ messages: {
42
+ required: "Workspace name is required.",
43
+ minLength: "Workspace name is required.",
44
+ maxLength: "Workspace name must be at most 160 characters.",
45
+ default: "Workspace name is required."
46
+ }
47
+ }),
48
+ outputSchema: Type.String({ minLength: 1, maxLength: 160 }),
49
+ normalizeInput: (value) => normalizeText(value),
50
+ normalizeOutput: (value) => normalizeText(value),
51
+ resolveDefault: ({ workspace = {} } = {}) => normalizeText(workspace.name) || "Workspace"
52
+ });
53
+
54
+ defineField({
55
+ key: "avatarUrl",
56
+ dbColumn: "avatar_url",
57
+ required: false,
58
+ inputSchema: Type.String({
59
+ pattern: "^(https?://.+)?$",
60
+ messages: {
61
+ pattern: "Workspace avatar URL must be a valid absolute URL (http:// or https://).",
62
+ default: "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
63
+ }
64
+ }),
65
+ outputSchema: Type.String(),
66
+ normalizeInput: normalizeAvatarUrl,
67
+ normalizeOutput: (value) => normalizeText(value),
68
+ resolveDefault: ({ workspace = {} } = {}) => normalizeText(workspace.avatarUrl)
69
+ });
70
+
71
+ defineField({
72
+ key: "color",
73
+ dbColumn: "color",
74
+ required: true,
75
+ inputSchema: Type.String({
76
+ minLength: 7,
77
+ maxLength: 7,
78
+ pattern: "^#[0-9A-Fa-f]{6}$",
79
+ messages: {
80
+ required: "Workspace color is required.",
81
+ pattern: "Workspace color must be a hex color like #0F6B54.",
82
+ default: "Workspace color must be a hex color like #0F6B54."
83
+ }
84
+ }),
85
+ outputSchema: Type.String({ minLength: 7, maxLength: 7, pattern: "^#[0-9A-Fa-f]{6}$" }),
86
+ normalizeInput: normalizeHexColor,
87
+ normalizeOutput: (value) => coerceWorkspaceColor(value),
88
+ resolveDefault: ({ workspace = {} } = {}) => coerceWorkspaceColor(workspace.color)
89
+ });
90
+
91
+ defineField({
92
+ key: "invitesEnabled",
93
+ dbColumn: "invites_enabled",
94
+ required: true,
95
+ inputSchema: Type.Boolean({
96
+ messages: {
97
+ required: "invitesEnabled is required.",
98
+ default: "invitesEnabled must be a boolean."
99
+ }
100
+ }),
101
+ outputSchema: Type.Boolean(),
102
+ normalizeInput: (value) => value === true,
103
+ normalizeOutput: (value) => value !== false,
104
+ resolveDefault: ({ defaultInvitesEnabled } = {}) => defaultInvitesEnabled !== false
105
+ });
@@ -0,0 +1,119 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createService } from "../src/server/common/services/authProfileSyncService.js";
4
+
5
+ test("authProfileSyncService.syncIdentityProfile uses shared transaction for profile upsert and provisioning", async () => {
6
+ const calls = [];
7
+ const transaction = { trxId: "tx-1" };
8
+
9
+ const service = createService({
10
+ userProfilesRepository: {
11
+ async findByIdentity(_identity, options = {}) {
12
+ calls.push({ step: "find", trx: options.trx || null });
13
+ return null;
14
+ },
15
+ async upsert(payload, options = {}) {
16
+ calls.push({ step: "upsert", trx: options.trx || null });
17
+ return {
18
+ id: 13,
19
+ authProvider: payload.authProvider,
20
+ authProviderUserId: payload.authProviderUserId,
21
+ email: payload.email,
22
+ displayName: payload.displayName
23
+ };
24
+ },
25
+ async withTransaction(work) {
26
+ calls.push({ step: "withTransaction", trx: transaction });
27
+ return work(transaction);
28
+ }
29
+ },
30
+ workspaceProvisioningService: {
31
+ async provisionWorkspaceForNewUser(_profile, options = {}) {
32
+ calls.push({ step: "provision", trx: options.trx || null });
33
+ }
34
+ }
35
+ });
36
+
37
+ const profile = await service.syncIdentityProfile({
38
+ authProvider: "supabase",
39
+ authProviderUserId: "abc-1",
40
+ email: "tony@example.com",
41
+ displayName: "Tony"
42
+ });
43
+
44
+ assert.equal(Number(profile.id), 13);
45
+ assert.equal(calls[0].step, "withTransaction");
46
+ assert.equal(calls[1].step, "find");
47
+ assert.equal(calls[2].step, "upsert");
48
+ assert.equal(calls[3].step, "provision");
49
+ assert.equal(calls[1].trx, transaction);
50
+ assert.equal(calls[2].trx, transaction);
51
+ assert.equal(calls[3].trx, transaction);
52
+ });
53
+
54
+ test("authProfileSyncService.syncIdentityProfile skips write path when profile is unchanged", async () => {
55
+ let upsertCalls = 0;
56
+ let provisionCalls = 0;
57
+
58
+ const service = createService({
59
+ userProfilesRepository: {
60
+ async findByIdentity() {
61
+ return {
62
+ id: 7,
63
+ authProvider: "supabase",
64
+ authProviderUserId: "abc-7",
65
+ email: "tony@example.com",
66
+ displayName: "Tony"
67
+ };
68
+ },
69
+ async upsert() {
70
+ upsertCalls += 1;
71
+ return null;
72
+ },
73
+ async withTransaction(work) {
74
+ return work({ trxId: "tx-2" });
75
+ }
76
+ },
77
+ workspaceProvisioningService: {
78
+ async provisionWorkspaceForNewUser() {
79
+ provisionCalls += 1;
80
+ }
81
+ }
82
+ });
83
+
84
+ const profile = await service.syncIdentityProfile({
85
+ authProvider: "supabase",
86
+ authProviderUserId: "abc-7",
87
+ email: "tony@example.com",
88
+ displayName: "Tony"
89
+ });
90
+
91
+ assert.equal(Number(profile.id), 7);
92
+ assert.equal(upsertCalls, 0);
93
+ assert.equal(provisionCalls, 0);
94
+ });
95
+
96
+ test("authProfileSyncService.findByIdentity normalizes provider identity input", async () => {
97
+ let capturedIdentity = null;
98
+ const service = createService({
99
+ userProfilesRepository: {
100
+ async findByIdentity(identity) {
101
+ capturedIdentity = identity;
102
+ return null;
103
+ },
104
+ async upsert() {
105
+ return null;
106
+ }
107
+ }
108
+ });
109
+
110
+ await service.findByIdentity({
111
+ authProvider: " SUPABASE ",
112
+ authProviderUserId: " user-1 "
113
+ });
114
+
115
+ assert.deepEqual(capturedIdentity, {
116
+ provider: "supabase",
117
+ providerUserId: "user-1"
118
+ });
119
+ });