@jskit-ai/users-core 0.1.31 → 0.1.33

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 (55) hide show
  1. package/package.descriptor.mjs +21 -19
  2. package/package.json +6 -6
  3. package/src/server/UsersCoreServiceProvider.js +1 -3
  4. package/src/server/accountNotifications/accountNotificationsService.js +3 -3
  5. package/src/server/accountNotifications/registerAccountNotifications.js +1 -1
  6. package/src/server/accountPreferences/accountPreferencesService.js +3 -3
  7. package/src/server/accountPreferences/registerAccountPreferences.js +1 -1
  8. package/src/server/accountProfile/accountProfileActions.js +8 -2
  9. package/src/server/accountProfile/accountProfileService.js +10 -10
  10. package/src/server/accountProfile/avatarService.js +26 -67
  11. package/src/server/accountProfile/avatarStorageService.js +14 -95
  12. package/src/server/accountProfile/bootAccountProfileRoutes.js +13 -15
  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/formatters/workspaceFormatter.js +2 -2
  18. package/src/server/common/registerCommonRepositories.js +3 -3
  19. package/src/server/common/repositories/{userProfilesRepository.js → usersRepository.js} +7 -7
  20. package/src/server/common/repositories/workspaceInvitesRepository.js +2 -2
  21. package/src/server/common/repositories/workspaceMembershipsRepository.js +9 -9
  22. package/src/server/common/repositories/workspacesRepository.js +2 -2
  23. package/src/server/common/services/accountContextService.js +4 -4
  24. package/src/server/common/services/authProfileSyncService.js +15 -15
  25. package/src/server/common/services/workspaceContextService.js +3 -3
  26. package/src/server/common/validators/authenticatedUserValidator.js +2 -2
  27. package/src/server/registerWorkspaceBootstrap.js +1 -1
  28. package/src/server/registerWorkspaceCore.js +5 -2
  29. package/src/server/workspaceBootstrapContributor.js +6 -6
  30. package/src/server/workspaceMembers/bootWorkspaceMembers.js +2 -2
  31. package/src/server/workspaceMembers/workspaceMembersActions.js +2 -2
  32. package/src/server/workspaceMembers/workspaceMembersService.js +11 -11
  33. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +1 -1
  34. package/src/shared/resources/workspaceMembersResource.js +11 -11
  35. package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
  36. package/src/shared/resources/workspaceResource.js +2 -2
  37. package/src/shared/roles.js +37 -12
  38. package/templates/config/roles.js +27 -0
  39. package/templates/migrations/users_core_initial.cjs +5 -5
  40. package/test/authProfileSyncService.test.js +8 -8
  41. package/test/avatarService.test.js +6 -6
  42. package/test/roles.test.js +90 -5
  43. package/test/usersRouteRequestInputValidator.test.js +4 -4
  44. package/test/workspaceActionContextContributor.test.js +107 -14
  45. package/test/workspaceAuthPolicyContextResolver.test.js +2 -2
  46. package/test/workspaceBootstrapContributor.test.js +8 -8
  47. package/test/workspaceInvitesRepository.test.js +3 -3
  48. package/test/workspaceMembersService.test.js +14 -12
  49. package/test/workspacePendingInvitationsResource.test.js +2 -2
  50. package/test/workspacePendingInvitationsService.test.js +3 -3
  51. package/test/workspaceService.test.js +22 -18
  52. package/test/workspaceSettingsResource.test.js +4 -2
  53. package/src/server/accountProfile/registerAvatarMultipartSupport.js +0 -40
  54. package/templates/config/workspaceRoles.js +0 -30
  55. package/test/registerAvatarMultipartSupport.test.js +0 -63
@@ -19,7 +19,7 @@ const workspaceSummaryOutputSchema = Type.Object(
19
19
  const memberSummaryOutputSchema = Type.Object(
20
20
  {
21
21
  userId: Type.Integer({ minimum: 1 }),
22
- roleId: Type.String({ minLength: 1 }),
22
+ roleSid: Type.String({ minLength: 1 }),
23
23
  status: Type.String({ minLength: 1 }),
24
24
  displayName: Type.String(),
25
25
  email: Type.String({ minLength: 1 }),
@@ -32,7 +32,7 @@ const inviteSummaryOutputSchema = Type.Object(
32
32
  {
33
33
  id: Type.Integer({ minimum: 1 }),
34
34
  email: Type.String({ minLength: 3, format: "email" }),
35
- roleId: Type.String({ minLength: 1 }),
35
+ roleSid: Type.String({ minLength: 1 }),
36
36
  status: Type.String({ minLength: 1 }),
37
37
  expiresAt: Type.String({ minLength: 1 }),
38
38
  invitedByUserId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])
@@ -57,13 +57,13 @@ function normalizeMemberSummary(member, workspace) {
57
57
 
58
58
  return {
59
59
  userId: Number(source.userId),
60
- roleId: normalizeLowerText(source.roleId || "member") || "member",
60
+ roleSid: normalizeLowerText(source.roleSid || "member") || "member",
61
61
  status: normalizeLowerText(source.status || "active") || "active",
62
62
  displayName: normalizeText(source.displayName),
63
63
  email: normalizeLowerText(source.email),
64
64
  isOwner:
65
65
  Number(source.userId) === Number(workspace.ownerUserId) ||
66
- normalizeLowerText(source.roleId) === OWNER_ROLE_ID
66
+ normalizeLowerText(source.roleSid) === OWNER_ROLE_ID
67
67
  };
68
68
  }
69
69
 
@@ -73,7 +73,7 @@ function normalizeInviteSummary(invite) {
73
73
  return {
74
74
  id: Number(source.id),
75
75
  email: normalizeLowerText(source.email),
76
- roleId: normalizeLowerText(source.roleId || "member") || "member",
76
+ roleSid: normalizeLowerText(source.roleSid || "member") || "member",
77
77
  status: normalizeLowerText(source.status || "pending") || "pending",
78
78
  expiresAt: source.expiresAt,
79
79
  invitedByUserId: source.invitedByUserId == null ? null : Number(source.invitedByUserId)
@@ -160,7 +160,7 @@ const workspaceInvitesOutputValidator = Object.freeze({
160
160
  const updateMemberRoleBodyValidator = Object.freeze({
161
161
  schema: Type.Object(
162
162
  {
163
- roleId: Type.String({ minLength: 1 })
163
+ roleSid: Type.String({ minLength: 1 })
164
164
  },
165
165
  { additionalProperties: false }
166
166
  ),
@@ -168,7 +168,7 @@ const updateMemberRoleBodyValidator = Object.freeze({
168
168
  const source = normalizeObjectInput(payload);
169
169
 
170
170
  return {
171
- roleId: normalizeLowerText(source.roleId)
171
+ roleSid: normalizeLowerText(source.roleSid)
172
172
  };
173
173
  }
174
174
  });
@@ -177,7 +177,7 @@ const updateMemberRoleInputValidator = Object.freeze({
177
177
  schema: Type.Object(
178
178
  {
179
179
  memberUserId: Type.Integer({ minimum: 1 }),
180
- roleId: Type.String({ minLength: 1 })
180
+ roleSid: Type.String({ minLength: 1 })
181
181
  },
182
182
  { additionalProperties: false }
183
183
  ),
@@ -186,7 +186,7 @@ const updateMemberRoleInputValidator = Object.freeze({
186
186
 
187
187
  return {
188
188
  memberUserId: normalizePositiveInteger(source.memberUserId),
189
- roleId: normalizeLowerText(source.roleId)
189
+ roleSid: normalizeLowerText(source.roleSid)
190
190
  };
191
191
  }
192
192
  });
@@ -211,7 +211,7 @@ const createInviteBodyValidator = Object.freeze({
211
211
  schema: Type.Object(
212
212
  {
213
213
  email: Type.String({ minLength: 3, format: "email" }),
214
- roleId: Type.String({ minLength: 1 })
214
+ roleSid: Type.String({ minLength: 1 })
215
215
  },
216
216
  { additionalProperties: false }
217
217
  ),
@@ -220,7 +220,7 @@ const createInviteBodyValidator = Object.freeze({
220
220
 
221
221
  return {
222
222
  email: normalizeLowerText(source.email),
223
- roleId: normalizeLowerText(source.roleId || "member") || "member"
223
+ roleSid: normalizeLowerText(source.roleSid || "member") || "member"
224
224
  };
225
225
  }
226
226
  });
@@ -25,7 +25,7 @@ function normalizePendingInvite(invite) {
25
25
  workspaceSlug: normalizeText(invite?.workspaceSlug),
26
26
  workspaceName: normalizeText(invite?.workspaceName || invite?.workspaceSlug),
27
27
  workspaceAvatarUrl: normalizeText(invite?.workspaceAvatarUrl),
28
- roleId: normalizeLowerText(invite?.roleId || "member") || "member",
28
+ roleSid: normalizeLowerText(invite?.roleSid || "member") || "member",
29
29
  status: normalizeLowerText(invite?.status || "pending") || "pending",
30
30
  expiresAt: invite?.expiresAt || null,
31
31
  token: encodeInviteTokenHash(tokenHash)
@@ -44,7 +44,7 @@ const pendingInviteRecordValidator = Object.freeze({
44
44
  workspaceSlug: Type.String({ minLength: 1 }),
45
45
  workspaceName: Type.String({ minLength: 1 }),
46
46
  workspaceAvatarUrl: Type.String(),
47
- roleId: Type.String({ minLength: 1 }),
47
+ roleSid: Type.String({ minLength: 1 }),
48
48
  status: Type.String({ minLength: 1 }),
49
49
  expiresAt: Type.Union([Type.String({ minLength: 1 }), Type.Null()]),
50
50
  token: Type.String({ minLength: 1 })
@@ -63,7 +63,7 @@ function normalizeWorkspaceListItemOutput(payload = {}) {
63
63
  slug: normalizeLowerText(source.slug),
64
64
  name: normalizeText(source.name),
65
65
  avatarUrl: normalizeText(source.avatarUrl),
66
- roleId: normalizeLowerText(source.roleId || "member") || "member",
66
+ roleSid: normalizeLowerText(source.roleSid || "member") || "member",
67
67
  isAccessible: source.isAccessible !== false
68
68
  };
69
69
  }
@@ -85,7 +85,7 @@ const listItemSchema = Type.Object(
85
85
  slug: Type.String({ minLength: 1 }),
86
86
  name: Type.String({ minLength: 1, maxLength: 160 }),
87
87
  avatarUrl: Type.String(),
88
- roleId: Type.String({ minLength: 1 }),
88
+ roleSid: Type.String({ minLength: 1 }),
89
89
  isAccessible: Type.Boolean()
90
90
  },
91
91
  { additionalProperties: false }
@@ -20,13 +20,38 @@ function normalizeRoleId(value) {
20
20
  .toLowerCase();
21
21
  }
22
22
 
23
- function createRoleDescriptor(roleId, 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
- const assignable = roleId === OWNER_ROLE_ID ? false : source.assignable === true;
26
- const permissions = normalizePermissionList(source.permissions);
50
+ const assignable = roleSid === OWNER_ROLE_ID ? false : source.assignable === true;
51
+ const permissions = resolveInheritedRolePermissions(roleSid, configuredRoles);
27
52
 
28
53
  return Object.freeze({
29
- id: roleId,
54
+ id: roleSid,
30
55
  assignable,
31
56
  permissions: Object.freeze([...permissions])
32
57
  });
@@ -38,16 +63,16 @@ 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
- for (const [roleId, roleDefinition] of Object.entries(configuredRoles)) {
50
- const normalizedRoleId = normalizeRoleId(roleId);
74
+ for (const [roleSid, roleDefinition] of Object.entries(configuredRoles)) {
75
+ const normalizedRoleId = normalizeRoleId(roleSid);
51
76
  if (!normalizedRoleId) {
52
77
  continue;
53
78
  }
@@ -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((roleId) => createRoleDescriptor(roleId, configuredRoles[roleId]));
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)
@@ -109,8 +134,8 @@ function listRoleDescriptors(appConfig = {}) {
109
134
  }));
110
135
  }
111
136
 
112
- function resolveRolePermissions(roleId, appConfig = {}) {
113
- const normalizedRoleId = normalizeRoleId(roleId);
137
+ function resolveRolePermissions(roleSid, appConfig = {}) {
138
+ const normalizedRoleId = normalizeRoleId(roleSid);
114
139
  if (!normalizedRoleId) {
115
140
  return [];
116
141
  }
@@ -0,0 +1,27 @@
1
+ export const roleCatalog = {
2
+ workspace: {
3
+ defaultInviteRole: "member"
4
+ },
5
+ roles: {
6
+ owner: {
7
+ assignable: false,
8
+ permissions: ["*"]
9
+ },
10
+ admin: {
11
+ assignable: true,
12
+ inherits: "member",
13
+ permissions: [
14
+ "workspace.roles.view",
15
+ "workspace.settings.update",
16
+ "workspace.members.view",
17
+ "workspace.members.invite",
18
+ "workspace.members.manage",
19
+ "workspace.invites.revoke"
20
+ ]
21
+ },
22
+ member: {
23
+ assignable: true,
24
+ permissions: ["workspace.settings.view"]
25
+ }
26
+ }
27
+ };
@@ -5,7 +5,7 @@ exports.up = async function up(knex) {
5
5
  await knex.schema.createTable("users", (table) => {
6
6
  table.increments("id").primary();
7
7
  table.string("auth_provider", 64).notNullable();
8
- table.string("auth_provider_user_id", 191).notNullable();
8
+ table.string("auth_provider_user_sid", 191).notNullable();
9
9
  table.string("email", 255).notNullable();
10
10
  table.string("username", 120).notNullable();
11
11
  table.string("display_name", 160).notNullable();
@@ -13,7 +13,7 @@ exports.up = async function up(knex) {
13
13
  table.string("avatar_version", 64).nullable();
14
14
  table.timestamp("avatar_updated_at", { useTz: false }).nullable();
15
15
  table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
16
- table.unique(["auth_provider", "auth_provider_user_id"], "uq_users_identity");
16
+ table.unique(["auth_provider", "auth_provider_user_sid"], "uq_users_identity");
17
17
  table.unique(["email"], "uq_users_email");
18
18
  table.unique(["username"], "uq_users_username");
19
19
  });
@@ -35,7 +35,7 @@ exports.up = async function up(knex) {
35
35
  table.increments("id").primary();
36
36
  table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
37
37
  table.integer("user_id").unsigned().notNullable().references("id").inTable("users").onDelete("CASCADE");
38
- table.string("role_id", 64).notNullable().defaultTo("member");
38
+ table.string("role_sid", 64).notNullable().defaultTo("member");
39
39
  table.string("status", 32).notNullable().defaultTo("active");
40
40
  table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
41
41
  table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
@@ -63,7 +63,7 @@ exports.up = async function up(knex) {
63
63
  table.increments("id").primary();
64
64
  table.integer("workspace_id").unsigned().notNullable().references("id").inTable("workspaces").onDelete("CASCADE");
65
65
  table.string("email", 255).notNullable();
66
- table.string("role_id", 64).notNullable().defaultTo("member");
66
+ table.string("role_sid", 64).notNullable().defaultTo("member");
67
67
  table.string("status", 32).notNullable().defaultTo("pending");
68
68
  table.string("token_hash", 191).notNullable();
69
69
  table.integer("invited_by_user_id").unsigned().nullable().references("id").inTable("users").onDelete("SET NULL");
@@ -97,7 +97,7 @@ exports.up = async function up(knex) {
97
97
 
98
98
  await knex.schema.createTable("console_settings", (table) => {
99
99
  table.integer("id").primary();
100
- table.integer("owner_user_id").unsigned().nullable();
100
+ table.integer("owner_user_id").unsigned().nullable().references("id").inTable("users").onDelete("SET NULL");
101
101
  table.timestamp("created_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
102
102
  table.timestamp("updated_at", { useTz: false }).notNullable().defaultTo(knex.fn.now());
103
103
  });
@@ -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;
@@ -17,7 +17,7 @@ test("authProfileSyncService.syncIdentityProfile uses shared transaction for pro
17
17
  return {
18
18
  id: 13,
19
19
  authProvider: payload.authProvider,
20
- authProviderUserId: payload.authProviderUserId,
20
+ authProviderUserSid: payload.authProviderUserSid,
21
21
  email: payload.email,
22
22
  displayName: payload.displayName
23
23
  };
@@ -42,7 +42,7 @@ test("authProfileSyncService.syncIdentityProfile uses shared transaction for pro
42
42
 
43
43
  const profile = await service.syncIdentityProfile({
44
44
  authProvider: "supabase",
45
- authProviderUserId: "abc-1",
45
+ authProviderUserSid: "abc-1",
46
46
  email: "tony@example.com",
47
47
  displayName: "Tony"
48
48
  });
@@ -64,12 +64,12 @@ 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,
71
71
  authProvider: "supabase",
72
- authProviderUserId: "abc-7",
72
+ authProviderUserSid: "abc-7",
73
73
  email: "tony@example.com",
74
74
  displayName: "Tony"
75
75
  };
@@ -96,7 +96,7 @@ test("authProfileSyncService.syncIdentityProfile skips write path when profile i
96
96
 
97
97
  const profile = await service.syncIdentityProfile({
98
98
  authProvider: "supabase",
99
- authProviderUserId: "abc-7",
99
+ authProviderUserSid: "abc-7",
100
100
  email: "tony@example.com",
101
101
  displayName: "Tony"
102
102
  });
@@ -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;
@@ -127,7 +127,7 @@ test("authProfileSyncService.findByIdentity normalizes provider identity input",
127
127
 
128
128
  await service.findByIdentity({
129
129
  authProvider: " SUPABASE ",
130
- authProviderUserId: " user-1 "
130
+ authProviderUserSid: " user-1 "
131
131
  });
132
132
 
133
133
  assert.deepEqual(capturedIdentity, {
@@ -41,7 +41,7 @@ test("avatarService uploadForUser stores bytes and updates profile avatar fields
41
41
  const repository = createRepositoryDouble({
42
42
  id: 7,
43
43
  authProvider: "local",
44
- authProviderUserId: "u-7",
44
+ authProviderUserSid: "u-7",
45
45
  email: "test@example.com",
46
46
  displayName: "Tester",
47
47
  avatarStorageKey: null,
@@ -59,13 +59,13 @@ 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
 
66
66
  const user = {
67
67
  authProvider: "local",
68
- authProviderUserId: "u-7"
68
+ authProviderUserSid: "u-7"
69
69
  };
70
70
 
71
71
  const result = await avatarService.uploadForUser(user, {
@@ -84,7 +84,7 @@ test("avatarService clearForUser removes stored avatar and clears profile fields
84
84
  const repository = createRepositoryDouble({
85
85
  id: 7,
86
86
  authProvider: "local",
87
- authProviderUserId: "u-7",
87
+ authProviderUserSid: "u-7",
88
88
  email: "test@example.com",
89
89
  displayName: "Tester",
90
90
  avatarStorageKey: "users/avatars/7/avatar",
@@ -99,13 +99,13 @@ 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
 
106
106
  const profile = await avatarService.clearForUser({
107
107
  authProvider: "local",
108
- authProviderUserId: "u-7"
108
+ authProviderUserSid: "u-7"
109
109
  });
110
110
 
111
111
  assert.deepEqual(deletedKeys, ["users/avatars/7/avatar"]);
@@ -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", () => {
@@ -379,7 +379,7 @@ test("workspace invite and member handlers build action input from request.input
379
379
  createActionRequest({
380
380
  input: {
381
381
  params: { workspaceSlug: "acme", memberUserId: "12" },
382
- body: { roleId: "admin" }
382
+ body: { roleSid: "admin" }
383
383
  },
384
384
  executeAction
385
385
  }),
@@ -389,7 +389,7 @@ test("workspace invite and member handlers build action input from request.input
389
389
  createActionRequest({
390
390
  input: {
391
391
  params: { workspaceSlug: "acme" },
392
- body: { email: "user@example.com", roleId: "member" }
392
+ body: { email: "user@example.com", roleSid: "member" }
393
393
  },
394
394
  executeAction
395
395
  }),
@@ -419,8 +419,8 @@ test("workspace invite and member handlers build action input from request.input
419
419
  input: { name: "Operations", slug: "operations" }
420
420
  });
421
421
  assert.deepEqual(calls[1].input, { payload: { token: "token-1", decision: "accept" } });
422
- assert.deepEqual(calls[2].input, { workspaceSlug: "acme", memberUserId: "12", roleId: "admin" });
423
- assert.deepEqual(calls[3].input, { workspaceSlug: "acme", email: "user@example.com", roleId: "member" });
422
+ assert.deepEqual(calls[2].input, { workspaceSlug: "acme", memberUserId: "12", roleSid: "admin" });
423
+ assert.deepEqual(calls[3].input, { workspaceSlug: "acme", email: "user@example.com", roleSid: "member" });
424
424
  assert.deepEqual(calls[4].input, { workspaceSlug: "acme", memberUserId: "44" });
425
425
  assert.deepEqual(calls[5].input, { workspaceSlug: "acme", inviteId: "55" });
426
426
  });