@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.
- package/locales/en.json +2 -2
- package/package.json +38 -11
- package/patterns/workspace-server/PATTERN.md +42 -17
- package/patterns/workspace-server/example/packages/main/src/server/WorkspaceInvitationsFeature.js +27 -0
- package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteEmail.js +11 -10
- package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteMailer.js +27 -0
- package/src/server/WorkspacesFeature.js +74 -183
- package/src/server/WorkspacesIntegrationsProvider.js +26 -10
- package/src/server/common/contributors/workspaceActionContextContributor.js +60 -70
- package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +12 -27
- package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +48 -63
- package/src/server/common/formatters/workspaceFormatter.js +12 -10
- package/src/server/common/repositories/workspaceInvitesRepository.js +74 -75
- package/src/server/common/repositories/workspaceMembershipsRepository.js +23 -45
- package/src/server/common/repositories/workspacesRepository.js +17 -25
- package/src/server/common/services/{workspaceContextService.js → workspaceDirectoryService.js} +52 -72
- package/src/server/common/support/realtimeServiceEvents.js +20 -12
- package/src/server/common/support/workspaceRecordId.js +21 -0
- package/src/server/common/validators/routeParamsValidator.js +3 -2
- package/src/server/i18n.js +5 -2
- package/src/server/support/resolveWorkspace.js +10 -7
- package/src/server/support/workspaceActionSurfaces.js +5 -41
- package/src/server/support/workspaceCrud.js +25 -0
- package/src/server/support/workspaceInvitationsPolicy.js +16 -27
- package/src/server/support/workspaceRouteInput.js +19 -2
- package/src/server/{common/support → support}/workspaceRoutePaths.js +8 -2
- package/src/server/types.js +47 -0
- package/src/server/workspaceBootstrapContributor.js +105 -142
- package/src/server/workspaceDirectory/workspaceDirectoryActions.js +105 -137
- package/src/server/workspaceDirectory/{bootWorkspaceDirectoryRoutes.js → workspaceDirectoryRoutes.js} +7 -19
- package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +15 -29
- package/src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js +25 -0
- package/src/server/workspaceMembers/workspaceInviteUrls.js +24 -23
- package/src/server/workspaceMembers/workspaceMembersActions.js +189 -219
- package/src/server/workspaceMembers/{bootWorkspaceMembers.js → workspaceMembersRoutes.js} +14 -26
- package/src/server/workspaceMembers/workspaceMembersService.js +110 -97
- package/src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js +8 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +79 -112
- package/src/server/workspacePendingInvitations/{bootWorkspacePendingInvitations.js → workspacePendingInvitationsRoutes.js} +11 -8
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +54 -57
- package/src/server/workspaceSettings/workspaceSettingsActions.js +53 -73
- package/src/server/workspaceSettings/workspaceSettingsRepository.js +20 -20
- package/src/server/workspaceSettings/{bootWorkspaceSettings.js → workspaceSettingsRoutes.js} +9 -21
- package/src/server/workspaceSettings/workspaceSettingsService.js +24 -26
- package/src/server/workspacesRuntime.js +185 -0
- package/src/shared/operationMessages.js +6 -15
- package/src/shared/resources/workspaceMembersResource.js +2 -2
- package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
- package/src/shared/resources/workspaceResource.js +4 -1
- package/src/shared/roles.js +15 -12
- package/src/shared/tenancyProfile.js +2 -2
- package/test/exportsContract.test.js +5 -0
- package/test/featureRuntime.test.js +5 -6
- package/test/packageMetadata.test.js +12 -0
- package/test/repositories.integration.test.js +1 -1
- package/test/requestedWorkspaceScope.test.js +24 -0
- package/test/roleLabels.test.js +29 -7
- package/test/support/invitationTransactions.js +35 -19
- package/test/workspaceActionSurfaces.test.js +0 -55
- package/test/workspaceAuthPolicyContextResolver.test.js +1 -1
- package/test/workspaceBootstrapContributor.test.js +7 -6
- package/test/workspaceCrud.test.js +23 -0
- package/test/workspaceInvitesRepository.test.js +9 -8
- package/test/workspaceMembersService.test.js +131 -28
- package/test/workspacePendingInvitationsResource.test.js +2 -2
- package/test/workspacePendingInvitationsService.test.js +69 -22
- package/test/workspaceService.test.js +58 -47
- package/test/workspaceSettingsActions.test.js +14 -7
- package/test/workspaceSettingsRepository.test.js +11 -0
- package/test/workspaceSettingsService.test.js +11 -9
- package/test/workspacesRepository.test.js +16 -16
- package/test/workspacesRouteRequestInputValidator.test.js +4 -4
- package/src/server/common/repositories/repositoryUtils.js +0 -43
- package/src/server/common/support/deepFreeze.js +0 -1
- 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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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 {
|
|
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:
|
|
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 };
|
package/src/shared/roles.js
CHANGED
|
@@ -65,11 +65,11 @@ function listConfiguredRoleIds(appConfig = {}) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function resolveConfiguredDefaultInviteRole(appConfig = {}) {
|
|
68
|
-
return normalizeRoleId(appConfig
|
|
68
|
+
return normalizeRoleId(appConfig.roleCatalog?.workspace?.defaultInviteRole);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function normalizeConfiguredRoles(appConfig = {}) {
|
|
72
|
-
const roleCatalog = asRecord(appConfig
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
151
|
+
return role ? [...role.permissions] : [];
|
|
152
|
+
}
|
|
153
153
|
|
|
154
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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: [
|
|
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
|
+
});
|
package/test/roleLabels.test.js
CHANGED
|
@@ -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.
|
|
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
|
|
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 <team>/u);
|
|
134
|
-
assert.match(renderDefaultWorkspaceInviteEmail(
|
|
156
|
+
assert.match(renderDefaultWorkspaceInviteEmail(input).text, /as worker/u);
|
|
135
157
|
});
|