@jskit-ai/workspaces-core 0.2.4 → 0.2.5

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 (75) hide show
  1. package/locales/en.json +2 -2
  2. package/package.json +38 -11
  3. package/patterns/workspace-server/PATTERN.md +42 -17
  4. package/patterns/workspace-server/example/packages/main/src/server/WorkspaceInvitationsFeature.js +27 -0
  5. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteEmail.js +11 -10
  6. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteMailer.js +27 -0
  7. package/src/server/WorkspacesFeature.js +74 -183
  8. package/src/server/WorkspacesIntegrationsProvider.js +26 -10
  9. package/src/server/common/contributors/workspaceActionContextContributor.js +60 -70
  10. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +12 -27
  11. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +48 -63
  12. package/src/server/common/formatters/workspaceFormatter.js +12 -10
  13. package/src/server/common/repositories/workspaceInvitesRepository.js +74 -75
  14. package/src/server/common/repositories/workspaceMembershipsRepository.js +23 -45
  15. package/src/server/common/repositories/workspacesRepository.js +17 -25
  16. package/src/server/common/services/{workspaceContextService.js → workspaceDirectoryService.js} +52 -72
  17. package/src/server/common/support/realtimeServiceEvents.js +20 -12
  18. package/src/server/common/support/workspaceRecordId.js +21 -0
  19. package/src/server/common/validators/routeParamsValidator.js +3 -2
  20. package/src/server/i18n.js +5 -2
  21. package/src/server/support/resolveWorkspace.js +10 -7
  22. package/src/server/support/workspaceActionSurfaces.js +5 -41
  23. package/src/server/support/workspaceCrud.js +25 -0
  24. package/src/server/support/workspaceInvitationsPolicy.js +16 -27
  25. package/src/server/support/workspaceRouteInput.js +19 -2
  26. package/src/server/{common/support → support}/workspaceRoutePaths.js +8 -2
  27. package/src/server/types.js +47 -0
  28. package/src/server/workspaceBootstrapContributor.js +105 -142
  29. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +105 -137
  30. package/src/server/workspaceDirectory/{bootWorkspaceDirectoryRoutes.js → workspaceDirectoryRoutes.js} +7 -19
  31. package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +15 -29
  32. package/src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js +25 -0
  33. package/src/server/workspaceMembers/workspaceInviteUrls.js +24 -23
  34. package/src/server/workspaceMembers/workspaceMembersActions.js +189 -219
  35. package/src/server/workspaceMembers/{bootWorkspaceMembers.js → workspaceMembersRoutes.js} +14 -26
  36. package/src/server/workspaceMembers/workspaceMembersService.js +110 -97
  37. package/src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js +8 -0
  38. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +79 -112
  39. package/src/server/workspacePendingInvitations/{bootWorkspacePendingInvitations.js → workspacePendingInvitationsRoutes.js} +11 -8
  40. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +54 -57
  41. package/src/server/workspaceSettings/workspaceSettingsActions.js +53 -73
  42. package/src/server/workspaceSettings/workspaceSettingsRepository.js +20 -20
  43. package/src/server/workspaceSettings/{bootWorkspaceSettings.js → workspaceSettingsRoutes.js} +9 -21
  44. package/src/server/workspaceSettings/workspaceSettingsService.js +24 -26
  45. package/src/server/workspacesRuntime.js +185 -0
  46. package/src/shared/operationMessages.js +6 -15
  47. package/src/shared/resources/workspaceMembersResource.js +2 -2
  48. package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
  49. package/src/shared/resources/workspaceResource.js +4 -1
  50. package/src/shared/roles.js +15 -12
  51. package/src/shared/tenancyProfile.js +2 -2
  52. package/test/exportsContract.test.js +5 -0
  53. package/test/featureRuntime.test.js +5 -6
  54. package/test/packageMetadata.test.js +12 -0
  55. package/test/repositories.integration.test.js +1 -1
  56. package/test/requestedWorkspaceScope.test.js +24 -0
  57. package/test/roleLabels.test.js +29 -7
  58. package/test/support/invitationTransactions.js +35 -19
  59. package/test/workspaceActionSurfaces.test.js +0 -55
  60. package/test/workspaceAuthPolicyContextResolver.test.js +1 -1
  61. package/test/workspaceBootstrapContributor.test.js +7 -6
  62. package/test/workspaceCrud.test.js +23 -0
  63. package/test/workspaceInvitesRepository.test.js +9 -8
  64. package/test/workspaceMembersService.test.js +131 -28
  65. package/test/workspacePendingInvitationsResource.test.js +2 -2
  66. package/test/workspacePendingInvitationsService.test.js +69 -22
  67. package/test/workspaceService.test.js +58 -47
  68. package/test/workspaceSettingsActions.test.js +14 -7
  69. package/test/workspaceSettingsRepository.test.js +11 -0
  70. package/test/workspaceSettingsService.test.js +11 -9
  71. package/test/workspacesRepository.test.js +16 -16
  72. package/test/workspacesRouteRequestInputValidator.test.js +4 -4
  73. package/src/server/common/repositories/repositoryUtils.js +0 -43
  74. package/src/server/common/support/deepFreeze.js +0 -1
  75. package/src/server/common/support/resolveActionUser.js +0 -9
@@ -0,0 +1,185 @@
1
+ import { toDatabaseDateTimeUtc } from "@jskit-ai/database-runtime/shared";
2
+ import {
3
+ addResourceIfMissing,
4
+ createJsonRestResourceScopeOptions
5
+ } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
6
+ import { workspaceInvitesResource } from "../shared/resources/workspaceInvitesResource.js";
7
+ import { workspaceMembershipsResource } from "../shared/resources/workspaceMembershipsResource.js";
8
+ import { workspaceResource } from "../shared/resources/workspaceResource.js";
9
+ import { workspaceSettingsResource } from "../shared/resources/workspaceSettingsResource.js";
10
+ import { createWorkspaceRoleCatalog } from "../shared/roles.js";
11
+ import { WORKSPACE_SETTINGS_COLOR_FIELDS, normalizeWorkspaceHexColor } from "../shared/settings.js";
12
+ import { resolveTenancyProfile } from "../shared/tenancyProfile.js";
13
+ import { createRepository as createWorkspaceInvitesRepository } from "./common/repositories/workspaceInvitesRepository.js";
14
+ import { createRepository as createWorkspaceMembershipsRepository } from "./common/repositories/workspaceMembershipsRepository.js";
15
+ import { createRepository as createWorkspacesRepository } from "./common/repositories/workspacesRepository.js";
16
+ import { createService as createWorkspaceService } from "./common/services/workspaceDirectoryService.js";
17
+ import { createWorkspacesServerI18n } from "./i18n.js";
18
+ import { createWorkspaceInviteUrlBuilder } from "./workspaceMembers/workspaceInviteUrls.js";
19
+ import { createService as createWorkspaceMembersService } from "./workspaceMembers/workspaceMembersService.js";
20
+ import { createService as createWorkspacePendingInvitationsService } from "./workspacePendingInvitations/workspacePendingInvitationsService.js";
21
+ import { createRepository as createWorkspaceSettingsRepository } from "./workspaceSettings/workspaceSettingsRepository.js";
22
+ import { createService as createWorkspaceSettingsService } from "./workspaceSettings/workspaceSettingsService.js";
23
+ import { resolveWorkspaceInvitationsPolicy } from "./support/workspaceInvitationsPolicy.js";
24
+ import { workspaceSlugParamsValidator } from "./common/validators/routeParamsValidator.js";
25
+ import { buildWorkspaceInputFromRouteParams } from "./support/workspaceRouteInput.js";
26
+ import { resolveWorkspace } from "./support/resolveWorkspace.js";
27
+
28
+ function requirePositiveInteger(value, label) {
29
+ const normalized = Number(value);
30
+ if (!Number.isInteger(normalized) || normalized < 1) {
31
+ throw new TypeError(`${label} must be a positive integer.`);
32
+ }
33
+ return normalized;
34
+ }
35
+
36
+ function requireBoolean(value, label) {
37
+ if (typeof value !== "boolean") {
38
+ throw new TypeError(`${label} must be boolean.`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function requireWorkspaceColorDefaults(defaults) {
44
+ const colors = {};
45
+ for (const fieldName of WORKSPACE_SETTINGS_COLOR_FIELDS) {
46
+ if (defaults?.[fieldName] === undefined) {
47
+ continue;
48
+ }
49
+ const color = normalizeWorkspaceHexColor(defaults[fieldName]);
50
+ if (!color) {
51
+ throw new TypeError(`runtime.config.workspaceSettings.defaults.${fieldName} must be a hex color like #1867C0.`);
52
+ }
53
+ colors[fieldName] = color;
54
+ }
55
+ return colors;
56
+ }
57
+
58
+ /** @param {import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi} jsonRestApi */
59
+ async function installWorkspaceResources(jsonRestApi) {
60
+ const scopeOptions = { writeSerializers: { "datetime-utc": toDatabaseDateTimeUtc } };
61
+ await addResourceIfMissing(
62
+ jsonRestApi,
63
+ "workspaces",
64
+ createJsonRestResourceScopeOptions(workspaceResource, scopeOptions)
65
+ );
66
+ await addResourceIfMissing(
67
+ jsonRestApi,
68
+ "workspaceMemberships",
69
+ createJsonRestResourceScopeOptions(workspaceMembershipsResource, scopeOptions)
70
+ );
71
+ await addResourceIfMissing(
72
+ jsonRestApi,
73
+ "workspaceInvites",
74
+ createJsonRestResourceScopeOptions(workspaceInvitesResource, scopeOptions)
75
+ );
76
+ await addResourceIfMissing(
77
+ jsonRestApi,
78
+ "workspaceSettings",
79
+ createJsonRestResourceScopeOptions(workspaceSettingsResource, scopeOptions)
80
+ );
81
+ }
82
+
83
+ /**
84
+ * The `workspaces.core` capability: the workspace repositories and services,
85
+ * built from the runtime config.
86
+ * @param {object} dependencies
87
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeAppRoot} dependencies.appRoot
88
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeConfig} dependencies.config
89
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} dependencies.env
90
+ * @param {import("./types.js").WorkspaceInviteMailer} dependencies.inviteMailer
91
+ * @param {import("./types.js").WorkspaceInviteEmailTemplate} dependencies.inviteEmailTemplate
92
+ * @param {import("./types.js").WorkspaceInviteAcceptanceParticipant} dependencies.inviteAcceptanceParticipant
93
+ * @param {import("./types.js").PersonalWorkspaceName} dependencies.personalWorkspaceName
94
+ * @param {import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi} dependencies.jsonRestApi
95
+ * @param {import("@jskit-ai/users-core/server/types").UsersCore} dependencies.users
96
+ */
97
+ function createWorkspacesRuntime({
98
+ appRoot,
99
+ config,
100
+ env,
101
+ inviteMailer,
102
+ inviteEmailTemplate,
103
+ inviteAcceptanceParticipant,
104
+ personalWorkspaceName,
105
+ jsonRestApi,
106
+ users
107
+ }) {
108
+ const tenancyProfile = resolveTenancyProfile(config);
109
+ const invitationsPolicy = resolveWorkspaceInvitationsPolicy({ appConfig: config, tenancyProfile });
110
+ const invitationsEnabled = invitationsPolicy.enabled === true;
111
+ const roleCatalog = createWorkspaceRoleCatalog(config);
112
+ const workspaceSettingsRepository = createWorkspaceSettingsRepository({
113
+ api: jsonRestApi,
114
+ defaultInvitesEnabled: requireBoolean(
115
+ config?.workspaceSettings?.defaults?.invitesEnabled,
116
+ "runtime.config.workspaceSettings.defaults.invitesEnabled"
117
+ ),
118
+ defaultColorSettings: requireWorkspaceColorDefaults(config?.workspaceSettings?.defaults)
119
+ });
120
+ const workspacesRepository = createWorkspacesRepository({ api: jsonRestApi });
121
+ const workspaceMembershipsRepository = createWorkspaceMembershipsRepository({ api: jsonRestApi });
122
+ const workspaceInvitesRepository = createWorkspaceInvitesRepository({ api: jsonRestApi });
123
+ const workspaceService = createWorkspaceService({
124
+ tenancyProfile,
125
+ roleCatalog,
126
+ personalWorkspaceName,
127
+ workspacesRepository,
128
+ workspaceMembershipsRepository,
129
+ workspaceSettingsRepository
130
+ });
131
+ const workspacePendingInvitationsService = createWorkspacePendingInvitationsService({
132
+ workspaceInvitesRepository,
133
+ workspaceMembershipsRepository,
134
+ roleCatalog,
135
+ acceptanceParticipant: inviteAcceptanceParticipant
136
+ });
137
+ const workspaceMembersService = createWorkspaceMembersService({
138
+ workspaceMembershipsRepository,
139
+ workspaceInvitesRepository,
140
+ inviteExpiresInMs: requirePositiveInteger(
141
+ config?.workspaceMembers?.defaults?.inviteExpiresInMs,
142
+ "runtime.config.workspaceMembers.defaults.inviteExpiresInMs"
143
+ ),
144
+ roleCatalog,
145
+ workspaceInvitationsEnabled: invitationsEnabled,
146
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: config, env }),
147
+ workspaceInviteMailer: inviteMailer,
148
+ workspaceInviteEmailTemplate: inviteEmailTemplate,
149
+ i18n: createWorkspacesServerI18n({ appRoot }),
150
+ userProfilesRepository: users.repositories.userProfiles,
151
+ userSettingsRepository: users.repositories.userSettings
152
+ });
153
+ const workspaceSettingsService = createWorkspaceSettingsService({
154
+ workspaceSettingsRepository,
155
+ workspaceInvitationsEnabled: invitationsEnabled,
156
+ roleCatalog
157
+ });
158
+
159
+ return Object.freeze({
160
+ enabled: tenancyProfile.workspace.enabled === true,
161
+ invitationsEnabled,
162
+ selfCreateEnabled: tenancyProfile.workspace.allowSelfCreate === true,
163
+ // The workspace route support the assistant runtime reads.
164
+ scope: Object.freeze({
165
+ params: workspaceSlugParamsValidator,
166
+ buildInputFromRouteParams: buildWorkspaceInputFromRouteParams,
167
+ resolveWorkspace
168
+ }),
169
+ tenancyProfile,
170
+ repositories: Object.freeze({
171
+ workspaceInvites: workspaceInvitesRepository,
172
+ workspaceMemberships: workspaceMembershipsRepository,
173
+ workspaceSettings: workspaceSettingsRepository,
174
+ workspaces: workspacesRepository
175
+ }),
176
+ services: Object.freeze({
177
+ directory: workspaceService,
178
+ members: workspaceMembersService,
179
+ pendingInvitations: workspacePendingInvitationsService,
180
+ settings: workspaceSettingsService
181
+ })
182
+ });
183
+ }
184
+
185
+ export { createWorkspacesRuntime, installWorkspaceResources };
@@ -1,16 +1,7 @@
1
- function createOperationMessages({
2
- validationMessage = "Validation failed.",
3
- apiValidationMessage = validationMessage
4
- } = {}) {
5
- const validation = String(validationMessage || "Validation failed.");
6
- const apiValidation = String(apiValidationMessage || validation || "Validation failed.");
1
+ /** The messages a resource's operations show when their input fails validation. */
2
+ const OPERATION_MESSAGES = Object.freeze({
3
+ validation: "Validation failed.",
4
+ apiValidation: "Validation failed."
5
+ });
7
6
 
8
- return Object.freeze({
9
- validation,
10
- apiValidation
11
- });
12
- }
13
-
14
- export {
15
- createOperationMessages
16
- };
7
+ export { OPERATION_MESSAGES };
@@ -1,7 +1,7 @@
1
1
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
2
2
  import { createSchema } from "json-rest-schema";
3
3
  import { defineResource } from "@jskit-ai/resource-core/shared/resource";
4
- import { createOperationMessages } from "../operationMessages.js";
4
+ import { OPERATION_MESSAGES } from "../operationMessages.js";
5
5
  import { workspaceRoleCatalogSchema } from "./workspaceRoleCatalogSchema.js";
6
6
 
7
7
  const workspaceSummaryOutputSchema = createSchema({
@@ -206,7 +206,7 @@ const redeemInviteOutputSchema = createSchema({
206
206
 
207
207
  const workspaceMembersResource = defineResource({
208
208
  namespace: "workspaceMembers",
209
- messages: createOperationMessages(),
209
+ messages: OPERATION_MESSAGES,
210
210
  operations: {
211
211
  rolesList: {
212
212
  method: "GET",
@@ -1,7 +1,7 @@
1
1
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
2
2
  import { createSchema } from "json-rest-schema";
3
3
  import { defineResource } from "@jskit-ai/resource-core/shared/resource";
4
- import { createOperationMessages } from "../operationMessages.js";
4
+ import { OPERATION_MESSAGES } from "../operationMessages.js";
5
5
 
6
6
  const invitationTokenQuerySchema = createSchema({
7
7
  token: {
@@ -64,7 +64,7 @@ const pendingInvitationsListOutputSchema = createSchema({
64
64
 
65
65
  const workspacePendingInvitationsResource = defineResource({
66
66
  namespace: "workspacePendingInvitations",
67
- messages: createOperationMessages(),
67
+ messages: OPERATION_MESSAGES,
68
68
  operations: {
69
69
  resolve: {
70
70
  method: "GET",
@@ -56,6 +56,9 @@ const workspacePatchBodySchema = createSchema({
56
56
  }
57
57
  });
58
58
 
59
+ // The fields a workspace patch may change.
60
+ const WORKSPACE_PATCH_FIELD_KEYS = Object.freeze(["name", "avatarUrl"]);
61
+
59
62
  const workspaceListItemOutputValidator = createSchemaDefinition(workspaceListItemSchema, "replace");
60
63
 
61
64
  const resource = defineCrudResource({
@@ -146,4 +149,4 @@ const resource = defineCrudResource({
146
149
  });
147
150
 
148
151
  export { resource as workspaceResource };
149
- export { workspaceListItemOutputValidator };
152
+ export { WORKSPACE_PATCH_FIELD_KEYS, workspaceListItemOutputValidator };
@@ -65,11 +65,11 @@ function listConfiguredRoleIds(appConfig = {}) {
65
65
  }
66
66
 
67
67
  function resolveConfiguredDefaultInviteRole(appConfig = {}) {
68
- return normalizeRoleId(appConfig?.roleCatalog?.workspace?.defaultInviteRole);
68
+ return normalizeRoleId(appConfig.roleCatalog?.workspace?.defaultInviteRole);
69
69
  }
70
70
 
71
71
  function normalizeConfiguredRoles(appConfig = {}) {
72
- const roleCatalog = asRecord(appConfig?.roleCatalog);
72
+ const roleCatalog = asRecord(appConfig.roleCatalog);
73
73
  const configuredRoles = asRecord(roleCatalog.roles);
74
74
  const normalizedRoles = {};
75
75
 
@@ -139,19 +139,21 @@ function listRoleDescriptors(appConfig = {}) {
139
139
  }));
140
140
  }
141
141
 
142
- function resolveRolePermissions(roleSid, appConfig = {}) {
142
+ /**
143
+ * The permissions of one role in a built role catalog; none for an unknown role.
144
+ * @param {import("../server/types.js").WorkspaceRoleCatalog} roleCatalog
145
+ * @param {unknown} roleSid
146
+ * @returns {string[]}
147
+ */
148
+ function listCatalogRolePermissions(roleCatalog, roleSid) {
143
149
  const normalizedRoleId = normalizeRoleId(roleSid);
144
- if (!normalizedRoleId) {
145
- return [];
146
- }
147
-
148
- const roleCatalog = createWorkspaceRoleCatalog(appConfig);
149
150
  const role = roleCatalog.roles.find((entry) => entry.id === normalizedRoleId);
150
- if (!role) {
151
- return [];
152
- }
151
+ return role ? [...role.permissions] : [];
152
+ }
153
153
 
154
- return [...role.permissions];
154
+ /** The permissions of one role in the app's configured role catalog. */
155
+ function resolveRolePermissions(roleSid, appConfig = {}) {
156
+ return listCatalogRolePermissions(createWorkspaceRoleCatalog(appConfig), roleSid);
155
157
  }
156
158
 
157
159
  export {
@@ -159,6 +161,7 @@ export {
159
161
  ADMIN_ROLE_ID,
160
162
  MEMBER_ROLE_ID,
161
163
  resolveRolePermissions,
164
+ listCatalogRolePermissions,
162
165
  listRoleDescriptors,
163
166
  createWorkspaceRoleCatalog,
164
167
  cloneWorkspaceRoleCatalog,
@@ -11,7 +11,7 @@ const WORKSPACE_SLUG_POLICY_IMMUTABLE_USERNAME = "immutable_username";
11
11
  const WORKSPACE_SLUG_POLICY_USER_SELECTED = "user_selected";
12
12
 
13
13
  function resolveWorkspacePolicyOverrides(appConfig = {}) {
14
- const tenancyPolicy = isRecord(appConfig?.tenancyPolicy) ? appConfig.tenancyPolicy : {};
14
+ const tenancyPolicy = isRecord(appConfig.tenancyPolicy) ? appConfig.tenancyPolicy : {};
15
15
  const workspacePolicy = isRecord(tenancyPolicy.workspace) ? tenancyPolicy.workspace : {};
16
16
 
17
17
  return Object.freeze({
@@ -48,7 +48,7 @@ function resolveWorkspacePolicy(mode, overrides = {}) {
48
48
  }
49
49
 
50
50
  function resolveTenancyProfile(appConfig = {}) {
51
- const mode = normalizeTenancyMode(appConfig?.tenancyMode);
51
+ const mode = normalizeTenancyMode(appConfig.tenancyMode);
52
52
  const workspacePolicyOverrides = resolveWorkspacePolicyOverrides(appConfig);
53
53
 
54
54
  return Object.freeze({
@@ -18,8 +18,13 @@ test("workspaces-core exports are explicit and aligned with production usage", (
18
18
  "./server/WorkspacesIntegrationsProvider",
19
19
  "./server/validators/routeParamsValidator",
20
20
  "./server/support/workspaceRouteInput",
21
+ // Workspace CRUD features take their scope, workspace id and route paths from these.
22
+ "./server/support/workspaceCrud",
23
+ "./server/support/workspaceRoutePaths",
21
24
  "./shared/resources/workspaceResource",
22
25
  "./shared/resources/workspaceSettingsResource",
26
+ // Features that require this package's capabilities take their types from it.
27
+ "./server/types",
23
28
  // The generated client bootstrap registers it so clients render these server codes.
24
29
  "./locales/en.json"
25
30
  ]
@@ -1,9 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createAuthExtensions } from "@jskit-ai/auth-core/server/authExtensions";
4
- import { createActionProvider } from "@jskit-ai/kernel/server/actions";
4
+ import { ActionProvider } from "@jskit-ai/kernel/server/actions";
5
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
5
6
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
6
- import { createBootstrapRuntime } from "@jskit-ai/kernel/server/runtime";
7
+ import { createBootstrapRuntime, EventProvider } from "@jskit-ai/kernel/server/runtime";
7
8
  import { createUsersExtensions } from "@jskit-ai/users-core/server/usersExtensions";
8
9
  import { WorkspacesFeature, createWorkspacesRuntime } from "../src/server/WorkspacesFeature.js";
9
10
  import { WorkspacesIntegrationsProvider } from "../src/server/WorkspacesIntegrationsProvider.js";
@@ -66,12 +67,12 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
66
67
  setup({ actions, workspaceCapability }) {
67
68
  actionCatalogue = actions;
68
69
  workspaces = workspaceCapability;
69
- workspaceCapability.services.pendingInvitations.registerAcceptanceParticipant(async () => {});
70
70
  return {};
71
71
  }
72
72
  });
73
73
  const runtime = createCapabilityRuntime({
74
74
  inputs: {
75
+ "runtime.logger": silentLogger,
75
76
  "auth.extensions": authExtensions,
76
77
  "runtime.app-root": process.cwd(),
77
78
  "runtime.bootstrap": bootstrap,
@@ -100,7 +101,7 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
100
101
  },
101
102
  "users.extensions": usersExtensions
102
103
  },
103
- providers: [createActionProvider(), WorkspacesFeature, WorkspacesIntegrationsProvider, probe]
104
+ providers: [EventProvider, ActionProvider, WorkspacesFeature, WorkspacesIntegrationsProvider, probe]
104
105
  });
105
106
 
106
107
  await runtime.start();
@@ -114,8 +115,6 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
114
115
  assert.equal(typeof workspaces.services.directory.resolveWorkspaceContextForUserBySlug, "function");
115
116
  assert.equal(typeof workspaces.services.members.prepareInvite, "function");
116
117
  assert.equal(typeof workspaces.services.members.sendInvite, "function");
117
- assert.throws(() => workspaces.services.pendingInvitations.registerAcceptanceParticipant(null), /must be a function/);
118
- assert.throws(() => workspaces.services.pendingInvitations.registerAcceptanceParticipant(async () => {}), /already registered/);
119
118
  assert.equal(routes.includes("POST /api/workspaces"), true);
120
119
  assert.equal(routes.includes("GET /api/workspace/invitations/resolve"), true);
121
120
  assert.equal(actionCatalogue.listDefinitions().length, 16);
@@ -57,3 +57,15 @@ test("workspaces-core publishes an app-owned editable workspace invite email pat
57
57
  assert.match(source, /inviteUrl/);
58
58
  assert.deepEqual(packageMetadata.migrations, { directories: ["migrations"] });
59
59
  });
60
+
61
+ test("workspaces-core's pattern provides the invite email and mailer from one application feature", async () => {
62
+ const source = await readFile(
63
+ path.join(PACKAGE_DIR, "patterns", "workspace-server", "example", "packages", "main", "src", "server", "WorkspaceInvitationsFeature.js"),
64
+ "utf8"
65
+ );
66
+
67
+ assert.match(source, /inviteEmailTemplate: "workspaces\.invite-email-template"/);
68
+ assert.match(source, /inviteMailer: "workspaces\.invite-mailer"/);
69
+ assert.match(source, /inviteEmailTemplate: renderWorkspaceInviteEmail/);
70
+ assert.match(source, /inviteMailer: createWorkspaceInviteMailer\(\{ env \}\)/);
71
+ });
@@ -113,7 +113,7 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
113
113
  workspaces = createWorkspacesRepository({ api });
114
114
  memberships = createMembershipsRepository({ api });
115
115
  invites = createInvitesRepository({ api });
116
- settings = createSettingsRepository({ api });
116
+ settings = createSettingsRepository({ api, defaultInvitesEnabled: false, defaultColorSettings: {} });
117
117
  });
118
118
 
119
119
  beforeEach(async () => {
@@ -0,0 +1,24 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { BOOTSTRAP_PATH } from "@jskit-ai/kernel/shared/surface/paths";
4
+ import { requestedWorkspaceScope } from "../src/server/support/workspaceRouteInput.js";
5
+
6
+ test("requestedWorkspaceScope reads the route's workspace slug", () => {
7
+ const request = {
8
+ params: { workspaceSlug: " Acme " },
9
+ query: { workspaceSlug: "other" },
10
+ routeOptions: { url: "/api/w/:workspaceSlug/contacts" }
11
+ };
12
+
13
+ assert.deepEqual(requestedWorkspaceScope(request), { workspaceSlug: "acme" });
14
+ });
15
+
16
+ test("requestedWorkspaceScope reads the bootstrap request's workspace query, and no other route's", () => {
17
+ assert.equal(BOOTSTRAP_PATH, "/api/bootstrap");
18
+ assert.deepEqual(requestedWorkspaceScope({ params: {}, query: { workspaceSlug: "Acme" }, routeOptions: { url: BOOTSTRAP_PATH } }), {
19
+ workspaceSlug: "acme"
20
+ });
21
+ assert.deepEqual(requestedWorkspaceScope({ params: {}, query: { workspaceSlug: "acme" }, routeOptions: { url: "/api/session" } }), {});
22
+ assert.deepEqual(requestedWorkspaceScope({ routeOptions: { url: BOOTSTRAP_PATH } }), {});
23
+ assert.deepEqual(requestedWorkspaceScope({}), {});
24
+ });
@@ -7,9 +7,13 @@ import { workspaceMembersResource } from "../src/shared/resources/workspaceMembe
7
7
  import { workspaceSettingsResource, WORKSPACE_SETTINGS_FIELD_KEYS } from "../src/shared/resources/workspaceSettingsResource.js";
8
8
  import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
9
9
  import { createService as createMembersService } from "../src/server/workspaceMembers/workspaceMembersService.js";
10
+ import { createWorkspacesServerI18n } from "../src/server/i18n.js";
11
+ import { noInviteAcceptanceParticipant } from "../src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js";
10
12
  import { createService as createSettingsService } from "../src/server/workspaceSettings/workspaceSettingsService.js";
11
13
  import { createService as createPendingService } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsService.js";
12
14
  import { renderDefaultWorkspaceInviteEmail } from "../src/server/workspaceMembers/defaultWorkspaceInviteEmail.js";
15
+ import { createWorkspaceInviteUrlBuilder } from "../src/server/workspaceMembers/workspaceInviteUrls.js";
16
+ import { unconfiguredWorkspaceInviteMailer } from "../src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js";
13
17
 
14
18
  const config = {
15
19
  roleCatalog: {
@@ -59,6 +63,8 @@ test("missing, whitespace and non-string labels retain the original descriptor s
59
63
  test("Members and Invitations response validation preserves labels and saved role IDs", async () => {
60
64
  let savedRole = "worker";
61
65
  const service = createMembersService({
66
+ workspaceInvitationsEnabled: true,
67
+ i18n: createWorkspacesServerI18n(),
62
68
  roleCatalog: catalog,
63
69
  inviteExpiresInMs: 60_000,
64
70
  workspaceMembershipsRepository: {
@@ -68,7 +74,10 @@ test("Members and Invitations response validation preserves labels and saved rol
68
74
  async findByWorkspaceIdAndUserId() { return { userId: "11", roleSid: savedRole, status: "active" }; },
69
75
  async upsertMembership(_workspaceId, _userId, patch) { savedRole = patch.roleSid; }
70
76
  },
71
- workspaceInvitesRepository: { async listPendingByWorkspaceIdWithWorkspace() { return []; } }
77
+ workspaceInvitesRepository: { async listPendingByWorkspaceIdWithWorkspace() { return []; } },
78
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
79
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
80
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
72
81
  });
73
82
  for (const roleSid of ["worker", "safety_manager", "administrator", "training_assessor"]) {
74
83
  await service.updateMemberRole(workspace, { memberUserId: "11", roleSid });
@@ -79,7 +88,7 @@ test("Members and Invitations response validation preserves labels and saved rol
79
88
  }
80
89
  const roles = output(workspaceMembersResource, "rolesList", await service.listRoles());
81
90
  assert.deepEqual(roles.roles.map((role) => role.label), expectedLabels);
82
- const invites = output(workspaceMembersResource, "invitesList", await service.listInvites(workspace));
91
+ const invites = output(workspaceMembersResource, "invitesList", (await service.listInvitesResponse(workspace)).value);
83
92
  assert.deepEqual(invites.roleCatalog.roles.map((role) => role.label), expectedLabels);
84
93
  await assert.rejects(service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "Controller" }), /Validation failed/);
85
94
  await assert.rejects(service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "owner" }), /Validation failed/);
@@ -87,6 +96,7 @@ test("Members and Invitations response validation preserves labels and saved rol
87
96
 
88
97
  test("Workspace Settings response validation preserves configured labels", async () => {
89
98
  const service = createSettingsService({
99
+ workspaceInvitationsEnabled: true,
90
100
  roleCatalog: catalog,
91
101
  workspaceSettingsRepository: {
92
102
  async ensureForWorkspaceId() {
@@ -94,9 +104,9 @@ test("Workspace Settings response validation preserves configured labels", async
94
104
  }
95
105
  }
96
106
  });
97
- const response = output(workspaceSettingsResource, "view", await service.getWorkspaceSettings(workspace, {
107
+ const response = output(workspaceSettingsResource, "view", (await service.getWorkspaceSettings(workspace, {
98
108
  context: { actor: { id: "9" }, permissions: ["workspace.settings.view"] }
99
- }));
109
+ })).value);
100
110
  assert.deepEqual(response.roleCatalog.roles.map((role) => role.label), expectedLabels);
101
111
  });
102
112
 
@@ -106,6 +116,7 @@ test("pending and resolved invitation responses derive labels while acceptance s
106
116
  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
117
  let membership;
108
118
  const service = createPendingService({
119
+ acceptanceParticipant: noInviteAcceptanceParticipant,
109
120
  roleCatalog: catalog,
110
121
  workspaceInvitesRepository: {
111
122
  async listPendingByEmail() { return [invite]; },
@@ -118,7 +129,7 @@ test("pending and resolved invitation responses derive labels while acceptance s
118
129
  });
119
130
  const user = { id: "11", email: invite.email };
120
131
  const pending = output(workspacePendingInvitationsResource, "list", { pendingInvites: await service.listPendingInvitesForUser(user) });
121
- const resolved = output(workspacePendingInvitationsResource, "resolve", await service.resolveInviteByToken(token));
132
+ const resolved = output(workspacePendingInvitationsResource, "resolve", (await service.resolveInviteByToken(token)).value);
122
133
  assert.equal(pending.pendingInvites[0].roleLabel, "Operator");
123
134
  assert.equal(resolved.roleLabel, "Operator");
124
135
  assert.equal(resolved.roleSid, "safety_manager");
@@ -128,8 +139,19 @@ test("pending and resolved invitation responses derive labels while acceptance s
128
139
  });
129
140
 
130
141
  test("default invitation email uses the configured label with HTML escaping and the original fallback", () => {
131
- const labelled = renderDefaultWorkspaceInviteEmail({ roleSid: "worker", roleLabel: " Member <team> " });
142
+ const i18n = createWorkspacesServerI18n();
143
+ const input = {
144
+ email: "reader@example.test",
145
+ inviteUrl: "/invite/abc",
146
+ workspace: { name: "Acme", slug: "acme" },
147
+ inviter: null,
148
+ roleSid: "worker",
149
+ expiresAt: "2026-11-02T00:00:00.000Z",
150
+ locale: "en",
151
+ t: (key, params) => i18n.t("en", key, params)
152
+ };
153
+ const labelled = renderDefaultWorkspaceInviteEmail({ ...input, roleLabel: "Member <team>" });
132
154
  assert.match(labelled.text, /as Member <team>/u);
133
155
  assert.match(labelled.html, /as Member &lt;team&gt;/u);
134
- assert.match(renderDefaultWorkspaceInviteEmail({ roleSid: "worker" }).text, /as worker/u);
156
+ assert.match(renderDefaultWorkspaceInviteEmail(input).text, /as worker/u);
135
157
  });