@jskit-ai/workspaces-core 0.1.226 → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,83 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 - 2026-10-29
4
+
5
+ - Translation ready: every user-visible text is a message key with its English in `locales/en.json`, so an app translates it by supplying translation files only. With no translations, texts and behaviour are unchanged. See the guide "Translating a JSKIT app".
6
+ - Workspace invitation emails are rendered in the recipient's saved locale (`createServerI18n`); `WorkspacesFeature` requires the `runtime.app-root` capability.
7
+
8
+ ### New message keys (34)
9
+
10
+ All keys in `locales/en.json` (exported as `./locales/en.json`) are new in this version.
11
+
12
+ <details><summary>Key list</summary>
13
+
14
+ - `jskit.workspaces.auth.required`
15
+ - `jskit.workspaces.user.invalidPayload`
16
+ - `jskit.workspaces.workspace.slugUnavailable`
17
+ - `jskit.workspaces.workspace.creationDisabled`
18
+ - `jskit.workspaces.workspace.nameRequired`
19
+ - `jskit.workspaces.workspace.contextDisabled`
20
+ - `jskit.workspaces.workspace.slugRequired`
21
+ - `jskit.workspaces.workspace.notFound`
22
+ - `jskit.workspaces.workspace.accessDenied`
23
+ - `jskit.workspaces.member.roleNotAssignable`
24
+ - `jskit.workspaces.member.notFound`
25
+ - `jskit.workspaces.member.ownerRoleLocked`
26
+ - `jskit.workspaces.member.ownerNotRemovable`
27
+ - `jskit.workspaces.invite.disabled`
28
+ - `jskit.workspaces.invite.notFound`
29
+ - `jskit.workspaces.invite.notFoundOrHandled`
30
+ - `jskit.workspaces.invite.invitationNotFoundOrHandled`
31
+ - `jskit.workspaces.invite.workspaceMissing`
32
+ - `jskit.workspaces.invite.expired`
33
+ - `jskit.workspaces.invite.tokenMismatch`
34
+ - `jskit.workspaces.invite.tokenRequired`
35
+ - `jskit.workspaces.invite.tokenInvalid`
36
+ - `jskit.workspaces.invite.emailMismatchUser`
37
+ - `jskit.workspaces.invite.emailMismatchAccount`
38
+ - `jskit.workspaces.invite.notAvailable`
39
+ - `jskit.workspaces.invite.mailerUnconfigured`
40
+ - `jskit.workspaces.invite.sendFailed`
41
+ - `jskit.workspaces.inviteEmail.subject`
42
+ - `jskit.workspaces.inviteEmail.intro`
43
+ - `jskit.workspaces.inviteEmail.introFromInviter`
44
+ - `jskit.workspaces.inviteEmail.expiry`
45
+ - `jskit.workspaces.inviteEmail.openLink`
46
+ - `jskit.workspaces.inviteEmail.accept`
47
+ - `jskit.workspaces.inviteEmail.workspaceFallback`
48
+
49
+ </details>
50
+
51
+ ### Codes (27)
52
+
53
+ Error, field-error and message codes this package sends, with their English. Undotted codes kept their existing values and render from `jskit.errors.<code>`.
54
+
55
+ | Code | English |
56
+ | --- | --- |
57
+ | `workspaces.auth.required` | Authentication required. |
58
+ | `workspaces.user.invalidPayload` | Invalid authenticated user payload. |
59
+ | `workspaces.workspace.slugUnavailable` | Unable to generate unique workspace slug. |
60
+ | `workspaces.workspace.creationDisabled` | Workspace creation is disabled for this tenancy mode. |
61
+ | `workspaces.workspace.nameRequired` | Workspace name is required. |
62
+ | `workspaces.workspace.contextDisabled` | Workspace context is disabled. |
63
+ | `workspaces.workspace.slugRequired` | workspaceSlug is required. |
64
+ | `workspaces.workspace.notFound` | Workspace not found. |
65
+ | `workspaces.workspace.accessDenied` | You do not have access to this workspace. |
66
+ | `workspaces.member.roleNotAssignable` | Role is not assignable. |
67
+ | `workspaces.member.notFound` | Member not found. |
68
+ | `workspaces.member.ownerRoleLocked` | Cannot change workspace owner role. |
69
+ | `workspaces.member.ownerNotRemovable` | Cannot remove workspace owner. |
70
+ | `workspaces.invite.disabled` | Workspace invitations are disabled. |
71
+ | `workspaces.invite.notFound` | Invite not found. |
72
+ | `workspaces.invite.notFoundOrHandled` | Invite not found or already handled. |
73
+ | `workspaces.invite.invitationNotFoundOrHandled` | Invitation not found or already handled. |
74
+ | `workspaces.invite.workspaceMissing` | Invitation workspace no longer exists. |
75
+ | `workspaces.invite.expired` | Invitation has expired. |
76
+ | `workspaces.invite.tokenMismatch` | Invitation token does not match the saved invitation. |
77
+ | `workspaces.invite.tokenRequired` | Invite token is required. |
78
+ | `workspaces.invite.tokenInvalid` | Invite token is invalid. |
79
+ | `workspaces.invite.emailMismatchUser` | Invitation email does not match authenticated user. |
80
+ | `workspaces.invite.emailMismatchAccount` | Invitation email does not match account email. |
81
+ | `workspaces.invite.notAvailable` | Invitation is not available. |
82
+ | `workspaces.invite.mailerUnconfigured` | No workspace invite mailer is configured. |
83
+ | `workspaces.invite.sendFailed` | Unable to send workspace invite email. |
@@ -0,0 +1,52 @@
1
+ {
2
+ "jskit": {
3
+ "workspaces": {
4
+ "auth": {
5
+ "required": "Authentication required."
6
+ },
7
+ "user": {
8
+ "invalidPayload": "Invalid authenticated user payload."
9
+ },
10
+ "workspace": {
11
+ "slugUnavailable": "Unable to generate unique workspace slug.",
12
+ "creationDisabled": "Workspace creation is disabled for this tenancy mode.",
13
+ "nameRequired": "Workspace name is required.",
14
+ "contextDisabled": "Workspace context is disabled.",
15
+ "slugRequired": "workspaceSlug is required.",
16
+ "notFound": "Workspace not found.",
17
+ "accessDenied": "You do not have access to this workspace."
18
+ },
19
+ "member": {
20
+ "roleNotAssignable": "Role is not assignable.",
21
+ "notFound": "Member not found.",
22
+ "ownerRoleLocked": "Cannot change workspace owner role.",
23
+ "ownerNotRemovable": "Cannot remove workspace owner."
24
+ },
25
+ "invite": {
26
+ "disabled": "Workspace invitations are disabled.",
27
+ "notFound": "Invite not found.",
28
+ "notFoundOrHandled": "Invite not found or already handled.",
29
+ "invitationNotFoundOrHandled": "Invitation not found or already handled.",
30
+ "workspaceMissing": "Invitation workspace no longer exists.",
31
+ "expired": "Invitation has expired.",
32
+ "tokenMismatch": "Invitation token does not match the saved invitation.",
33
+ "tokenRequired": "Invite token is required.",
34
+ "tokenInvalid": "Invite token is invalid.",
35
+ "emailMismatchUser": "Invitation email does not match authenticated user.",
36
+ "emailMismatchAccount": "Invitation email does not match account email.",
37
+ "notAvailable": "Invitation is not available.",
38
+ "mailerUnconfigured": "No workspace invite mailer is configured.",
39
+ "sendFailed": "Unable to send workspace invite email."
40
+ },
41
+ "inviteEmail": {
42
+ "subject": "You're invited to {workspace}",
43
+ "intro": "You have been invited to join {workspace} as {role}.",
44
+ "introFromInviter": "{inviter} invited you to join {workspace} as {role}.",
45
+ "expiry": "This invitation expires at {expiresAt}.",
46
+ "openLink": "Open this link to accept the invitation:",
47
+ "accept": "Accept invitation",
48
+ "workspaceFallback": "the workspace"
49
+ }
50
+ }
51
+ }
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.1.226",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -15,14 +15,15 @@
15
15
  "./shared/tenancyProfile": "./src/shared/tenancyProfile.js",
16
16
  "./shared/support/workspacePathModel": "./src/shared/support/workspacePathModel.js",
17
17
  "./shared/resources/workspaceResource": "./src/shared/resources/workspaceResource.js",
18
- "./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js"
18
+ "./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js",
19
+ "./locales/en.json": "./locales/en.json"
19
20
  },
20
21
  "dependencies": {
21
- "@jskit-ai/database-runtime": "0.1.248",
22
- "@jskit-ai/json-rest-api-core": "0.1.192",
23
- "@jskit-ai/resource-core": "0.1.190",
24
- "@jskit-ai/resource-crud-core": "0.1.190",
25
- "@jskit-ai/users-core": "0.1.261",
22
+ "@jskit-ai/database-runtime": "0.1.249",
23
+ "@jskit-ai/json-rest-api-core": "0.2.0",
24
+ "@jskit-ai/resource-core": "0.1.191",
25
+ "@jskit-ai/resource-crud-core": "0.1.191",
26
+ "@jskit-ai/users-core": "0.2.0",
26
27
  "json-rest-schema": "^1.0.17"
27
28
  },
28
29
  "description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
@@ -35,6 +36,7 @@
35
36
  "requires": [
36
37
  "auth.extensions",
37
38
  "runtime.actions",
39
+ "runtime.app-root",
38
40
  "runtime.bootstrap",
39
41
  "runtime.config",
40
42
  "runtime.database",
@@ -169,8 +171,8 @@
169
171
  }
170
172
  },
171
173
  "peerDependencies": {
172
- "@jskit-ai/auth-core": "0.1.246",
173
- "@jskit-ai/http-runtime": "0.1.246",
174
- "@jskit-ai/kernel": "0.1.248"
174
+ "@jskit-ai/auth-core": "0.2.0",
175
+ "@jskit-ai/http-runtime": "0.2.0",
176
+ "@jskit-ai/kernel": "0.2.0"
175
177
  }
176
178
  }
@@ -49,6 +49,13 @@ responses expose optional `roleLabel` alongside the unchanged `roleSid`.
49
49
  Applications with their own invitation renderer may use its optional
50
50
  `roleLabel` input; `roleSid` remains the stored role id.
51
51
 
52
+ Invitation emails use the recipient's language: `locale` is the saved
53
+ `user_settings.locale` of an existing account with the invited email, else `en`.
54
+ Renderers receive `locale` and `t(key, params)`, which renders a
55
+ `jskit.workspaces.inviteEmail.*` message from the app's
56
+ `src/locales/<code>/jskit.json` (exact locale, then language, then English); the
57
+ mailer's `sendWorkspaceInvite` receives `locale` too.
58
+
52
59
  Keep existing permissions, inheritance, assignability and
53
60
  `workspace.defaultInviteRole` when adding a label. `owner` remains
54
61
  nonassignable. Custom roles, including specialised Training roles, are ordinary
@@ -15,6 +15,7 @@ import { createRepository as createWorkspaceInvitesRepository } from "./common/r
15
15
  import { createRepository as createWorkspaceMembershipsRepository } from "./common/repositories/workspaceMembershipsRepository.js";
16
16
  import { createRepository as createWorkspacesRepository } from "./common/repositories/workspacesRepository.js";
17
17
  import { createService as createWorkspaceService } from "./common/services/workspaceContextService.js";
18
+ import { createWorkspacesServerI18n } from "./i18n.js";
18
19
  import { createWorkspaceBootstrapContributor } from "./workspaceBootstrapContributor.js";
19
20
  import { registerWorkspaceDirectoryRoutes } from "./workspaceDirectory/bootWorkspaceDirectoryRoutes.js";
20
21
  import { buildWorkspaceDirectoryActions } from "./workspaceDirectory/workspaceDirectoryActions.js";
@@ -98,7 +99,7 @@ async function installWorkspaceResources(jsonRestApi) {
98
99
  );
99
100
  }
100
101
 
101
- function createWorkspacesRuntime({ config, env, jsonRestApi } = {}) {
102
+ function createWorkspacesRuntime({ appRoot = "", config, env, jsonRestApi, users = null } = {}) {
102
103
  const tenancyProfile = resolveTenancyProfile(config);
103
104
  const invitationsPolicy = resolveWorkspaceInvitationsPolicy({ config, appConfig: config, tenancyProfile });
104
105
  const invitationsEnabled = invitationsPolicy.enabled === true;
@@ -136,7 +137,10 @@ function createWorkspacesRuntime({ config, env, jsonRestApi } = {}) {
136
137
  workspaceInvitationsEnabled: invitationsEnabled,
137
138
  inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: config, env }),
138
139
  workspaceInviteMailer: config?.workspaceInviteMailer || null,
139
- workspaceInviteEmailTemplate: resolveInviteEmailTemplate(config)
140
+ workspaceInviteEmailTemplate: resolveInviteEmailTemplate(config),
141
+ i18n: createWorkspacesServerI18n({ appRoot }),
142
+ userProfilesRepository: users?.repositories?.userProfiles || null,
143
+ userSettingsRepository: users?.repositories?.userSettings || null
140
144
  });
141
145
  const workspaceSettingsService = createWorkspaceSettingsService({
142
146
  workspaceSettingsRepository,
@@ -174,6 +178,7 @@ const WorkspacesFeature = defineFeature({
174
178
  id: "workspaces.core",
175
179
  domain: "workspace",
176
180
  requires: {
181
+ appRoot: "runtime.app-root",
177
182
  bootstrap: "runtime.bootstrap",
178
183
  config: "runtime.config",
179
184
  env: "runtime.env",
@@ -184,9 +189,9 @@ const WorkspacesFeature = defineFeature({
184
189
  provides: {
185
190
  workspaces: "workspaces.core"
186
191
  },
187
- async setup({ bootstrap, config, env, http, jsonRestApi, users }) {
192
+ async setup({ appRoot, bootstrap, config, env, http, jsonRestApi, users }) {
188
193
  await installWorkspaceResources(jsonRestApi);
189
- const workspaces = createWorkspacesRuntime({ config, env, jsonRestApi });
194
+ const workspaces = createWorkspacesRuntime({ appRoot, config, env, jsonRestApi, users });
190
195
 
191
196
  bootstrap.register({
192
197
  id: "workspaces.bootstrap",
@@ -146,7 +146,7 @@ function createRepository({ api } = {}) {
146
146
  }
147
147
  const row = await options.trx("workspaces").where({ id }).forUpdate().first("id");
148
148
  if (!row) {
149
- throw new AppError(404, "Invitation workspace no longer exists.");
149
+ throw new AppError(404, "Invitation workspace no longer exists.", { code: "workspaces.invite.workspaceMissing" });
150
150
  }
151
151
  }
152
152
 
@@ -335,7 +335,9 @@ function createRepository({ api } = {}) {
335
335
  if (!workspaceId) {
336
336
  const candidate = await queryInvites({ id }, { ...options, lock: false });
337
337
  if (!candidate[0]) {
338
- throw new AppError(404, "Invitation not found or already handled.");
338
+ throw new AppError(404, "Invitation not found or already handled.", {
339
+ code: "workspaces.invite.invitationNotFoundOrHandled"
340
+ });
339
341
  }
340
342
  workspaceId = normalizeInviteRecord(candidate[0]).workspaceId;
341
343
  }
@@ -344,7 +346,9 @@ function createRepository({ api } = {}) {
344
346
  }
345
347
  const invite = await findPendingByIdForWorkspace(id, workspaceId, { ...options, lock: true });
346
348
  if (!invite) {
347
- throw new AppError(404, "Invitation not found or already handled.");
349
+ throw new AppError(404, "Invitation not found or already handled.", {
350
+ code: "workspaces.invite.invitationNotFoundOrHandled"
351
+ });
348
352
  }
349
353
  const updatedAt = new Date().toISOString();
350
354
  await api.resources.workspaceInvites.patch(
@@ -96,7 +96,7 @@ function createService({
96
96
  return candidate;
97
97
  }
98
98
  }
99
- throw new AppError(500, "Unable to generate unique workspace slug.");
99
+ throw new AppError(500, "Unable to generate unique workspace slug.", { code: "workspaces.workspace.slugUnavailable" });
100
100
  }
101
101
 
102
102
  async function ensureWorkspaceSettingsForWorkspace(workspace, options = {}) {
@@ -109,7 +109,7 @@ function createService({
109
109
  async function ensurePersonalWorkspaceForUser(user, options = {}) {
110
110
  const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
111
111
  if (!normalizedUserId) {
112
- throw new AppError(400, "Invalid authenticated user payload.");
112
+ throw new AppError(400, "Invalid authenticated user payload.", { code: "workspaces.user.invalidPayload" });
113
113
  }
114
114
 
115
115
  const existing = await workspacesRepository.findPersonalByOwnerUserId(normalizedUserId, options);
@@ -166,7 +166,7 @@ function createService({
166
166
  async function ensureProvisionedWorkspaceForAuthenticatedUser(user, options = {}) {
167
167
  const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
168
168
  if (!normalizedUserId) {
169
- throw new AppError(400, "Invalid authenticated user payload.");
169
+ throw new AppError(400, "Invalid authenticated user payload.", { code: "workspaces.user.invalidPayload" });
170
170
  }
171
171
 
172
172
  if (workspacePolicy.autoProvision !== true) {
@@ -182,16 +182,18 @@ function createService({
182
182
  async function createWorkspaceForAuthenticatedUser(user, payload = {}, options = {}) {
183
183
  const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
184
184
  if (!normalizedUserId) {
185
- throw new AppError(401, "Authentication required.");
185
+ throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
186
186
  }
187
187
 
188
188
  if (workspacePolicy.allowSelfCreate !== true) {
189
- throw new AppError(403, "Workspace creation is disabled for this tenancy mode.");
189
+ throw new AppError(403, "Workspace creation is disabled for this tenancy mode.", {
190
+ code: "workspaces.workspace.creationDisabled"
191
+ });
190
192
  }
191
193
 
192
194
  const createInput = normalizeWorkspaceCreationInput(payload);
193
195
  if (!createInput.name) {
194
- throw new AppError(400, "Workspace name is required.");
196
+ throw new AppError(400, "Workspace name is required.", { code: "workspaces.workspace.nameRequired" });
195
197
  }
196
198
 
197
199
  const slugBase = createInput.requestedSlug || toSlugPart(createInput.name);
@@ -226,22 +228,22 @@ function createService({
226
228
  const requireMembership = options?.requireMembership !== false;
227
229
  const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
228
230
  if (!normalizedUserId) {
229
- throw new AppError(401, "Authentication required.");
231
+ throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
230
232
  }
231
233
 
232
234
  if (resolvedTenancyMode === TENANCY_MODE_NONE) {
233
- throw new AppError(403, "Workspace context is disabled.");
235
+ throw new AppError(403, "Workspace context is disabled.", { code: "workspaces.workspace.contextDisabled" });
234
236
  }
235
237
 
236
238
  const normalizedWorkspaceSlug = normalizeLowerText(workspaceSlug);
237
239
  if (!normalizedWorkspaceSlug) {
238
- throw new AppError(400, "workspaceSlug is required.");
240
+ throw new AppError(400, "workspaceSlug is required.", { code: "workspaces.workspace.slugRequired" });
239
241
  }
240
242
 
241
243
  const workspace = await workspacesRepository.findBySlug(normalizedWorkspaceSlug, options);
242
244
 
243
245
  if (!workspace) {
244
- throw new AppError(404, "Workspace not found.");
246
+ throw new AppError(404, "Workspace not found.", { code: "workspaces.workspace.notFound" });
245
247
  }
246
248
 
247
249
  let membership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(
@@ -259,7 +261,7 @@ function createService({
259
261
  }
260
262
 
261
263
  if ((!membership || normalizeLowerText(membership.status) !== "active") && requireMembership) {
262
- throw new AppError(403, "You do not have access to this workspace.");
264
+ throw new AppError(403, "You do not have access to this workspace.", { code: "workspaces.workspace.accessDenied" });
263
265
  }
264
266
 
265
267
  if (!membership || normalizeLowerText(membership.status) !== "active") {
@@ -0,0 +1,10 @@
1
+ import { createServerI18n } from "@jskit-ai/kernel/server/i18n";
2
+ import en from "../../locales/en.json" with { type: "json" };
3
+
4
+ // Workspace texts rendered on the server (invitation emails) in the recipient's
5
+ // language, from the app's translations under appRoot, or English.
6
+ function createWorkspacesServerI18n({ appRoot = "" } = {}) {
7
+ return createServerI18n({ appRoot, catalogs: [en] });
8
+ }
9
+
10
+ export { createWorkspacesServerI18n };
@@ -1,3 +1,11 @@
1
+ import { createWorkspacesServerI18n } from "../i18n.js";
2
+
3
+ const englishI18n = createWorkspacesServerI18n();
4
+
5
+ function english(key, params) {
6
+ return englishI18n.t("en", key, params);
7
+ }
8
+
1
9
  function escapeHtml(value = "") {
2
10
  return String(value ?? "")
3
11
  .replaceAll("&", "&amp;")
@@ -7,29 +15,33 @@ function escapeHtml(value = "") {
7
15
  .replaceAll("'", "&#39;");
8
16
  }
9
17
 
18
+ // t renders a message in the recipient's language. The markup stays here; each
19
+ // translated fragment, parameters included, is escaped before it enters the HTML.
10
20
  function renderDefaultWorkspaceInviteEmail({
11
21
  inviteUrl = "",
12
22
  workspace = {},
13
23
  inviter = null,
14
24
  roleSid = "member",
15
25
  roleLabel = "",
16
- expiresAt = ""
26
+ expiresAt = "",
27
+ t = english
17
28
  } = {}) {
18
- const workspaceName = String(workspace?.name || workspace?.slug || "the workspace").trim() || "the workspace";
29
+ const workspaceName =
30
+ String(workspace?.name || workspace?.slug || "").trim() || t("jskit.workspaces.inviteEmail.workspaceFallback");
19
31
  const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
20
32
  const roleName = (typeof roleLabel === "string" ? roleLabel.trim() : "") || String(roleSid || "member").trim() || "member";
21
- const expiryText = expiresAt ? `This invitation expires at ${expiresAt}.` : "";
33
+ const expiryText = expiresAt ? t("jskit.workspaces.inviteEmail.expiry", { expiresAt }) : "";
22
34
  const intro = inviterName
23
- ? `${inviterName} invited you to join ${workspaceName} as ${roleName}.`
24
- : `You have been invited to join ${workspaceName} as ${roleName}.`;
25
- const subject = `You're invited to ${workspaceName}`;
26
- const text = [intro, expiryText, `Open this link to accept the invitation:`, inviteUrl]
35
+ ? t("jskit.workspaces.inviteEmail.introFromInviter", { inviter: inviterName, workspace: workspaceName, role: roleName })
36
+ : t("jskit.workspaces.inviteEmail.intro", { workspace: workspaceName, role: roleName });
37
+ const subject = t("jskit.workspaces.inviteEmail.subject", { workspace: workspaceName });
38
+ const text = [intro, expiryText, t("jskit.workspaces.inviteEmail.openLink"), inviteUrl]
27
39
  .filter(Boolean)
28
40
  .join("\n\n");
29
41
  const html = [
30
42
  `<p>${escapeHtml(intro)}</p>`,
31
43
  expiryText ? `<p>${escapeHtml(expiryText)}</p>` : "",
32
- `<p><a href="${escapeHtml(inviteUrl)}">Accept invitation</a></p>`,
44
+ `<p><a href="${escapeHtml(inviteUrl)}">${escapeHtml(t("jskit.workspaces.inviteEmail.accept"))}</a></p>`,
33
45
  `<p>${escapeHtml(inviteUrl)}</p>`
34
46
  ].filter(Boolean).join("\n");
35
47
 
@@ -2,10 +2,19 @@ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
2
2
  import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
4
4
  import { resolveInviteTokenHash, buildInviteToken, hashInviteToken } from "@jskit-ai/auth-core/server/inviteTokens";
5
- import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
5
+ import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
6
6
  import { OWNER_ROLE_ID, createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
7
+ import { createWorkspacesServerI18n } from "../i18n.js";
7
8
  import { renderDefaultWorkspaceInviteEmail } from "./defaultWorkspaceInviteEmail.js";
8
9
 
10
+ const DEFAULT_LOCALE = "en";
11
+
12
+ function roleNotAssignableError() {
13
+ return createValidationError({
14
+ roleSid: { code: "workspaces.member.roleNotAssignable", message: "Role is not assignable." }
15
+ });
16
+ }
17
+
9
18
  function createService({
10
19
  workspaceMembershipsRepository,
11
20
  workspaceInvitesRepository,
@@ -14,7 +23,10 @@ function createService({
14
23
  workspaceInvitationsEnabled = true,
15
24
  inviteUrlBuilder = null,
16
25
  workspaceInviteMailer = null,
17
- workspaceInviteEmailTemplate = renderDefaultWorkspaceInviteEmail
26
+ workspaceInviteEmailTemplate = renderDefaultWorkspaceInviteEmail,
27
+ i18n = createWorkspacesServerI18n(),
28
+ userProfilesRepository = null,
29
+ userSettingsRepository = null
18
30
  } = {}) {
19
31
  if (!workspaceMembershipsRepository || !workspaceInvitesRepository) {
20
32
  throw new Error("workspaceMembersService requires membership and invite repositories.");
@@ -43,7 +55,7 @@ function createService({
43
55
  if (resolvedWorkspaceInvitationsEnabled) {
44
56
  return;
45
57
  }
46
- throw new AppError(403, "Workspace invitations are disabled.");
58
+ throw new AppError(403, "Workspace invitations are disabled.", { code: "workspaces.invite.disabled" });
47
59
  }
48
60
 
49
61
  function withRoleCatalog(payload = {}) {
@@ -100,17 +112,36 @@ function createService({
100
112
  };
101
113
  }
102
114
 
115
+ // The saved locale of an existing account with the invited email, else English.
116
+ // A failed lookup must not stop the invitation, so it also falls back to English.
117
+ async function resolveRecipientLocale(email, options = {}) {
118
+ if (typeof userProfilesRepository?.findByEmail !== "function" ||
119
+ typeof userSettingsRepository?.findByUserId !== "function") {
120
+ return DEFAULT_LOCALE;
121
+ }
122
+ try {
123
+ const profile = await userProfilesRepository.findByEmail(email, options);
124
+ const settings = profile?.id ? await userSettingsRepository.findByUserId(profile.id, options) : null;
125
+ return normalizeText(settings?.locale) || DEFAULT_LOCALE;
126
+ } catch {
127
+ return DEFAULT_LOCALE;
128
+ }
129
+ }
130
+
103
131
  async function deliverInviteEmail({ workspace, user, invite, inviteUrl } = {}, options = {}) {
104
132
  if (!resolvedWorkspaceInviteMailer || typeof resolvedWorkspaceInviteMailer.sendWorkspaceInvite !== "function") {
105
133
  return {
106
134
  status: "mailer_unconfigured",
107
135
  message: "No workspace invite mailer is configured.",
136
+ messageCode: "workspaces.invite.mailerUnconfigured",
137
+ messageParams: {},
108
138
  providerMessageId: ""
109
139
  };
110
140
  }
111
141
 
112
142
  try {
113
143
  const roleLabel = resolvedRoleCatalog.roles?.find((role) => role.id === invite.roleSid)?.label;
144
+ const locale = await resolveRecipientLocale(invite.email, options);
114
145
  const message = await resolvedWorkspaceInviteEmailTemplate({
115
146
  email: invite.email,
116
147
  inviteUrl,
@@ -118,7 +149,9 @@ function createService({
118
149
  inviter: user || null,
119
150
  roleSid: invite.roleSid,
120
151
  ...(roleLabel ? { roleLabel } : {}),
121
- expiresAt: invite.expiresAt
152
+ expiresAt: invite.expiresAt,
153
+ locale,
154
+ t: (key, params) => i18n.t(locale, key, params)
122
155
  });
123
156
  const result = await resolvedWorkspaceInviteMailer.sendWorkspaceInvite({
124
157
  email: invite.email,
@@ -127,6 +160,7 @@ function createService({
127
160
  inviter: user || null,
128
161
  roleSid: invite.roleSid,
129
162
  expiresAt: invite.expiresAt,
163
+ locale,
130
164
  message
131
165
  }, options);
132
166
  return normalizeInviteDeliveryResult(result || { status: "sent" });
@@ -135,6 +169,8 @@ function createService({
135
169
  status: "failed",
136
170
  // Provider errors can contain the secret invitation URL.
137
171
  message: "Unable to send workspace invite email.",
172
+ messageCode: "workspaces.invite.sendFailed",
173
+ messageParams: {},
138
174
  providerMessageId: ""
139
175
  };
140
176
  }
@@ -164,24 +200,18 @@ function createService({
164
200
  const memberUserId = normalizeRecordId(payload.memberUserId, { fallback: null });
165
201
  const roleSid = normalizeLowerText(payload.roleSid || "");
166
202
  if (!memberUserId) {
167
- throw new AppError(400, "Validation failed.");
203
+ throw new AppError(400, "Validation failed.", { code: "validation_failed" });
168
204
  }
169
205
  if (!assignableRoleIds.includes(roleSid)) {
170
- throw new AppError(400, "Validation failed.", {
171
- details: {
172
- fieldErrors: {
173
- roleSid: "Role is not assignable."
174
- }
175
- }
176
- });
206
+ throw roleNotAssignableError();
177
207
  }
178
208
 
179
209
  const existingMembership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(workspace.id, memberUserId, options);
180
210
  if (!existingMembership || existingMembership.status !== "active") {
181
- throw new AppError(404, "Member not found.");
211
+ throw new AppError(404, "Member not found.", { code: "workspaces.member.notFound" });
182
212
  }
183
213
  if (memberUserId === normalizeRecordId(workspace.ownerUserId, { fallback: null }) || existingMembership.roleSid === OWNER_ROLE_ID) {
184
- throw new AppError(409, "Cannot change workspace owner role.");
214
+ throw new AppError(409, "Cannot change workspace owner role.", { code: "workspaces.member.ownerRoleLocked" });
185
215
  }
186
216
 
187
217
  await workspaceMembershipsRepository.upsertMembership(
@@ -200,15 +230,15 @@ function createService({
200
230
  async function removeMember(workspace, payload = {}, options = {}) {
201
231
  const memberUserId = normalizeRecordId(payload.memberUserId, { fallback: null });
202
232
  if (!memberUserId) {
203
- throw new AppError(400, "Validation failed.");
233
+ throw new AppError(400, "Validation failed.", { code: "validation_failed" });
204
234
  }
205
235
 
206
236
  const existingMembership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(workspace.id, memberUserId, options);
207
237
  if (!existingMembership || existingMembership.status !== "active") {
208
- throw new AppError(404, "Member not found.");
238
+ throw new AppError(404, "Member not found.", { code: "workspaces.member.notFound" });
209
239
  }
210
240
  if (memberUserId === normalizeRecordId(workspace.ownerUserId, { fallback: null }) || existingMembership.roleSid === OWNER_ROLE_ID) {
211
- throw new AppError(409, "Cannot remove workspace owner.");
241
+ throw new AppError(409, "Cannot remove workspace owner.", { code: "workspaces.member.ownerNotRemovable" });
212
242
  }
213
243
 
214
244
  await workspaceMembershipsRepository.upsertMembership(
@@ -246,13 +276,7 @@ function createService({
246
276
  const email = normalizeLowerText(payload.email);
247
277
  const roleSid = normalizeLowerText(payload.roleSid || "member") || "member";
248
278
  if (!assignableRoleIds.includes(roleSid)) {
249
- throw new AppError(400, "Validation failed.", {
250
- details: {
251
- fieldErrors: {
252
- roleSid: "Role is not assignable."
253
- }
254
- }
255
- });
279
+ throw roleNotAssignableError();
256
280
  }
257
281
 
258
282
  const token = buildInviteToken();
@@ -291,16 +315,18 @@ function createService({
291
315
  { ...options, includeWorkspace: true, lock: false }
292
316
  );
293
317
  if (!invite) {
294
- throw new AppError(404, "Invite not found or already handled.");
318
+ throw new AppError(404, "Invite not found or already handled.", { code: "workspaces.invite.notFoundOrHandled" });
295
319
  }
296
320
  if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
297
- throw new AppError(409, "Invitation has expired.");
321
+ throw new AppError(409, "Invitation has expired.", { code: "workspaces.invite.expired" });
298
322
  }
299
323
  const token = Object.hasOwn(prepared, "inviteTokenPreview")
300
324
  ? normalizeText(prepared.inviteTokenPreview)
301
325
  : encodeInviteTokenHash(invite.tokenHash);
302
326
  if (!token || resolveInviteTokenHash(token) !== invite.tokenHash) {
303
- throw new AppError(400, "Invitation token does not match the saved invitation.");
327
+ throw new AppError(400, "Invitation token does not match the saved invitation.", {
328
+ code: "workspaces.invite.tokenMismatch"
329
+ });
304
330
  }
305
331
  const savedWorkspace = {
306
332
  ...workspace,
@@ -336,7 +362,7 @@ function createService({
336
362
  ensureWorkspaceInvitationsEnabled();
337
363
  const normalizedInviteId = normalizeRecordId(inviteId, { fallback: null });
338
364
  if (!normalizedInviteId) {
339
- throw new AppError(400, "Validation failed.");
365
+ throw new AppError(400, "Validation failed.", { code: "validation_failed" });
340
366
  }
341
367
 
342
368
  const revoke = async (trx) => {
@@ -347,7 +373,7 @@ function createService({
347
373
  transactionOptions
348
374
  );
349
375
  if (!invite) {
350
- throw new AppError(404, "Invite not found.");
376
+ throw new AppError(404, "Invite not found.", { code: "workspaces.invite.notFound" });
351
377
  }
352
378
  await workspaceInvitesRepository.revokeById(normalizedInviteId, transactionOptions);
353
379
  return normalizeRecordId(invite.id, { fallback: null });
@@ -52,14 +52,14 @@ function createService({
52
52
  }));
53
53
  // Commit expiry revocation before reporting the rejection.
54
54
  if (result.expired) {
55
- throw Object.assign(new AppError(409, "Invitation has expired."), { transactionOutcome: "committed" });
55
+ throw Object.assign(new AppError(409, "Invitation has expired.", { code: "workspaces.invite.expired" }), { transactionOutcome: "committed" });
56
56
  }
57
57
  return result;
58
58
  }
59
59
 
60
60
  function requireAuthenticatedInviteUser(user) {
61
61
  if (!normalizeRecordId(user?.id, { fallback: null })) {
62
- throw new AppError(401, "Authentication required.");
62
+ throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
63
63
  }
64
64
 
65
65
  return user;
@@ -68,12 +68,12 @@ function createService({
68
68
  function requireInviteTokenHash(token) {
69
69
  const normalizedToken = normalizeText(token);
70
70
  if (!normalizedToken) {
71
- throw new AppError(400, "Invite token is required.");
71
+ throw new AppError(400, "Invite token is required.", { code: "workspaces.invite.tokenRequired" });
72
72
  }
73
73
 
74
74
  const tokenHash = resolveInviteTokenHash(normalizedToken);
75
75
  if (!tokenHash) {
76
- throw new AppError(400, "Invite token is invalid.");
76
+ throw new AppError(400, "Invite token is invalid.", { code: "workspaces.invite.tokenInvalid" });
77
77
  }
78
78
 
79
79
  return tokenHash;
@@ -170,11 +170,15 @@ function createService({
170
170
 
171
171
  const invite = await workspaceInvitesRepository.findPendingByTokenHash(tokenHash, options);
172
172
  if (!invite) {
173
- throw new AppError(404, "Invitation not found or already handled.");
173
+ throw new AppError(404, "Invitation not found or already handled.", {
174
+ code: "workspaces.invite.invitationNotFoundOrHandled"
175
+ });
174
176
  }
175
177
 
176
178
  if (normalizeLowerText(invite.email) !== normalizeLowerText(actor?.email)) {
177
- throw new AppError(403, "Invitation email does not match authenticated user.");
179
+ throw new AppError(403, "Invitation email does not match authenticated user.", {
180
+ code: "workspaces.invite.emailMismatchUser"
181
+ });
178
182
  }
179
183
 
180
184
  return {
@@ -206,10 +210,12 @@ function createService({
206
210
  async function resolveInviteContextForAuth({ token, email } = {}, options = {}) {
207
211
  const resolved = await resolveInviteByToken(token, options);
208
212
  if (resolved.status !== "pending") {
209
- throw new AppError(409, "Invitation is not available.");
213
+ throw new AppError(409, "Invitation is not available.", { code: "workspaces.invite.notAvailable" });
210
214
  }
211
215
  if (normalizeLowerText(resolved.email) !== normalizeLowerText(email)) {
212
- throw new AppError(403, "Invitation email does not match account email.");
216
+ throw new AppError(403, "Invitation email does not match account email.", {
217
+ code: "workspaces.invite.emailMismatchAccount"
218
+ });
213
219
  }
214
220
 
215
221
  return {
@@ -105,6 +105,15 @@ const workspaceInviteCreateOutputSchema = createSchema({
105
105
  type: "string",
106
106
  required: true
107
107
  },
108
+ messageCode: {
109
+ type: "string",
110
+ required: false
111
+ },
112
+ messageParams: {
113
+ type: "object",
114
+ required: false,
115
+ additionalProperties: true
116
+ },
108
117
  providerMessageId: {
109
118
  type: "string",
110
119
  required: true
@@ -19,7 +19,9 @@ test("workspaces-core exports are explicit and aligned with production usage", (
19
19
  "./server/validators/routeParamsValidator",
20
20
  "./server/support/workspaceRouteInput",
21
21
  "./shared/resources/workspaceResource",
22
- "./shared/resources/workspaceSettingsResource"
22
+ "./shared/resources/workspaceSettingsResource",
23
+ // The generated client bootstrap registers it so clients render these server codes.
24
+ "./locales/en.json"
23
25
  ]
24
26
  });
25
27
 
@@ -73,6 +73,7 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
73
73
  const runtime = createCapabilityRuntime({
74
74
  inputs: {
75
75
  "auth.extensions": authExtensions,
76
+ "runtime.app-root": process.cwd(),
76
77
  "runtime.bootstrap": bootstrap,
77
78
  "runtime.config": runtimeConfig(),
78
79
  "runtime.env": {},
@@ -0,0 +1,16 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import { evaluateI18nCatalogContract } from "../../../tooling/test-support/i18nCatalogContract.mjs";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
9
+
10
+ test("workspaces-core English catalog matches its source", () => {
11
+ const { problems } = evaluateI18nCatalogContract({
12
+ repoRoot: REPO_ROOT,
13
+ packageDir: path.join(REPO_ROOT, "packages", "workspaces-core")
14
+ });
15
+ assert.deepEqual(problems, []);
16
+ });
@@ -182,7 +182,7 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
182
182
  locale: " EN-AU ", currencyCode: "aud", timeZone: " Australia/Perth ",
183
183
  avatarSize: 96, theme: " dark ", ignoredField: "must not reach storage"
184
184
  });
185
- assert.equal(preferences.locale, "en-au");
185
+ assert.equal(preferences.locale, "en-AU");
186
186
  assert.equal(preferences.currencyCode, "AUD");
187
187
  assert.equal(preferences.timeZone, "Australia/Perth");
188
188
  assert.equal(preferences.avatarSize, 96);
@@ -1,5 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
  import test from "node:test";
6
+ import { createWorkspacesServerI18n } from "../src/server/i18n.js";
3
7
  import { createService } from "../src/server/workspaceMembers/workspaceMembersService.js";
4
8
  import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
5
9
 
@@ -155,6 +159,8 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
155
159
  assert.deepEqual(response.inviteDelivery, {
156
160
  status: "mailer_unconfigured",
157
161
  message: "No workspace invite mailer is configured.",
162
+ messageCode: "workspaces.invite.mailerUnconfigured",
163
+ messageParams: {},
158
164
  providerMessageId: ""
159
165
  });
160
166
  });
@@ -245,7 +251,9 @@ test("workspaceMembersService.createInvite sends generated invite URLs through t
245
251
  assert.equal(templateCalls[0].email, "invitee@example.com");
246
252
  assert.equal(templateCalls[0].workspace.name, "Acme");
247
253
  assert.equal(templateCalls[0].inviteUrl, response.inviteUrl);
254
+ assert.equal(templateCalls[0].locale, "en");
248
255
  assert.equal(mailerCalls.length, 1);
256
+ assert.equal(mailerCalls[0].payload.locale, "en");
249
257
  assert.equal(mailerCalls[0].payload.email, "invitee@example.com");
250
258
  assert.equal(mailerCalls[0].payload.inviteUrl, response.inviteUrl);
251
259
  assert.equal(mailerCalls[0].payload.message.subject, "Join Acme");
@@ -529,3 +537,141 @@ test("workspaceMembersService.removeMember rejects removing the owner", async ()
529
537
  /Cannot remove workspace owner/
530
538
  );
531
539
  });
540
+
541
+ function createMailingService({ accounts = {}, appRoot = "", mailer } = {}) {
542
+ let savedInvite;
543
+ return createService({
544
+ workspaceMembershipsRepository: {
545
+ async listActiveByWorkspaceId() {
546
+ return [];
547
+ }
548
+ },
549
+ workspaceInvitesRepository: {
550
+ async withTransaction(work) {
551
+ return work({});
552
+ },
553
+ async lockWorkspaceForInvitations() {},
554
+ async expirePendingByWorkspaceIdAndEmail() {},
555
+ async insert(payload) {
556
+ savedInvite = { ...payload, id: "88", workspaceSlug: "acme", workspaceName: "Acme & Co" };
557
+ return savedInvite;
558
+ },
559
+ async listPendingByWorkspaceIdWithWorkspace() {
560
+ return [];
561
+ },
562
+ async findPendingByIdForWorkspace() {
563
+ return savedInvite;
564
+ }
565
+ },
566
+ inviteExpiresInMs: 30 * 60 * 1000,
567
+ roleCatalog: createRoleCatalog(),
568
+ inviteUrlBuilder: ({ token }) => `https://app.example.test/invite/${token}`,
569
+ workspaceInviteMailer: mailer,
570
+ i18n: createWorkspacesServerI18n({ appRoot }),
571
+ userProfilesRepository: {
572
+ async findByEmail(email) {
573
+ return accounts[email] ? { id: accounts[email].id } : null;
574
+ }
575
+ },
576
+ userSettingsRepository: {
577
+ async findByUserId(userId) {
578
+ const account = Object.values(accounts).find((entry) => entry.id === userId);
579
+ if (account?.fail) {
580
+ throw new Error("settings unavailable");
581
+ }
582
+ return account ? { locale: account.locale } : null;
583
+ }
584
+ }
585
+ });
586
+ }
587
+
588
+ test("workspaceMembersService renders the default invitation email in the recipient's saved locale", async (t) => {
589
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), "workspaces-invite-i18n-"));
590
+ t.after(() => fs.rmSync(appRoot, { recursive: true, force: true }));
591
+ fs.mkdirSync(path.join(appRoot, "src", "locales", "it"), { recursive: true });
592
+ fs.writeFileSync(path.join(appRoot, "src", "locales", "it", "jskit.json"), JSON.stringify({
593
+ jskit: {
594
+ workspaces: {
595
+ inviteEmail: {
596
+ subject: "Sei invitato in {workspace}",
597
+ introFromInviter: "{inviter} ti ha invitato in {workspace} come {role}.",
598
+ accept: "Accetta l'invito"
599
+ }
600
+ }
601
+ }
602
+ }));
603
+ const sent = [];
604
+ const service = createMailingService({
605
+ appRoot,
606
+ accounts: {
607
+ "rossi@example.com": { id: "21", locale: "it-it" },
608
+ "ada@example.com": { id: "22", locale: "en" },
609
+ "broken@example.com": { id: "23", fail: true }
610
+ },
611
+ mailer: {
612
+ async sendWorkspaceInvite(payload) {
613
+ sent.push(payload);
614
+ return { status: "sent", providerMessageId: `msg-${sent.length}` };
615
+ }
616
+ }
617
+ });
618
+ const workspace = { id: "7", slug: "acme", name: "Acme & Co", ownerUserId: "9", avatarUrl: "" };
619
+ const owner = { id: "9", displayName: "Tony <Owner>" };
620
+ const invite = (email) => service.createInvite(workspace, owner, { email, roleSid: "member" }, authorizedOptions());
621
+
622
+ await invite("rossi@example.com");
623
+ assert.equal(sent[0].locale, "it-it");
624
+ assert.equal(sent[0].message.subject, "Sei invitato in Acme & Co");
625
+ assert.match(sent[0].message.text, /^Tony <Owner> ti ha invitato in Acme & Co come member\./u);
626
+ assert.match(sent[0].message.text, /Open this link to accept the invitation:/u, "missing translations stay English");
627
+ assert.match(sent[0].message.html, /<p>Tony &lt;Owner&gt; ti ha invitato in Acme &amp; Co come member\.<\/p>/u);
628
+ assert.match(sent[0].message.html, />Accetta l&#39;invito<\/a>/u);
629
+
630
+ for (const email of ["ada@example.com", "stranger@example.com", "broken@example.com"]) {
631
+ await invite(email);
632
+ const { locale, message } = sent.at(-1);
633
+ assert.equal(locale, "en", email);
634
+ assert.equal(message.subject, "You're invited to Acme & Co", email);
635
+ assert.match(message.text, /^Tony <Owner> invited you to join Acme & Co as member\./u, email);
636
+ }
637
+ });
638
+
639
+ test("workspaceMembersService reports a failed delivery with a message code", async () => {
640
+ const service = createMailingService({
641
+ mailer: {
642
+ async sendWorkspaceInvite() {
643
+ throw new Error("provider rejected https://app.example.test/invite/secret");
644
+ }
645
+ }
646
+ });
647
+ const response = await service.createInvite(
648
+ { id: "7", slug: "acme", name: "Acme", ownerUserId: "9", avatarUrl: "" },
649
+ { id: "9" },
650
+ { email: "invitee@example.com", roleSid: "member" },
651
+ authorizedOptions()
652
+ );
653
+ assert.deepEqual(response.inviteDelivery, {
654
+ status: "failed",
655
+ message: "Unable to send workspace invite email.",
656
+ messageCode: "workspaces.invite.sendFailed",
657
+ messageParams: {},
658
+ providerMessageId: ""
659
+ });
660
+ });
661
+
662
+ test("workspaceMembersService codes a role that cannot be assigned", async () => {
663
+ const { service, workspace } = createFixture();
664
+ await assert.rejects(
665
+ () => service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "owner" }, authorizedOptions()),
666
+ (error) => {
667
+ assert.equal(error.status, 400);
668
+ assert.equal(error.message, "Validation failed.");
669
+ assert.equal(error.code, "validation_failed");
670
+ assert.deepEqual(error.details, {
671
+ fieldErrors: { roleSid: "Role is not assignable." },
672
+ fieldErrorCodes: { roleSid: { code: "workspaces.member.roleNotAssignable", params: {} } }
673
+ });
674
+ return true;
675
+ }
676
+ );
677
+ });