@jskit-ai/users-core 0.1.32 → 0.1.35

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 (48) hide show
  1. package/package.descriptor.mjs +16 -245
  2. package/package.json +7 -7
  3. package/src/server/UsersCoreServiceProvider.js +4 -28
  4. package/src/server/UsersWorkspacesServiceProvider.js +44 -0
  5. package/src/server/accountNotifications/accountNotificationsService.js +3 -3
  6. package/src/server/accountNotifications/registerAccountNotifications.js +1 -1
  7. package/src/server/accountPreferences/accountPreferencesService.js +3 -3
  8. package/src/server/accountPreferences/registerAccountPreferences.js +1 -1
  9. package/src/server/accountProfile/accountProfileActions.js +8 -2
  10. package/src/server/accountProfile/accountProfileService.js +10 -10
  11. package/src/server/accountProfile/avatarService.js +9 -9
  12. package/src/server/accountProfile/bootAccountProfileRoutes.js +5 -3
  13. package/src/server/accountProfile/registerAccountProfile.js +2 -2
  14. package/src/server/accountSecurity/accountSecurityService.js +3 -3
  15. package/src/server/accountSecurity/registerAccountSecurity.js +1 -1
  16. package/src/server/common/contributors/workspaceActionContextContributor.js +24 -17
  17. package/src/server/common/registerCommonRepositories.js +3 -22
  18. package/src/server/common/repositories/userSettingsRepository.js +1 -12
  19. package/src/server/common/repositories/{userProfilesRepository.js → usersRepository.js} +1 -1
  20. package/src/server/common/services/accountContextService.js +4 -4
  21. package/src/server/common/services/authProfileSyncService.js +10 -10
  22. package/src/server/registerUsersBootstrap.js +22 -0
  23. package/src/server/registerUsersCore.js +30 -0
  24. package/src/server/registerWorkspaceBootstrap.js +3 -6
  25. package/src/server/registerWorkspaceCore.js +5 -17
  26. package/src/server/registerWorkspaceRepositories.js +26 -0
  27. package/src/server/usersBootstrapContributor.js +248 -0
  28. package/src/server/workspaceBootstrapContributor.js +65 -259
  29. package/src/shared/roles.js +31 -6
  30. package/src/shared/settings.js +1 -2
  31. package/templates/migrations/users_core_generic_initial.cjs +69 -0
  32. package/test/authProfileSyncService.test.js +3 -3
  33. package/test/avatarService.test.js +2 -2
  34. package/test/registerUsersCore.test.js +42 -0
  35. package/test/roles.test.js +90 -5
  36. package/test/usersBootstrapContributor.test.js +172 -0
  37. package/test/usersRouteRequestInputValidator.test.js +7 -390
  38. package/test/workspaceActionContextContributor.test.js +98 -5
  39. package/test/workspaceBootstrapContributor.test.js +34 -346
  40. package/test/workspaceMembersService.test.js +4 -2
  41. package/test/workspaceService.test.js +12 -8
  42. package/test/workspaceSettingsResource.test.js +4 -2
  43. package/test-support/registerDefaultSettingsFields.js +1 -1
  44. package/templates/config/workspaceRoles.js +0 -30
  45. package/templates/migrations/users_core_initial.cjs +0 -123
  46. package/templates/migrations/users_core_workspace_settings_single_name_source.cjs +0 -71
  47. package/templates/migrations/users_core_workspaces_drop_color.cjs +0 -85
  48. package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +0 -197
@@ -20,10 +20,35 @@ function normalizeRoleId(value) {
20
20
  .toLowerCase();
21
21
  }
22
22
 
23
- function createRoleDescriptor(roleSid, configuredDefinition) {
23
+ function resolveInheritedRolePermissions(roleSid, configuredRoles = {}, seenRoleIds = new Set()) {
24
+ if (seenRoleIds.has(roleSid)) {
25
+ throw new TypeError(`roleCatalog role "${roleSid}" has circular inheritance.`);
26
+ }
27
+
28
+ const source = asRecord(configuredRoles[roleSid]);
29
+ const inheritedRoleId = normalizeRoleId(source.inherits);
30
+ const directPermissions = normalizePermissionList(source.permissions);
31
+ if (!inheritedRoleId) {
32
+ return directPermissions;
33
+ }
34
+
35
+ if (!Object.hasOwn(configuredRoles, inheritedRoleId)) {
36
+ throw new TypeError(`roleCatalog role "${roleSid}" inherits unknown role "${inheritedRoleId}".`);
37
+ }
38
+
39
+ const nextSeenRoleIds = new Set(seenRoleIds);
40
+ nextSeenRoleIds.add(roleSid);
41
+
42
+ return normalizePermissionList([
43
+ ...resolveInheritedRolePermissions(inheritedRoleId, configuredRoles, nextSeenRoleIds),
44
+ ...directPermissions
45
+ ]);
46
+ }
47
+
48
+ function createRoleDescriptor(roleSid, configuredDefinition, configuredRoles = {}) {
24
49
  const source = asRecord(configuredDefinition);
25
50
  const assignable = roleSid === OWNER_ROLE_ID ? false : source.assignable === true;
26
- const permissions = normalizePermissionList(source.permissions);
51
+ const permissions = resolveInheritedRolePermissions(roleSid, configuredRoles);
27
52
 
28
53
  return Object.freeze({
29
54
  id: roleSid,
@@ -38,12 +63,12 @@ function listConfiguredRoleIds(appConfig = {}) {
38
63
  }
39
64
 
40
65
  function resolveConfiguredDefaultInviteRole(appConfig = {}) {
41
- return normalizeRoleId(appConfig?.workspaceRoles?.defaultInviteRole);
66
+ return normalizeRoleId(appConfig?.roleCatalog?.workspace?.defaultInviteRole);
42
67
  }
43
68
 
44
69
  function normalizeConfiguredRoles(appConfig = {}) {
45
- const workspaceRoles = asRecord(appConfig?.workspaceRoles);
46
- const configuredRoles = asRecord(workspaceRoles.roles);
70
+ const roleCatalog = asRecord(appConfig?.roleCatalog);
71
+ const configuredRoles = asRecord(roleCatalog.roles);
47
72
  const normalizedRoles = {};
48
73
 
49
74
  for (const [roleSid, roleDefinition] of Object.entries(configuredRoles)) {
@@ -60,7 +85,7 @@ function normalizeConfiguredRoles(appConfig = {}) {
60
85
  function createWorkspaceRoleCatalog(appConfig = {}) {
61
86
  const configuredRoles = normalizeConfiguredRoles(appConfig);
62
87
  const roleIds = listConfiguredRoleIds(appConfig);
63
- const roles = roleIds.map((roleSid) => createRoleDescriptor(roleSid, configuredRoles[roleSid]));
88
+ const roles = roleIds.map((roleSid) => createRoleDescriptor(roleSid, configuredRoles[roleSid], configuredRoles));
64
89
  const assignableRoleIds = roles.filter((role) => role.assignable).map((role) => role.id);
65
90
  const configuredDefaultInviteRole = resolveConfiguredDefaultInviteRole(appConfig);
66
91
  const defaultInviteRole = assignableRoleIds.includes(configuredDefaultInviteRole)
@@ -30,8 +30,7 @@ const DEFAULT_USER_SETTINGS = Object.freeze({
30
30
  accountActivity: true,
31
31
  securityAlerts: true,
32
32
  passwordSignInEnabled: true,
33
- passwordSetupRequired: false,
34
- lastActiveWorkspaceId: null
33
+ passwordSetupRequired: false
35
34
  });
36
35
 
37
36
  function normalizeWorkspaceHexColor(value) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @param {import('knex').Knex} knex
3
+ */
4
+ exports.up = async function up(knex) {
5
+ const hasUsersTable = await knex.schema.hasTable("users");
6
+ if (!hasUsersTable) {
7
+ await knex.schema.createTable("users", (table) => {
8
+ table.increments("id").primary();
9
+ table.string("auth_provider", 64).notNullable();
10
+ table.string("auth_provider_user_sid", 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_sid"], "uq_users_identity");
19
+ table.unique(["email"], "uq_users_email");
20
+ table.unique(["username"], "uq_users_username");
21
+ });
22
+ }
23
+
24
+ const hasUserSettingsTable = await knex.schema.hasTable("user_settings");
25
+ if (!hasUserSettingsTable) {
26
+ await knex.schema.createTable("user_settings", (table) => {
27
+ table.integer("user_id").unsigned().primary().references("id").inTable("users").onDelete("CASCADE");
28
+ table.string("theme", 32).notNullable().defaultTo("system");
29
+ table.string("locale", 24).notNullable().defaultTo("en");
30
+ table.string("time_zone", 64).notNullable().defaultTo("UTC");
31
+ table.string("date_format", 32).notNullable().defaultTo("yyyy-mm-dd");
32
+ table.string("number_format", 32).notNullable().defaultTo("1,234.56");
33
+ table.string("currency_code", 3).notNullable().defaultTo("USD");
34
+ table.integer("avatar_size").notNullable().defaultTo(64);
35
+ table.boolean("password_sign_in_enabled").notNullable().defaultTo(true);
36
+ table.boolean("password_setup_required").notNullable().defaultTo(false);
37
+ table.boolean("notify_product_updates").notNullable().defaultTo(true);
38
+ table.boolean("notify_account_activity").notNullable().defaultTo(true);
39
+ table.boolean("notify_security_alerts").notNullable().defaultTo(true);
40
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
41
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
42
+ });
43
+ }
44
+
45
+ const hasConsoleSettingsTable = await knex.schema.hasTable("console_settings");
46
+ if (!hasConsoleSettingsTable) {
47
+ await knex.schema.createTable("console_settings", (table) => {
48
+ table.integer("id").primary();
49
+ table.integer("owner_user_id").unsigned().nullable().references("id").inTable("users").onDelete("SET NULL");
50
+ table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
51
+ table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
52
+ });
53
+
54
+ await knex("console_settings").insert({
55
+ id: 1,
56
+ created_at: knex.fn.now(),
57
+ updated_at: knex.fn.now()
58
+ });
59
+ }
60
+ };
61
+
62
+ /**
63
+ * @param {import('knex').Knex} knex
64
+ */
65
+ exports.down = async function down(knex) {
66
+ await knex.schema.dropTableIfExists("console_settings");
67
+ await knex.schema.dropTableIfExists("user_settings");
68
+ await knex.schema.dropTableIfExists("users");
69
+ };
@@ -7,7 +7,7 @@ test("authProfileSyncService.syncIdentityProfile uses shared transaction for pro
7
7
  const transaction = { trxId: "tx-1" };
8
8
 
9
9
  const service = createService({
10
- userProfilesRepository: {
10
+ usersRepository: {
11
11
  async findByIdentity(_identity, options = {}) {
12
12
  calls.push({ step: "find", trx: options.trx || null });
13
13
  return null;
@@ -64,7 +64,7 @@ test("authProfileSyncService.syncIdentityProfile skips write path when profile i
64
64
  let provisionCalls = 0;
65
65
 
66
66
  const service = createService({
67
- userProfilesRepository: {
67
+ usersRepository: {
68
68
  async findByIdentity() {
69
69
  return {
70
70
  id: 7,
@@ -109,7 +109,7 @@ test("authProfileSyncService.syncIdentityProfile skips write path when profile i
109
109
  test("authProfileSyncService.findByIdentity normalizes provider identity input", async () => {
110
110
  let capturedIdentity = null;
111
111
  const service = createService({
112
- userProfilesRepository: {
112
+ usersRepository: {
113
113
  async findByIdentity(identity) {
114
114
  capturedIdentity = identity;
115
115
  return null;
@@ -59,7 +59,7 @@ test("avatarService uploadForUser stores bytes and updates profile avatar fields
59
59
  };
60
60
 
61
61
  const avatarService = createService({
62
- userProfilesRepository: repository,
62
+ usersRepository: repository,
63
63
  avatarStorageService
64
64
  });
65
65
 
@@ -99,7 +99,7 @@ test("avatarService clearForUser removes stored avatar and clears profile fields
99
99
  };
100
100
 
101
101
  const avatarService = createService({
102
- userProfilesRepository: repository,
102
+ usersRepository: repository,
103
103
  avatarStorageService
104
104
  });
105
105
 
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { registerUsersCore } from "../src/server/registerUsersCore.js";
4
+
5
+ test("registerUsersCore registers console and workspace action surface aliases when action runtime is available", () => {
6
+ const calls = [];
7
+ const app = {
8
+ singleton() {
9
+ return this;
10
+ },
11
+ actionSurfaceSource(sourceName, resolver) {
12
+ calls.push({
13
+ sourceName: String(sourceName || ""),
14
+ resolverType: typeof resolver
15
+ });
16
+ return this;
17
+ }
18
+ };
19
+
20
+ registerUsersCore(app);
21
+
22
+ assert.deepEqual(calls, [
23
+ {
24
+ sourceName: "workspace",
25
+ resolverType: "function"
26
+ },
27
+ {
28
+ sourceName: "console",
29
+ resolverType: "function"
30
+ }
31
+ ]);
32
+ });
33
+
34
+ test("registerUsersCore still works when action runtime has not installed actionSurfaceSource yet", () => {
35
+ const app = {
36
+ singleton() {
37
+ return this;
38
+ }
39
+ };
40
+
41
+ assert.doesNotThrow(() => registerUsersCore(app));
42
+ });
@@ -7,7 +7,7 @@ import {
7
7
  hasPermission
8
8
  } from "../src/shared/roles.js";
9
9
 
10
- test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.workspaceRoles", () => {
10
+ test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.roleCatalog", () => {
11
11
  const emptyCatalog = createWorkspaceRoleCatalog();
12
12
  assert.deepEqual(emptyCatalog.roles, []);
13
13
  assert.deepEqual(emptyCatalog.assignableRoleIds, []);
@@ -15,8 +15,10 @@ test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.w
15
15
  assert.equal(emptyCatalog.collaborationEnabled, false);
16
16
 
17
17
  const appConfig = {
18
- workspaceRoles: {
19
- defaultInviteRole: "editor",
18
+ roleCatalog: {
19
+ workspace: {
20
+ defaultInviteRole: "editor"
21
+ },
20
22
  roles: {
21
23
  owner: {
22
24
  assignable: false,
@@ -24,7 +26,7 @@ test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.w
24
26
  },
25
27
  editor: {
26
28
  assignable: true,
27
- permissions: ["crud_contacts.*"]
29
+ permissions: ["crud.contacts.*"]
28
30
  }
29
31
  }
30
32
  }
@@ -35,7 +37,90 @@ test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.w
35
37
  assert.equal(roleCatalog.defaultInviteRole, "editor");
36
38
  assert.equal(roleCatalog.assignableRoleIds.includes("editor"), true);
37
39
  assert.deepEqual(resolveRolePermissions("owner", appConfig), ["workspace.settings.update"]);
38
- assert.equal(hasPermission(editorRole?.permissions, "crud_contacts.update"), true);
40
+ assert.equal(hasPermission(editorRole?.permissions, "crud.contacts.update"), true);
41
+ });
42
+
43
+ test("createWorkspaceRoleCatalog resolves inherited role permissions with parent permissions first", () => {
44
+ const appConfig = {
45
+ roleCatalog: {
46
+ workspace: {
47
+ defaultInviteRole: "member"
48
+ },
49
+ roles: {
50
+ member: {
51
+ assignable: true,
52
+ permissions: [
53
+ "workspace.settings.view",
54
+ "crud.contacts.list"
55
+ ]
56
+ },
57
+ admin: {
58
+ assignable: true,
59
+ inherits: "member",
60
+ permissions: [
61
+ "workspace.settings.update",
62
+ "workspace.members.manage",
63
+ "workspace.settings.view"
64
+ ]
65
+ }
66
+ }
67
+ }
68
+ };
69
+
70
+ const roleCatalog = createWorkspaceRoleCatalog(appConfig);
71
+ const adminRole = roleCatalog.roles.find((role) => role.id === "admin");
72
+
73
+ assert.deepEqual(adminRole, {
74
+ id: "admin",
75
+ assignable: true,
76
+ permissions: [
77
+ "workspace.settings.view",
78
+ "crud.contacts.list",
79
+ "workspace.settings.update",
80
+ "workspace.members.manage"
81
+ ]
82
+ });
83
+ });
84
+
85
+ test("createWorkspaceRoleCatalog rejects unknown inherited roles", () => {
86
+ assert.throws(
87
+ () =>
88
+ createWorkspaceRoleCatalog({
89
+ roleCatalog: {
90
+ roles: {
91
+ admin: {
92
+ assignable: true,
93
+ inherits: "member",
94
+ permissions: []
95
+ }
96
+ }
97
+ }
98
+ }),
99
+ /inherits unknown role "member"/
100
+ );
101
+ });
102
+
103
+ test("createWorkspaceRoleCatalog rejects circular inherited roles", () => {
104
+ assert.throws(
105
+ () =>
106
+ createWorkspaceRoleCatalog({
107
+ roleCatalog: {
108
+ roles: {
109
+ member: {
110
+ assignable: true,
111
+ inherits: "admin",
112
+ permissions: []
113
+ },
114
+ admin: {
115
+ assignable: true,
116
+ inherits: "member",
117
+ permissions: []
118
+ }
119
+ }
120
+ }
121
+ }),
122
+ /circular inheritance/
123
+ );
39
124
  });
40
125
 
41
126
  test("cloneWorkspaceRoleCatalog normalizes role ids and returns detached arrays", () => {
@@ -0,0 +1,172 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createUsersBootstrapContributor } from "../src/server/usersBootstrapContributor.js";
4
+ import {
5
+ TENANCY_MODE_PERSONAL,
6
+ WORKSPACE_SLUG_POLICY_IMMUTABLE_USERNAME
7
+ } from "../src/shared/tenancyProfile.js";
8
+
9
+ function createAuthenticatedProfile(overrides = {}) {
10
+ return {
11
+ id: 7,
12
+ authProvider: "local",
13
+ authProviderUserSid: "user-7",
14
+ username: "tester",
15
+ displayName: "Test User",
16
+ email: "test@example.com",
17
+ ...overrides
18
+ };
19
+ }
20
+
21
+ function createUserSettings() {
22
+ return {
23
+ theme: "system",
24
+ locale: "en",
25
+ timeZone: "UTC",
26
+ dateFormat: "YYYY-MM-DD",
27
+ numberFormat: "1,234.56",
28
+ currencyCode: "USD",
29
+ avatarSize: 64,
30
+ productUpdates: true,
31
+ accountActivity: true,
32
+ securityAlerts: true
33
+ };
34
+ }
35
+
36
+ test("users bootstrap contributor seeds the initial console owner and exposes generic app payload", async () => {
37
+ const profile = createAuthenticatedProfile({ id: 12 });
38
+ const consoleOwnerSeeds = [];
39
+ const writtenSessions = [];
40
+ const contributor = createUsersBootstrapContributor({
41
+ usersRepository: {
42
+ async findById() {
43
+ return profile;
44
+ }
45
+ },
46
+ userSettingsRepository: {
47
+ async ensureForUserId() {
48
+ return createUserSettings();
49
+ }
50
+ },
51
+ authService: {
52
+ writeSessionCookies(reply, session) {
53
+ writtenSessions.push({ reply, session });
54
+ },
55
+ getOAuthProviderCatalog() {
56
+ return {
57
+ providers: [
58
+ { id: "google", label: "Google" }
59
+ ],
60
+ defaultProvider: "google"
61
+ };
62
+ }
63
+ },
64
+ consoleService: {
65
+ async ensureInitialConsoleMember(userId) {
66
+ consoleOwnerSeeds.push(Number(userId));
67
+ return Number(userId);
68
+ }
69
+ }
70
+ });
71
+
72
+ const reply = {};
73
+ const payload = await contributor.contribute({
74
+ request: {
75
+ async executeAction() {
76
+ return {
77
+ authenticated: true,
78
+ profile,
79
+ session: {
80
+ csrfToken: "csrf-1"
81
+ }
82
+ };
83
+ }
84
+ },
85
+ reply
86
+ });
87
+
88
+ assert.deepEqual(consoleOwnerSeeds, [12]);
89
+ assert.equal(writtenSessions.length, 1);
90
+ assert.equal(writtenSessions[0].reply, reply);
91
+ assert.deepEqual(writtenSessions[0].session, {
92
+ csrfToken: "csrf-1"
93
+ });
94
+ assert.equal(payload.session.authenticated, true);
95
+ assert.equal(payload.session.userId, 12);
96
+ assert.equal(payload.surfaceAccess.consoleowner, true);
97
+ assert.equal(payload.app.features.workspaceSwitching, false);
98
+ assert.deepEqual(payload.session.oauthProviders, [
99
+ {
100
+ id: "google",
101
+ label: "Google"
102
+ }
103
+ ]);
104
+ assert.equal(payload.session.oauthDefaultProvider, "google");
105
+ assert.deepEqual(payload.workspaces, []);
106
+ assert.deepEqual(payload.userSettings, {});
107
+ assert.equal(payload.requestMeta.hasRequest, true);
108
+ });
109
+
110
+ test("users bootstrap contributor emits canonical tenancy profile for anonymous bootstrap", async () => {
111
+ const contributor = createUsersBootstrapContributor({
112
+ usersRepository: {
113
+ async findById() {
114
+ return null;
115
+ }
116
+ },
117
+ userSettingsRepository: {
118
+ async ensureForUserId() {
119
+ return createUserSettings();
120
+ }
121
+ },
122
+ tenancyProfile: {
123
+ mode: TENANCY_MODE_PERSONAL,
124
+ workspace: {
125
+ enabled: true,
126
+ autoProvision: true,
127
+ allowSelfCreate: false,
128
+ slugPolicy: WORKSPACE_SLUG_POLICY_IMMUTABLE_USERNAME
129
+ }
130
+ },
131
+ appConfig: {
132
+ tenancyMode: "none"
133
+ },
134
+ authService: {
135
+ getOAuthProviderCatalog() {
136
+ return {
137
+ providers: [],
138
+ defaultProvider: null
139
+ };
140
+ }
141
+ }
142
+ });
143
+
144
+ const payload = await contributor.contribute({
145
+ request: {
146
+ async executeAction() {
147
+ return {
148
+ authenticated: false
149
+ };
150
+ }
151
+ },
152
+ reply: {}
153
+ });
154
+
155
+ assert.deepEqual(payload.tenancy, {
156
+ mode: TENANCY_MODE_PERSONAL,
157
+ workspace: {
158
+ enabled: true,
159
+ autoProvision: true,
160
+ allowSelfCreate: false,
161
+ slugPolicy: WORKSPACE_SLUG_POLICY_IMMUTABLE_USERNAME
162
+ }
163
+ });
164
+ assert.deepEqual(payload.session, {
165
+ authenticated: false,
166
+ oauthProviders: [],
167
+ oauthDefaultProvider: null
168
+ });
169
+ assert.deepEqual(payload.workspaces, []);
170
+ assert.equal(payload.surfaceAccess.consoleowner, false);
171
+ assert.equal(payload.userSettings, null);
172
+ });