@jskit-ai/workspaces-core 0.1.209 → 0.1.212

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.1.209",
3
+ "version": "0.1.212",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -18,11 +18,11 @@
18
18
  "./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js"
19
19
  },
20
20
  "dependencies": {
21
- "@jskit-ai/database-runtime": "0.1.231",
22
- "@jskit-ai/json-rest-api-core": "0.1.175",
23
- "@jskit-ai/resource-core": "0.1.173",
24
- "@jskit-ai/resource-crud-core": "0.1.173",
25
- "@jskit-ai/users-core": "0.1.244",
21
+ "@jskit-ai/database-runtime": "0.1.234",
22
+ "@jskit-ai/json-rest-api-core": "0.1.178",
23
+ "@jskit-ai/resource-core": "0.1.176",
24
+ "@jskit-ai/resource-crud-core": "0.1.176",
25
+ "@jskit-ai/users-core": "0.1.247",
26
26
  "json-rest-schema": "^1.0.17"
27
27
  },
28
28
  "description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
@@ -169,8 +169,8 @@
169
169
  }
170
170
  },
171
171
  "peerDependencies": {
172
- "@jskit-ai/auth-core": "0.1.229",
173
- "@jskit-ai/http-runtime": "0.1.229",
174
- "@jskit-ai/kernel": "0.1.231"
172
+ "@jskit-ai/auth-core": "0.1.232",
173
+ "@jskit-ai/http-runtime": "0.1.232",
174
+ "@jskit-ai/kernel": "0.1.234"
175
175
  }
176
176
  }
@@ -28,6 +28,26 @@ member permissions, personal-workspace behavior, and surface access rules.
28
28
  Use workspace routes, resources, scope helpers, tenancy profiles, and settings
29
29
  contracts exported by `@jskit-ai/workspaces-core`.
30
30
 
31
+ ## Role display labels
32
+
33
+ Each `roleCatalog.roles[roleId]` definition may include an optional string
34
+ `label`. JSKIT trims it and preserves nonempty labels in role descriptors,
35
+ catalogue clones and Members/Workspace Settings responses. Labels are display
36
+ metadata; they do not inherit from a parent role. Missing, blank or non-string
37
+ labels keep the existing role-title fallback.
38
+
39
+ Shared Members invitation and member-role selectors show the label but submit
40
+ the role id. Pending invitation captions, workspace role captions and the
41
+ default invitation email use the same configured labels. Pending invitation
42
+ responses expose optional `roleLabel` alongside the unchanged `roleSid`.
43
+ Applications with their own invitation renderer may use its optional
44
+ `roleLabel` input; `roleSid` remains the stored role id.
45
+
46
+ Keep existing permissions, inheritance, assignability and
47
+ `workspace.defaultInviteRole` when adding a label. `owner` remains
48
+ nonassignable. Custom roles, including specialised Training roles, are ordinary
49
+ catalogue entries. Updating labels requires no database migration.
50
+
31
51
  ## Transactional invitation participation
32
52
 
33
53
  Through `workspaces.core`, register one callback during feature setup with
@@ -8,6 +8,7 @@ export const roleCatalog = {
8
8
  permissions: ["*"]
9
9
  },
10
10
  admin: {
11
+ label: "Administrator",
11
12
  assignable: true,
12
13
  inherits: "member",
13
14
  permissions: [
@@ -20,6 +21,7 @@ export const roleCatalog = {
20
21
  ]
21
22
  },
22
23
  member: {
24
+ label: "Team member",
23
25
  assignable: true,
24
26
  permissions: ["workspace.settings.view"]
25
27
  }
@@ -105,7 +105,8 @@ function createWorkspacesRuntime({ config, env, jsonRestApi } = {}) {
105
105
  });
106
106
  const workspacePendingInvitationsService = createWorkspacePendingInvitationsService({
107
107
  workspaceInvitesRepository,
108
- workspaceMembershipsRepository
108
+ workspaceMembershipsRepository,
109
+ roleCatalog
109
110
  });
110
111
  const workspaceMembersService = createWorkspaceMembersService({
111
112
  workspaceMembershipsRepository,
@@ -2,13 +2,16 @@ import { resolveWorkspaceThemePalettes } from "@jskit-ai/workspaces-core/shared/
2
2
  import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
3
3
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
4
4
 
5
- function mapWorkspaceSummary(workspace, membership) {
5
+ function mapWorkspaceSummary(workspace, membership, roleCatalog = null) {
6
+ const roleSid = normalizeLowerText(membership?.roleSid || "member") || "member";
7
+ const roleLabel = roleCatalog?.roles?.find((role) => role.id === roleSid)?.label;
6
8
  return {
7
9
  id: normalizeRecordId(workspace.id, { fallback: "" }),
8
10
  slug: normalizeText(workspace.slug),
9
11
  name: normalizeText(workspace.name),
10
12
  avatarUrl: normalizeText(workspace.avatarUrl),
11
- roleSid: normalizeLowerText(membership?.roleSid || "member") || "member",
13
+ roleSid,
14
+ ...(roleLabel ? { roleLabel } : {}),
12
15
  isAccessible: normalizeLowerText(membership?.status || "active") === "active"
13
16
  };
14
17
  }
@@ -5,7 +5,8 @@ import {
5
5
  resolveTenancyProfile
6
6
  } from "../../../shared/tenancyProfile.js";
7
7
  import {
8
- resolveRolePermissions
8
+ resolveRolePermissions,
9
+ createWorkspaceRoleCatalog
9
10
  } from "../../../shared/roles.js";
10
11
  import {
11
12
  mapWorkspaceSummary
@@ -144,8 +145,13 @@ function createService({
144
145
  }
145
146
 
146
147
  const list = await workspacesRepository.listForUserId(normalizedUserId, options);
148
+ const roleCatalog = createWorkspaceRoleCatalog(appConfig);
147
149
  const accessible = list
148
- .map((entry) => mapWorkspaceSummary(entry, { roleSid: entry.roleSid, status: entry.membershipStatus }))
150
+ .map((entry) => mapWorkspaceSummary(
151
+ entry,
152
+ { roleSid: entry.roleSid, status: entry.membershipStatus },
153
+ roleCatalog
154
+ ))
149
155
  .filter((entry) => entry.isAccessible);
150
156
 
151
157
  return accessible;
@@ -12,11 +12,12 @@ function renderDefaultWorkspaceInviteEmail({
12
12
  workspace = {},
13
13
  inviter = null,
14
14
  roleSid = "member",
15
+ roleLabel = "",
15
16
  expiresAt = ""
16
17
  } = {}) {
17
18
  const workspaceName = String(workspace?.name || workspace?.slug || "the workspace").trim() || "the workspace";
18
19
  const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
19
- const roleName = String(roleSid || "member").trim() || "member";
20
+ const roleName = (typeof roleLabel === "string" ? roleLabel.trim() : "") || String(roleSid || "member").trim() || "member";
20
21
  const expiryText = expiresAt ? `This invitation expires at ${expiresAt}.` : "";
21
22
  const intro = inviterName
22
23
  ? `${inviterName} invited you to join ${workspaceName} as ${roleName}.`
@@ -110,12 +110,14 @@ function createService({
110
110
  }
111
111
 
112
112
  try {
113
+ const roleLabel = resolvedRoleCatalog.roles?.find((role) => role.id === invite.roleSid)?.label;
113
114
  const message = await resolvedWorkspaceInviteEmailTemplate({
114
115
  email: invite.email,
115
116
  inviteUrl,
116
117
  workspace: mapWorkspaceSummary(workspace),
117
118
  inviter: user || null,
118
119
  roleSid: invite.roleSid,
120
+ ...(roleLabel ? { roleLabel } : {}),
119
121
  expiresAt: invite.expiresAt
120
122
  });
121
123
  const result = await resolvedWorkspaceInviteMailer.sendWorkspaceInvite({
@@ -6,7 +6,8 @@ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
6
6
 
7
7
  function createService({
8
8
  workspaceInvitesRepository,
9
- workspaceMembershipsRepository
9
+ workspaceMembershipsRepository,
10
+ roleCatalog = null
10
11
  } = {}) {
11
12
  if (!workspaceInvitesRepository || !workspaceMembershipsRepository) {
12
13
  throw new Error("workspacePendingInvitationsService requires invite and membership repositories.");
@@ -15,6 +16,12 @@ function createService({
15
16
  let acceptanceParticipant = null;
16
17
  let registrationClosed = false;
17
18
 
19
+ function roleLabelMetadata(roleSid) {
20
+ const role = roleCatalog?.roles?.find((entry) => entry.id === (normalizeLowerText(roleSid || "member") || "member"));
21
+ const label = typeof role?.label === "string" ? role.label.trim() : "";
22
+ return label ? { roleLabel: label } : {};
23
+ }
24
+
18
25
  function registerAcceptanceParticipant(participant) {
19
26
  if (typeof participant !== "function") {
20
27
  throw new TypeError("Acceptance participant must be a function.");
@@ -123,6 +130,7 @@ function createService({
123
130
  email: normalizeLowerText(invite.email),
124
131
  maskedEmail: maskInviteEmail(invite.email),
125
132
  roleSid: normalizeLowerText(invite.roleSid || "member") || "member",
133
+ ...roleLabelMetadata(invite.roleSid),
126
134
  expiresAt: invite.expiresAt || null,
127
135
  workspace: {
128
136
  id: normalizeRecordId(invite.workspaceId, { fallback: "" }),
@@ -149,6 +157,7 @@ function createService({
149
157
  workspaceName: normalizeText(invite.workspaceName || invite.workspaceSlug),
150
158
  workspaceAvatarUrl: normalizeText(invite.workspaceAvatarUrl),
151
159
  roleSid: normalizeLowerText(invite.roleSid || "member") || "member",
160
+ ...roleLabelMetadata(invite.roleSid),
152
161
  status: normalizeLowerText(invite.status || "pending") || "pending",
153
162
  expiresAt: invite.expiresAt || null,
154
163
  token: encodeInviteTokenHash(tokenHash)
@@ -34,6 +34,7 @@ const invitationResolveOutputSchema = createSchema({
34
34
  email: { type: "string", required: true },
35
35
  maskedEmail: { type: "string", required: true },
36
36
  roleSid: { type: "string", required: true },
37
+ roleLabel: { type: "string", required: false },
37
38
  expiresAt: { type: "string", required: false, nullable: true, minLength: 1 },
38
39
  workspace: {
39
40
  type: "object",
@@ -53,6 +54,7 @@ const pendingInvitationsListOutputSchema = createSchema({
53
54
  workspaceName: { type: "string", required: true, minLength: 1, maxLength: 160 },
54
55
  workspaceAvatarUrl: { type: "string", required: true },
55
56
  roleSid: { type: "string", required: true, minLength: 1, maxLength: 64 },
57
+ roleLabel: { type: "string", required: false },
56
58
  status: { type: "string", required: true, minLength: 1, maxLength: 64 },
57
59
  expiresAt: { type: "string", required: false, nullable: true, minLength: 1 },
58
60
  token: { type: "string", required: true, minLength: 1 }
@@ -33,6 +33,7 @@ const workspaceListItemSchema = createSchema({
33
33
  name: { type: "string", required: true, minLength: 1, maxLength: 160 },
34
34
  avatarUrl: { type: "string", required: true },
35
35
  roleSid: { type: "string", required: true, minLength: 1, maxLength: 64 },
36
+ roleLabel: { type: "string", required: false },
36
37
  isAccessible: { type: "boolean", required: true }
37
38
  });
38
39
 
@@ -2,6 +2,7 @@ import { createSchema } from "json-rest-schema";
2
2
 
3
3
  const workspaceRoleDescriptorSchema = createSchema({
4
4
  id: { type: "string", required: true, minLength: 1, maxLength: 64 },
5
+ label: { type: "string", required: false },
5
6
  assignable: { type: "boolean", required: true },
6
7
  permissions: {
7
8
  type: "array",
@@ -47,11 +47,13 @@ function resolveInheritedRolePermissions(roleSid, configuredRoles = {}, seenRole
47
47
 
48
48
  function createRoleDescriptor(roleSid, configuredDefinition, configuredRoles = {}) {
49
49
  const source = asRecord(configuredDefinition);
50
+ const label = typeof source.label === "string" ? source.label.trim() : "";
50
51
  const assignable = roleSid === OWNER_ROLE_ID ? false : source.assignable === true;
51
52
  const permissions = resolveInheritedRolePermissions(roleSid, configuredRoles);
52
53
 
53
54
  return Object.freeze({
54
55
  id: roleSid,
56
+ ...(label ? { label } : {}),
55
57
  assignable,
56
58
  permissions: Object.freeze([...permissions])
57
59
  });
@@ -99,6 +101,7 @@ function createWorkspaceRoleCatalog(appConfig = {}) {
99
101
  roles.map((role) =>
100
102
  Object.freeze({
101
103
  id: role.id,
104
+ ...(role.label ? { label: role.label } : {}),
102
105
  assignable: role.assignable,
103
106
  permissions: Object.freeze([...role.permissions])
104
107
  })
@@ -117,6 +120,7 @@ function cloneWorkspaceRoleCatalog(roleCatalog = null) {
117
120
  roles: Array.isArray(source.roles)
118
121
  ? source.roles.map((role) => ({
119
122
  id: normalizeRoleId(role?.id),
123
+ ...(typeof role?.label === "string" && role.label.trim() ? { label: role.label.trim() } : {}),
120
124
  assignable: role?.assignable === true,
121
125
  permissions: Array.isArray(role?.permissions) ? [...role.permissions] : []
122
126
  }))
@@ -129,6 +133,7 @@ function listRoleDescriptors(appConfig = {}) {
129
133
  const roleCatalog = createWorkspaceRoleCatalog(appConfig);
130
134
  return roleCatalog.roles.map((role) => ({
131
135
  id: role.id,
136
+ ...(role.label ? { label: role.label } : {}),
132
137
  assignable: role.assignable,
133
138
  permissions: [...role.permissions]
134
139
  }));
@@ -0,0 +1,135 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
4
+ import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
5
+ import { createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog, listRoleDescriptors } from "../src/shared/roles.js";
6
+ import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
7
+ import { workspaceSettingsResource, WORKSPACE_SETTINGS_FIELD_KEYS } from "../src/shared/resources/workspaceSettingsResource.js";
8
+ import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
9
+ import { createService as createMembersService } from "../src/server/workspaceMembers/workspaceMembersService.js";
10
+ import { createService as createSettingsService } from "../src/server/workspaceSettings/workspaceSettingsService.js";
11
+ import { createService as createPendingService } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsService.js";
12
+ import { renderDefaultWorkspaceInviteEmail } from "../src/server/workspaceMembers/defaultWorkspaceInviteEmail.js";
13
+
14
+ const config = {
15
+ roleCatalog: {
16
+ workspace: { defaultInviteRole: "worker" },
17
+ roles: {
18
+ owner: { label: "Owner", assignable: true, permissions: ["*"] },
19
+ worker: { label: " Member ", assignable: true, permissions: ["training.view"] },
20
+ safety_manager: { label: "Operator", assignable: true, inherits: "worker", permissions: ["training.manage"] },
21
+ administrator: { label: "Controller", assignable: true, inherits: "safety_manager", permissions: ["workspace.members.manage"] },
22
+ training_assessor: { label: "Training assessor", assignable: true, permissions: ["training.assess"] }
23
+ }
24
+ }
25
+ };
26
+ const catalog = createWorkspaceRoleCatalog(config);
27
+ const workspace = { id: "7", slug: "example", name: "Example", ownerUserId: "9", avatarUrl: "" };
28
+ const expectedLabels = ["Owner", "Member", "Operator", "Controller", "Training assessor"];
29
+
30
+ function output(resource, operation, value) {
31
+ return validateSchemaPayload(resource.operations[operation].output, value, { phase: "output" });
32
+ }
33
+
34
+ test("configured labels survive creation, cloning and listing without changing role policy", () => {
35
+ for (const roles of [catalog.roles, cloneWorkspaceRoleCatalog(catalog).roles, listRoleDescriptors(config)]) {
36
+ assert.deepEqual(roles.map((role) => role.label), expectedLabels);
37
+ assert.equal(roles[0].assignable, false);
38
+ assert.deepEqual(roles[2].permissions, ["training.view", "training.manage"]);
39
+ assert.deepEqual(roles[4].permissions, ["training.assess"]);
40
+ }
41
+ assert.equal(catalog.defaultInviteRole, "worker");
42
+ assert.equal(catalog.assignableRoleIds.includes("owner"), false);
43
+ assert.equal(Object.isFrozen(catalog.roles[1]), true);
44
+ const clone = cloneWorkspaceRoleCatalog(catalog);
45
+ clone.roles[1].label = "Edited";
46
+ assert.equal(catalog.roles[1].label, "Member");
47
+ });
48
+
49
+ test("missing, whitespace and non-string labels retain the original descriptor shape", () => {
50
+ const roles = createWorkspaceRoleCatalog({ roleCatalog: { roles: {
51
+ member: { assignable: true, permissions: [] },
52
+ blank: { label: " ", permissions: [] },
53
+ invalid: { label: 123, permissions: [] }
54
+ } } }).roles;
55
+ assert.equal(roles.every((role) => !Object.hasOwn(role, "label")), true);
56
+ assert.deepEqual(cloneWorkspaceRoleCatalog({ roles }).roles, roles);
57
+ });
58
+
59
+ test("Members and Invitations response validation preserves labels and saved role IDs", async () => {
60
+ let savedRole = "worker";
61
+ const service = createMembersService({
62
+ roleCatalog: catalog,
63
+ inviteExpiresInMs: 60_000,
64
+ workspaceMembershipsRepository: {
65
+ async listActiveByWorkspaceId() {
66
+ return [{ userId: "11", roleSid: savedRole, status: "active", displayName: "Person", email: "person@example.com" }];
67
+ },
68
+ async findByWorkspaceIdAndUserId() { return { userId: "11", roleSid: savedRole, status: "active" }; },
69
+ async upsertMembership(_workspaceId, _userId, patch) { savedRole = patch.roleSid; }
70
+ },
71
+ workspaceInvitesRepository: { async listPendingByWorkspaceIdWithWorkspace() { return []; } }
72
+ });
73
+ for (const roleSid of ["worker", "safety_manager", "administrator", "training_assessor"]) {
74
+ await service.updateMemberRole(workspace, { memberUserId: "11", roleSid });
75
+ const reloaded = output(workspaceMembersResource, "membersList", await service.listMembers(workspace));
76
+ assert.equal(savedRole, roleSid);
77
+ assert.equal(reloaded.members[0].roleSid, roleSid);
78
+ assert.deepEqual(reloaded.roleCatalog.roles.map((role) => role.label), expectedLabels);
79
+ }
80
+ const roles = output(workspaceMembersResource, "rolesList", await service.listRoles());
81
+ assert.deepEqual(roles.roles.map((role) => role.label), expectedLabels);
82
+ const invites = output(workspaceMembersResource, "invitesList", await service.listInvites(workspace));
83
+ assert.deepEqual(invites.roleCatalog.roles.map((role) => role.label), expectedLabels);
84
+ await assert.rejects(service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "Controller" }), /Validation failed/);
85
+ await assert.rejects(service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "owner" }), /Validation failed/);
86
+ });
87
+
88
+ test("Workspace Settings response validation preserves configured labels", async () => {
89
+ const service = createSettingsService({
90
+ roleCatalog: catalog,
91
+ workspaceSettingsRepository: {
92
+ async ensureForWorkspaceId() {
93
+ return { ...Object.fromEntries(WORKSPACE_SETTINGS_FIELD_KEYS.map((key) => [key, "#123456"])), invitesEnabled: true };
94
+ }
95
+ }
96
+ });
97
+ const response = output(workspaceSettingsResource, "view", await service.getWorkspaceSettings(workspace, {
98
+ context: { actor: { id: "9" }, permissions: ["workspace.settings.view"] }
99
+ }));
100
+ assert.deepEqual(response.roleCatalog.roles.map((role) => role.label), expectedLabels);
101
+ });
102
+
103
+ test("pending and resolved invitation responses derive labels while acceptance stores the role ID", async () => {
104
+ const tokenHash = "a".repeat(64);
105
+ const token = encodeInviteTokenHash(tokenHash);
106
+ const invite = { id: "10", workspaceId: "7", workspaceSlug: "example", workspaceName: "Example", roleSid: "safety_manager", status: "pending", email: "person@example.com", expiresAt: "2099-01-01T00:00:00.000Z", tokenHash };
107
+ let membership;
108
+ const service = createPendingService({
109
+ roleCatalog: catalog,
110
+ workspaceInvitesRepository: {
111
+ async listPendingByEmail() { return [invite]; },
112
+ async findByTokenHashWithWorkspace() { return invite; },
113
+ async findPendingByTokenHash() { return invite; },
114
+ async withTransaction(work) { return work({}); },
115
+ async markAcceptedById() {}
116
+ },
117
+ workspaceMembershipsRepository: { async upsertMembership(_workspaceId, _userId, patch) { membership = patch; } }
118
+ });
119
+ const user = { id: "11", email: invite.email };
120
+ const pending = output(workspacePendingInvitationsResource, "list", { pendingInvites: await service.listPendingInvitesForUser(user) });
121
+ const resolved = output(workspacePendingInvitationsResource, "resolve", await service.resolveInviteByToken(token));
122
+ assert.equal(pending.pendingInvites[0].roleLabel, "Operator");
123
+ assert.equal(resolved.roleLabel, "Operator");
124
+ assert.equal(resolved.roleSid, "safety_manager");
125
+ await service.acceptInviteByToken({ user, token });
126
+ assert.deepEqual(membership, { roleSid: "safety_manager", status: "active" });
127
+ assert.equal(Object.hasOwn(invite, "roleLabel"), false);
128
+ });
129
+
130
+ test("default invitation email uses the configured label with HTML escaping and the original fallback", () => {
131
+ const labelled = renderDefaultWorkspaceInviteEmail({ roleSid: "worker", roleLabel: " Member <team> " });
132
+ assert.match(labelled.text, /as Member <team>/u);
133
+ assert.match(labelled.html, /as Member &lt;team&gt;/u);
134
+ assert.match(renderDefaultWorkspaceInviteEmail({ roleSid: "worker" }).text, /as worker/u);
135
+ });