@jskit-ai/users-core 0.1.32 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.descriptor.mjs +19 -19
- package/package.json +6 -6
- package/src/server/accountNotifications/accountNotificationsService.js +3 -3
- package/src/server/accountNotifications/registerAccountNotifications.js +1 -1
- package/src/server/accountPreferences/accountPreferencesService.js +3 -3
- package/src/server/accountPreferences/registerAccountPreferences.js +1 -1
- package/src/server/accountProfile/accountProfileActions.js +8 -2
- package/src/server/accountProfile/accountProfileService.js +10 -10
- package/src/server/accountProfile/avatarService.js +9 -9
- package/src/server/accountProfile/bootAccountProfileRoutes.js +5 -3
- package/src/server/accountProfile/registerAccountProfile.js +2 -2
- package/src/server/accountSecurity/accountSecurityService.js +3 -3
- package/src/server/accountSecurity/registerAccountSecurity.js +1 -1
- package/src/server/common/contributors/workspaceActionContextContributor.js +24 -17
- package/src/server/common/registerCommonRepositories.js +3 -3
- package/src/server/common/repositories/{userProfilesRepository.js → usersRepository.js} +1 -1
- package/src/server/common/services/accountContextService.js +4 -4
- package/src/server/common/services/authProfileSyncService.js +10 -10
- package/src/server/registerWorkspaceBootstrap.js +1 -1
- package/src/server/registerWorkspaceCore.js +5 -2
- package/src/server/workspaceBootstrapContributor.js +4 -4
- package/src/shared/roles.js +31 -6
- package/templates/config/roles.js +27 -0
- package/test/authProfileSyncService.test.js +3 -3
- package/test/avatarService.test.js +2 -2
- package/test/roles.test.js +90 -5
- package/test/workspaceActionContextContributor.test.js +98 -5
- package/test/workspaceBootstrapContributor.test.js +7 -7
- package/test/workspaceMembersService.test.js +4 -2
- package/test/workspaceService.test.js +12 -8
- package/test/workspaceSettingsResource.test.js +4 -2
- package/templates/config/workspaceRoles.js +0 -30
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/users-core",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.33",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Users/workspace domain runtime plus HTTP routes for workspace, account, and console features.",
|
|
7
7
|
dependsOn: [
|
|
@@ -95,7 +95,7 @@ export default Object.freeze({
|
|
|
95
95
|
{
|
|
96
96
|
method: "GET",
|
|
97
97
|
path: "/api/w/:workspaceSlug/roles",
|
|
98
|
-
summary: "Get
|
|
98
|
+
summary: "Get role catalog by workspace slug."
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
method: "GET",
|
|
@@ -198,11 +198,11 @@ export default Object.freeze({
|
|
|
198
198
|
mutations: {
|
|
199
199
|
dependencies: {
|
|
200
200
|
runtime: {
|
|
201
|
-
"@jskit-ai/auth-core": "0.1.
|
|
202
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
203
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
204
|
-
"@jskit-ai/kernel": "0.1.
|
|
205
|
-
"@jskit-ai/uploads-runtime": "0.1.
|
|
201
|
+
"@jskit-ai/auth-core": "0.1.23",
|
|
202
|
+
"@jskit-ai/database-runtime": "0.1.24",
|
|
203
|
+
"@jskit-ai/http-runtime": "0.1.23",
|
|
204
|
+
"@jskit-ai/kernel": "0.1.24",
|
|
205
|
+
"@jskit-ai/uploads-runtime": "0.1.2",
|
|
206
206
|
"@fastify/type-provider-typebox": "^6.1.0",
|
|
207
207
|
"typebox": "^1.0.81"
|
|
208
208
|
},
|
|
@@ -290,12 +290,12 @@ export default Object.freeze({
|
|
|
290
290
|
id: "users-core-app-owned-user-settings-fields"
|
|
291
291
|
},
|
|
292
292
|
{
|
|
293
|
-
from: "templates/config/
|
|
294
|
-
to: "config/
|
|
293
|
+
from: "templates/config/roles.js",
|
|
294
|
+
to: "config/roles.js",
|
|
295
295
|
preserveOnRemove: true,
|
|
296
|
-
reason: "Install app-owned
|
|
296
|
+
reason: "Install app-owned role catalog in a dedicated config file.",
|
|
297
297
|
category: "users-core",
|
|
298
|
-
id: "users-core-app-owned-
|
|
298
|
+
id: "users-core-app-owned-role-catalog-config"
|
|
299
299
|
}
|
|
300
300
|
],
|
|
301
301
|
text: [
|
|
@@ -362,11 +362,11 @@ export default Object.freeze({
|
|
|
362
362
|
op: "append-text",
|
|
363
363
|
file: "config/public.js",
|
|
364
364
|
position: "top",
|
|
365
|
-
skipIfContains: "import {
|
|
366
|
-
value: "import {
|
|
367
|
-
reason: "Load app-owned
|
|
365
|
+
skipIfContains: "import { roleCatalog } from \"./roles.js\";",
|
|
366
|
+
value: "import { roleCatalog } from \"./roles.js\";\n",
|
|
367
|
+
reason: "Load app-owned role catalog from dedicated config file.",
|
|
368
368
|
category: "users-core",
|
|
369
|
-
id: "users-core-
|
|
369
|
+
id: "users-core-role-catalog-public-import"
|
|
370
370
|
},
|
|
371
371
|
{
|
|
372
372
|
op: "append-text",
|
|
@@ -428,11 +428,11 @@ export default Object.freeze({
|
|
|
428
428
|
op: "append-text",
|
|
429
429
|
file: "config/public.js",
|
|
430
430
|
position: "bottom",
|
|
431
|
-
skipIfContains: "config.
|
|
432
|
-
value: "\nconfig.
|
|
433
|
-
reason: "Bind app-owned
|
|
431
|
+
skipIfContains: "config.roleCatalog = roleCatalog;",
|
|
432
|
+
value: "\nconfig.roleCatalog = roleCatalog;\n",
|
|
433
|
+
reason: "Bind app-owned role catalog onto public config.",
|
|
434
434
|
category: "users-core",
|
|
435
|
-
id: "users-core-
|
|
435
|
+
id: "users-core-role-catalog-public-config"
|
|
436
436
|
},
|
|
437
437
|
{
|
|
438
438
|
op: "append-text",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/users-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"./shared/resources/consoleSettingsFields": "./src/shared/resources/consoleSettingsFields.js"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@jskit-ai/auth-core": "0.1.
|
|
28
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
29
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
30
|
-
"@jskit-ai/kernel": "0.1.
|
|
31
|
-
"@jskit-ai/uploads-runtime": "0.1.
|
|
27
|
+
"@jskit-ai/auth-core": "0.1.23",
|
|
28
|
+
"@jskit-ai/database-runtime": "0.1.24",
|
|
29
|
+
"@jskit-ai/http-runtime": "0.1.23",
|
|
30
|
+
"@jskit-ai/kernel": "0.1.24",
|
|
31
|
+
"@jskit-ai/uploads-runtime": "0.1.2",
|
|
32
32
|
"@fastify/type-provider-typebox": "^6.1.0",
|
|
33
33
|
"typebox": "^1.0.81"
|
|
34
34
|
}
|
|
@@ -9,15 +9,15 @@ import {
|
|
|
9
9
|
|
|
10
10
|
function createService({
|
|
11
11
|
userSettingsRepository,
|
|
12
|
-
|
|
12
|
+
usersRepository,
|
|
13
13
|
authService
|
|
14
14
|
} = {}) {
|
|
15
|
-
if (!userSettingsRepository || !
|
|
15
|
+
if (!userSettingsRepository || !usersRepository) {
|
|
16
16
|
throw new Error("accountNotificationsService requires repositories.");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
async function updateNotifications(request, user, payload = {}, options = {}) {
|
|
20
|
-
const profile = await resolveUserProfile(
|
|
20
|
+
const profile = await resolveUserProfile(usersRepository, user);
|
|
21
21
|
if (!profile) {
|
|
22
22
|
throw new AppError(404, "User profile was not found.");
|
|
23
23
|
}
|
|
@@ -14,7 +14,7 @@ function registerAccountNotifications(app) {
|
|
|
14
14
|
(scope) =>
|
|
15
15
|
createAccountNotificationsService({
|
|
16
16
|
userSettingsRepository: scope.make("userSettingsRepository"),
|
|
17
|
-
|
|
17
|
+
usersRepository: scope.make("usersRepository"),
|
|
18
18
|
authService: scope.make("authService")
|
|
19
19
|
}),
|
|
20
20
|
{
|
|
@@ -9,15 +9,15 @@ import {
|
|
|
9
9
|
|
|
10
10
|
function createService({
|
|
11
11
|
userSettingsRepository,
|
|
12
|
-
|
|
12
|
+
usersRepository,
|
|
13
13
|
authService
|
|
14
14
|
} = {}) {
|
|
15
|
-
if (!userSettingsRepository || !
|
|
15
|
+
if (!userSettingsRepository || !usersRepository) {
|
|
16
16
|
throw new Error("accountPreferencesService requires repositories.");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
async function updatePreferences(request, user, payload = {}, options = {}) {
|
|
20
|
-
const profile = await resolveUserProfile(
|
|
20
|
+
const profile = await resolveUserProfile(usersRepository, user);
|
|
21
21
|
if (!profile) {
|
|
22
22
|
throw new AppError(404, "User profile was not found.");
|
|
23
23
|
}
|
|
@@ -14,7 +14,7 @@ function registerAccountPreferences(app) {
|
|
|
14
14
|
(scope) =>
|
|
15
15
|
createAccountPreferencesService({
|
|
16
16
|
userSettingsRepository: scope.make("userSettingsRepository"),
|
|
17
|
-
|
|
17
|
+
usersRepository: scope.make("usersRepository"),
|
|
18
18
|
authService: scope.make("authService")
|
|
19
19
|
}),
|
|
20
20
|
{
|
|
@@ -95,10 +95,17 @@ const accountProfileActions = Object.freeze([
|
|
|
95
95
|
},
|
|
96
96
|
observability: {},
|
|
97
97
|
async execute(input, context, deps) {
|
|
98
|
+
const avatarUpload = {
|
|
99
|
+
stream: input.stream,
|
|
100
|
+
mimeType: input.mimeType,
|
|
101
|
+
fileName: input.fileName,
|
|
102
|
+
uploadDimension: input.uploadDimension
|
|
103
|
+
};
|
|
104
|
+
|
|
98
105
|
return deps.accountProfileService.uploadAvatar(
|
|
99
106
|
resolveRequest(context),
|
|
100
107
|
resolveActionUser(context, input),
|
|
101
|
-
|
|
108
|
+
avatarUpload,
|
|
102
109
|
{
|
|
103
110
|
context
|
|
104
111
|
}
|
|
@@ -125,7 +132,6 @@ const accountProfileActions = Object.freeze([
|
|
|
125
132
|
return deps.accountProfileService.deleteAvatar(
|
|
126
133
|
resolveRequest(context),
|
|
127
134
|
resolveActionUser(context, input),
|
|
128
|
-
input,
|
|
129
135
|
{
|
|
130
136
|
context
|
|
131
137
|
}
|
|
@@ -9,16 +9,16 @@ import {
|
|
|
9
9
|
|
|
10
10
|
function createService({
|
|
11
11
|
userSettingsRepository,
|
|
12
|
-
|
|
12
|
+
usersRepository,
|
|
13
13
|
authService,
|
|
14
14
|
avatarService
|
|
15
15
|
} = {}) {
|
|
16
|
-
if (!userSettingsRepository || !
|
|
16
|
+
if (!userSettingsRepository || !usersRepository || !avatarService) {
|
|
17
17
|
throw new Error("accountProfileService requires repositories and avatarService.");
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async function getForUser(request, user, options = {}) {
|
|
21
|
-
const profile = await resolveUserProfile(
|
|
21
|
+
const profile = await resolveUserProfile(usersRepository, user);
|
|
22
22
|
if (!profile) {
|
|
23
23
|
throw new AppError(404, "User profile was not found.");
|
|
24
24
|
}
|
|
@@ -35,7 +35,7 @@ function createService({
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async function updateProfile(request, user, payload = {}, options = {}) {
|
|
38
|
-
const profile = await resolveUserProfile(
|
|
38
|
+
const profile = await resolveUserProfile(usersRepository, user);
|
|
39
39
|
if (!profile) {
|
|
40
40
|
throw new AppError(404, "User profile was not found.");
|
|
41
41
|
}
|
|
@@ -49,7 +49,7 @@ function createService({
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (!updatedProfile) {
|
|
52
|
-
updatedProfile = await
|
|
52
|
+
updatedProfile = await usersRepository.updateDisplayNameById(profile.id, payload.displayName);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const settings = await userSettingsRepository.ensureForUserId(updatedProfile.id);
|
|
@@ -66,11 +66,11 @@ function createService({
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
async function uploadAvatar(request, user,
|
|
69
|
+
async function uploadAvatar(request, user, avatarUpload = {}, options = {}) {
|
|
70
70
|
void options;
|
|
71
71
|
|
|
72
|
-
const
|
|
73
|
-
const profile =
|
|
72
|
+
const result = await avatarService.uploadForUser(user, avatarUpload);
|
|
73
|
+
const profile = result?.profile || null;
|
|
74
74
|
if (!profile) {
|
|
75
75
|
throw new AppError(500, "Avatar upload completed without a profile result.");
|
|
76
76
|
}
|
|
@@ -86,7 +86,7 @@ function createService({
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
async function deleteAvatar(request, user,
|
|
89
|
+
async function deleteAvatar(request, user, options = {}) {
|
|
90
90
|
void options;
|
|
91
91
|
|
|
92
92
|
const profile = await avatarService.clearForUser(user);
|
|
@@ -101,7 +101,7 @@ function createService({
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
async function readAvatar(_request, user,
|
|
104
|
+
async function readAvatar(_request, user, options = {}) {
|
|
105
105
|
void options;
|
|
106
106
|
|
|
107
107
|
const avatar = await avatarService.readForUser(user);
|
|
@@ -21,9 +21,9 @@ async function readAvatarBuffer(stream, { maxBytes = DEFAULT_AVATAR_POLICY.maxUp
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function createService({
|
|
25
|
-
if (!
|
|
26
|
-
throw new TypeError("avatarService requires
|
|
24
|
+
function createService({ usersRepository, avatarStorageService, avatarPolicy } = {}) {
|
|
25
|
+
if (!usersRepository) {
|
|
26
|
+
throw new TypeError("avatarService requires usersRepository.");
|
|
27
27
|
}
|
|
28
28
|
if (!avatarStorageService) {
|
|
29
29
|
throw new TypeError("avatarService requires avatarStorageService.");
|
|
@@ -32,21 +32,21 @@ function createService({ userProfilesRepository, avatarStorageService, avatarPol
|
|
|
32
32
|
const resolvedAvatarPolicy = resolveAvatarPolicy(avatarPolicy);
|
|
33
33
|
|
|
34
34
|
async function resolveProfile(user) {
|
|
35
|
-
const profile = await resolveUserProfile(
|
|
35
|
+
const profile = await resolveUserProfile(usersRepository, user);
|
|
36
36
|
if (!profile) {
|
|
37
37
|
throw new AppError(404, "User profile was not found.");
|
|
38
38
|
}
|
|
39
39
|
return profile;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
async function uploadForUser(user,
|
|
42
|
+
async function uploadForUser(user, avatarUpload = {}) {
|
|
43
43
|
const profile = await resolveProfile(user);
|
|
44
|
-
validateUploadMimeType(
|
|
44
|
+
validateUploadMimeType(avatarUpload?.mimeType, resolvedAvatarPolicy, {
|
|
45
45
|
fieldName: "avatar",
|
|
46
46
|
label: "Avatar"
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
const buffer = await readAvatarBuffer(
|
|
49
|
+
const buffer = await readAvatarBuffer(avatarUpload?.stream, {
|
|
50
50
|
maxBytes: resolvedAvatarPolicy.maxUploadBytes
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -57,7 +57,7 @@ function createService({ userProfilesRepository, avatarStorageService, avatarPol
|
|
|
57
57
|
buffer
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
const updatedProfile = await
|
|
60
|
+
const updatedProfile = await usersRepository.updateAvatarById(profile.id, {
|
|
61
61
|
avatarStorageKey: savedAvatar.storageKey,
|
|
62
62
|
avatarVersion,
|
|
63
63
|
avatarUpdatedAt: new Date(avatarVersionMs)
|
|
@@ -73,7 +73,7 @@ function createService({ userProfilesRepository, avatarStorageService, avatarPol
|
|
|
73
73
|
if (profile.avatarStorageKey) {
|
|
74
74
|
await avatarStorageService.deleteAvatar(profile.avatarStorageKey);
|
|
75
75
|
}
|
|
76
|
-
return
|
|
76
|
+
return usersRepository.clearAvatarById(profile.id);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
async function readForUser(user) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
|
|
2
|
+
import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from "@jskit-ai/uploads-runtime/shared";
|
|
2
3
|
import { readSingleMultipartFile } from "@jskit-ai/uploads-runtime/server/multipart/readSingleMultipartFile";
|
|
3
4
|
import { userSettingsResource } from "../../shared/resources/userSettingsResource.js";
|
|
4
5
|
import { userProfileResource } from "../../shared/resources/userProfileResource.js";
|
|
@@ -77,7 +78,7 @@ function bootAccountProfileRoutes(app) {
|
|
|
77
78
|
}
|
|
78
79
|
},
|
|
79
80
|
async function (request, reply) {
|
|
80
|
-
const avatar = await accountProfileService.readAvatar(request, request.user, {
|
|
81
|
+
const avatar = await accountProfileService.readAvatar(request, request.user, {
|
|
81
82
|
context: {
|
|
82
83
|
actor: request.user
|
|
83
84
|
}
|
|
@@ -114,10 +115,11 @@ function bootAccountProfileRoutes(app) {
|
|
|
114
115
|
},
|
|
115
116
|
async function (request, reply) {
|
|
116
117
|
const filePart = await readSingleMultipartFile(request, {
|
|
117
|
-
|
|
118
|
+
fieldName: "avatar",
|
|
118
119
|
required: true,
|
|
119
120
|
fieldErrorKey: "avatar",
|
|
120
|
-
label: "Avatar"
|
|
121
|
+
label: "Avatar",
|
|
122
|
+
maxBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES
|
|
121
123
|
});
|
|
122
124
|
|
|
123
125
|
const uploadDimension = filePart.fields?.uploadDimension?.value;
|
|
@@ -20,7 +20,7 @@ function registerAccountProfile(app) {
|
|
|
20
20
|
|
|
21
21
|
app.singleton("users.avatar.service", (scope) =>
|
|
22
22
|
createAvatarService({
|
|
23
|
-
|
|
23
|
+
usersRepository: scope.make("usersRepository"),
|
|
24
24
|
avatarStorageService: scope.make("users.avatar.storage.service")
|
|
25
25
|
})
|
|
26
26
|
);
|
|
@@ -30,7 +30,7 @@ function registerAccountProfile(app) {
|
|
|
30
30
|
(scope) =>
|
|
31
31
|
createAccountProfileService({
|
|
32
32
|
userSettingsRepository: scope.make("userSettingsRepository"),
|
|
33
|
-
|
|
33
|
+
usersRepository: scope.make("usersRepository"),
|
|
34
34
|
authService: scope.make("authService"),
|
|
35
35
|
avatarService: scope.make("users.avatar.service")
|
|
36
36
|
}),
|
|
@@ -10,10 +10,10 @@ import {
|
|
|
10
10
|
|
|
11
11
|
function createService({
|
|
12
12
|
userSettingsRepository,
|
|
13
|
-
|
|
13
|
+
usersRepository,
|
|
14
14
|
authService
|
|
15
15
|
} = {}) {
|
|
16
|
-
if (!userSettingsRepository || !
|
|
16
|
+
if (!userSettingsRepository || !usersRepository) {
|
|
17
17
|
throw new Error("accountSecurityService requires repositories.");
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -40,7 +40,7 @@ function createService({
|
|
|
40
40
|
throw new AppError(501, "Password method toggle is not available.");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const profile = await resolveUserProfile(
|
|
43
|
+
const profile = await resolveUserProfile(usersRepository, user);
|
|
44
44
|
if (!profile) {
|
|
45
45
|
throw new AppError(404, "User profile was not found.");
|
|
46
46
|
}
|
|
@@ -11,7 +11,7 @@ function registerAccountSecurity(app) {
|
|
|
11
11
|
const authService = scope.has("authService") ? scope.make("authService") : null;
|
|
12
12
|
return createAccountSecurityService({
|
|
13
13
|
userSettingsRepository: scope.make("userSettingsRepository"),
|
|
14
|
-
|
|
14
|
+
usersRepository: scope.make("usersRepository"),
|
|
15
15
|
authService
|
|
16
16
|
});
|
|
17
17
|
});
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
normalizeObject,
|
|
3
3
|
requireServiceMethod
|
|
4
4
|
} from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
|
|
5
|
+
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
5
6
|
import {
|
|
6
7
|
checkRouteVisibility,
|
|
7
8
|
USERS_ROUTE_VISIBILITY_PUBLIC,
|
|
@@ -9,45 +10,51 @@ import {
|
|
|
9
10
|
USERS_ROUTE_VISIBILITY_WORKSPACE_USER
|
|
10
11
|
} from "../../../shared/support/usersVisibility.js";
|
|
11
12
|
import { resolveActionUser } from "../support/resolveActionUser.js";
|
|
12
|
-
|
|
13
|
-
const WORKSPACE_CONTEXT_ACTION_IDS = Object.freeze([
|
|
14
|
-
"workspace.roles.list",
|
|
15
|
-
"workspace.settings.read",
|
|
16
|
-
"workspace.settings.update",
|
|
17
|
-
"workspace.members.list",
|
|
18
|
-
"workspace.member.role.update",
|
|
19
|
-
"workspace.member.remove",
|
|
20
|
-
"workspace.invites.list",
|
|
21
|
-
"workspace.invite.create",
|
|
22
|
-
"workspace.invite.revoke"
|
|
23
|
-
]);
|
|
24
13
|
const WORKSPACE_VISIBILITY_ACTION_CONTEXT_SET = new Set([
|
|
25
14
|
USERS_ROUTE_VISIBILITY_WORKSPACE,
|
|
26
15
|
USERS_ROUTE_VISIBILITY_WORKSPACE_USER
|
|
27
16
|
]);
|
|
28
17
|
|
|
29
|
-
function
|
|
18
|
+
function normalizeWorkspaceSurfaceIds(surfaceIds = []) {
|
|
19
|
+
const source = Array.isArray(surfaceIds) ? surfaceIds : [];
|
|
20
|
+
const normalized = new Set();
|
|
21
|
+
|
|
22
|
+
for (const entry of source) {
|
|
23
|
+
const surfaceId = normalizeSurfaceId(entry);
|
|
24
|
+
if (!surfaceId) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
normalized.add(surfaceId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return normalized;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createWorkspaceActionContextContributor({ workspaceService, workspaceSurfaceIds = [] } = {}) {
|
|
30
34
|
const contributorId = "users.workspace.context";
|
|
35
|
+
const workspaceSurfaceIdSet = normalizeWorkspaceSurfaceIds(workspaceSurfaceIds);
|
|
31
36
|
|
|
32
37
|
requireServiceMethod(workspaceService, "resolveWorkspaceContextForUserBySlug", contributorId);
|
|
33
38
|
|
|
34
39
|
return Object.freeze({
|
|
35
40
|
contributorId,
|
|
36
|
-
async contribute({
|
|
41
|
+
async contribute({ definition = null, input, context, request } = {}) {
|
|
37
42
|
const payload = normalizeObject(input);
|
|
38
43
|
if (!Object.hasOwn(payload, "workspaceSlug")) {
|
|
39
44
|
return {};
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
const
|
|
43
|
-
const
|
|
47
|
+
const actionSurfaces = Array.isArray(definition?.surfaces) ? definition.surfaces : [];
|
|
48
|
+
const hasWorkspaceActionSurface = actionSurfaces.some((surfaceId) => workspaceSurfaceIdSet.has(surfaceId));
|
|
49
|
+
const routeSurfaceId = normalizeSurfaceId(request?.routeOptions?.config?.surface);
|
|
50
|
+
const hasWorkspaceSurface = workspaceSurfaceIdSet.has(routeSurfaceId);
|
|
44
51
|
const routeVisibilityInput =
|
|
45
52
|
request && request.routeOptions && request.routeOptions.config
|
|
46
53
|
? request.routeOptions.config.visibility
|
|
47
54
|
: USERS_ROUTE_VISIBILITY_PUBLIC;
|
|
48
55
|
const routeVisibility = checkRouteVisibility(routeVisibilityInput);
|
|
49
56
|
const hasWorkspaceRouteVisibility = WORKSPACE_VISIBILITY_ACTION_CONTEXT_SET.has(routeVisibility);
|
|
50
|
-
if (!
|
|
57
|
+
if (!hasWorkspaceActionSurface && !hasWorkspaceRouteVisibility && !hasWorkspaceSurface) {
|
|
51
58
|
return {};
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRepository as
|
|
1
|
+
import { createRepository as createUsersRepository } from "./repositories/usersRepository.js";
|
|
2
2
|
import { createRepository as createUserSettingsRepository } from "./repositories/userSettingsRepository.js";
|
|
3
3
|
import { createRepository as createWorkspacesRepository } from "./repositories/workspacesRepository.js";
|
|
4
4
|
import { createRepository as createWorkspaceMembershipsRepository } from "./repositories/workspaceMembershipsRepository.js";
|
|
@@ -10,9 +10,9 @@ function registerCommonRepositories(app) {
|
|
|
10
10
|
throw new Error("registerCommonRepositories requires application singleton().");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
app.singleton("
|
|
13
|
+
app.singleton("usersRepository", (scope) => {
|
|
14
14
|
const knex = scope.make("jskit.database.knex");
|
|
15
|
-
return
|
|
15
|
+
return createUsersRepository(knex);
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
app.singleton("userSettingsRepository", (scope) => {
|
|
@@ -104,7 +104,7 @@ async function resolveUniqueUsername(client, baseUsername, { excludeUserId = 0 }
|
|
|
104
104
|
|
|
105
105
|
function createRepository(knex) {
|
|
106
106
|
if (typeof knex !== "function") {
|
|
107
|
-
throw new TypeError("
|
|
107
|
+
throw new TypeError("usersRepository requires knex.");
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
async function findById(userId, options = {}) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { normalizeIdentity } from "../repositories/
|
|
1
|
+
import { normalizeIdentity } from "../repositories/usersRepository.js";
|
|
2
2
|
|
|
3
|
-
async function resolveUserProfile(
|
|
3
|
+
async function resolveUserProfile(usersRepository, user) {
|
|
4
4
|
const identity = normalizeIdentity(user);
|
|
5
5
|
if (identity) {
|
|
6
|
-
const profile = await
|
|
6
|
+
const profile = await usersRepository.findByIdentity(identity);
|
|
7
7
|
if (profile) {
|
|
8
8
|
return profile;
|
|
9
9
|
}
|
|
@@ -11,7 +11,7 @@ async function resolveUserProfile(userProfilesRepository, user) {
|
|
|
11
11
|
|
|
12
12
|
const userId = Number(user?.id);
|
|
13
13
|
if (Number.isInteger(userId) && userId > 0) {
|
|
14
|
-
const profileById = await
|
|
14
|
+
const profileById = await usersRepository.findById(userId);
|
|
15
15
|
if (profileById) {
|
|
16
16
|
return profileById;
|
|
17
17
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
2
|
-
import { normalizeIdentity } from "../repositories/
|
|
2
|
+
import { normalizeIdentity } from "../repositories/usersRepository.js";
|
|
3
3
|
|
|
4
4
|
function buildNormalizedIdentityKey(identityLike) {
|
|
5
5
|
const identity = normalizeIdentity(identityLike);
|
|
@@ -53,12 +53,12 @@ function requireSynchronizedProfile(profile) {
|
|
|
53
53
|
throw new Error("Profile synchronization failed.");
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
function createService({
|
|
57
|
-
if (!
|
|
58
|
-
throw new Error("authProfileSyncService requires
|
|
56
|
+
function createService({ usersRepository, workspaceProvisioningService = null, userSettingsRepository = null } = {}) {
|
|
57
|
+
if (!usersRepository || typeof usersRepository.findByIdentity !== "function") {
|
|
58
|
+
throw new Error("authProfileSyncService requires usersRepository.findByIdentity().");
|
|
59
59
|
}
|
|
60
|
-
if (typeof
|
|
61
|
-
throw new Error("authProfileSyncService requires
|
|
60
|
+
if (typeof usersRepository.upsert !== "function") {
|
|
61
|
+
throw new Error("authProfileSyncService requires usersRepository.upsert().");
|
|
62
62
|
}
|
|
63
63
|
if (!userSettingsRepository || typeof userSettingsRepository.ensureForUserId !== "function") {
|
|
64
64
|
throw new Error("authProfileSyncService requires userSettingsRepository.ensureForUserId().");
|
|
@@ -66,7 +66,7 @@ function createService({ userProfilesRepository, workspaceProvisioningService =
|
|
|
66
66
|
|
|
67
67
|
async function findByIdentity(identityLike, options = {}) {
|
|
68
68
|
const normalized = buildNormalizedIdentityKey(identityLike);
|
|
69
|
-
return
|
|
69
|
+
return usersRepository.findByIdentity(
|
|
70
70
|
{
|
|
71
71
|
provider: normalized.authProvider,
|
|
72
72
|
providerUserId: normalized.authProviderUserSid
|
|
@@ -77,7 +77,7 @@ function createService({ userProfilesRepository, workspaceProvisioningService =
|
|
|
77
77
|
|
|
78
78
|
async function upsertByIdentity(profileLike, options = {}) {
|
|
79
79
|
const normalized = buildNormalizedIdentityProfile(profileLike);
|
|
80
|
-
return
|
|
80
|
+
return usersRepository.upsert(
|
|
81
81
|
{
|
|
82
82
|
authProvider: normalized.authProvider,
|
|
83
83
|
authProviderUserSid: normalized.authProviderUserSid,
|
|
@@ -118,8 +118,8 @@ function createService({ userProfilesRepository, workspaceProvisioningService =
|
|
|
118
118
|
if (options?.trx) {
|
|
119
119
|
return runSync(options.trx);
|
|
120
120
|
}
|
|
121
|
-
if (typeof
|
|
122
|
-
return
|
|
121
|
+
if (typeof usersRepository.withTransaction === "function") {
|
|
122
|
+
return usersRepository.withTransaction((trx) => runSync(trx));
|
|
123
123
|
}
|
|
124
124
|
return runSync();
|
|
125
125
|
}
|
|
@@ -17,7 +17,7 @@ function registerWorkspaceBootstrap(app) {
|
|
|
17
17
|
? scope.make("users.workspace.pending-invitations.service")
|
|
18
18
|
: null,
|
|
19
19
|
workspaceInvitationsEnabled,
|
|
20
|
-
|
|
20
|
+
usersRepository: scope.make("usersRepository"),
|
|
21
21
|
userSettingsRepository: scope.make("userSettingsRepository"),
|
|
22
22
|
appConfig: resolveAppConfig(scope),
|
|
23
23
|
tenancyProfile: scope.make("users.tenancy.profile"),
|
|
@@ -10,6 +10,7 @@ import { createWorkspaceActionContextContributor } from "./common/contributors/w
|
|
|
10
10
|
import { createWorkspaceRouteVisibilityResolver } from "./common/contributors/workspaceRouteVisibilityResolver.js";
|
|
11
11
|
import { createWorkspaceAuthPolicyContextResolver } from "./common/contributors/workspaceAuthPolicyContextResolver.js";
|
|
12
12
|
import { resolveWorkspaceInvitationsPolicy } from "./support/workspaceInvitationsPolicy.js";
|
|
13
|
+
import { resolveWorkspaceSurfaceIdsFromAppConfig } from "./support/workspaceActionSurfaces.js";
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
function registerWorkspaceCore(app) {
|
|
@@ -29,7 +30,7 @@ function registerWorkspaceCore(app) {
|
|
|
29
30
|
|
|
30
31
|
app.singleton("users.profile.sync.service", (scope) => {
|
|
31
32
|
return createAuthProfileSyncService({
|
|
32
|
-
|
|
33
|
+
usersRepository: scope.make("usersRepository"),
|
|
33
34
|
userSettingsRepository: scope.make("userSettingsRepository"),
|
|
34
35
|
workspaceProvisioningService: scope.make("users.workspace.service")
|
|
35
36
|
});
|
|
@@ -62,8 +63,10 @@ function registerWorkspaceCore(app) {
|
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
registerActionContextContributor(app, "users.core.workspace.actionContextContributor", (scope) => {
|
|
66
|
+
const appConfig = resolveAppConfig(scope);
|
|
65
67
|
return createWorkspaceActionContextContributor({
|
|
66
|
-
workspaceService: scope.make("users.workspace.service")
|
|
68
|
+
workspaceService: scope.make("users.workspace.service"),
|
|
69
|
+
workspaceSurfaceIds: resolveWorkspaceSurfaceIdsFromAppConfig(appConfig)
|
|
67
70
|
});
|
|
68
71
|
});
|
|
69
72
|
|
|
@@ -230,7 +230,7 @@ function mapUserSettingsBootstrap(settings = {}) {
|
|
|
230
230
|
function createWorkspaceBootstrapContributor({
|
|
231
231
|
workspaceService,
|
|
232
232
|
workspacePendingInvitationsService,
|
|
233
|
-
|
|
233
|
+
usersRepository,
|
|
234
234
|
userSettingsRepository,
|
|
235
235
|
workspaceInvitationsEnabled = false,
|
|
236
236
|
appConfig = {},
|
|
@@ -255,8 +255,8 @@ function createWorkspaceBootstrapContributor({
|
|
|
255
255
|
serviceLabel: "workspacePendingInvitationsService"
|
|
256
256
|
});
|
|
257
257
|
}
|
|
258
|
-
requireServiceMethod(
|
|
259
|
-
serviceLabel: "
|
|
258
|
+
requireServiceMethod(usersRepository, "findByIdentity", contributorId, {
|
|
259
|
+
serviceLabel: "usersRepository"
|
|
260
260
|
});
|
|
261
261
|
requireServiceMethod(userSettingsRepository, "ensureForUserId", contributorId, {
|
|
262
262
|
serviceLabel: "userSettingsRepository"
|
|
@@ -317,7 +317,7 @@ function createWorkspaceBootstrapContributor({
|
|
|
317
317
|
|
|
318
318
|
if (normalizedUser) {
|
|
319
319
|
const latestProfile =
|
|
320
|
-
(await
|
|
320
|
+
(await usersRepository.findByIdentity({
|
|
321
321
|
provider: normalizedUser.authProvider,
|
|
322
322
|
providerUserId: normalizedUser.authProviderUserSid
|
|
323
323
|
})) || normalizedUser;
|
package/src/shared/roles.js
CHANGED
|
@@ -20,10 +20,35 @@ function normalizeRoleId(value) {
|
|
|
20
20
|
.toLowerCase();
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function
|
|
23
|
+
function resolveInheritedRolePermissions(roleSid, configuredRoles = {}, seenRoleIds = new Set()) {
|
|
24
|
+
if (seenRoleIds.has(roleSid)) {
|
|
25
|
+
throw new TypeError(`roleCatalog role "${roleSid}" has circular inheritance.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const source = asRecord(configuredRoles[roleSid]);
|
|
29
|
+
const inheritedRoleId = normalizeRoleId(source.inherits);
|
|
30
|
+
const directPermissions = normalizePermissionList(source.permissions);
|
|
31
|
+
if (!inheritedRoleId) {
|
|
32
|
+
return directPermissions;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!Object.hasOwn(configuredRoles, inheritedRoleId)) {
|
|
36
|
+
throw new TypeError(`roleCatalog role "${roleSid}" inherits unknown role "${inheritedRoleId}".`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const nextSeenRoleIds = new Set(seenRoleIds);
|
|
40
|
+
nextSeenRoleIds.add(roleSid);
|
|
41
|
+
|
|
42
|
+
return normalizePermissionList([
|
|
43
|
+
...resolveInheritedRolePermissions(inheritedRoleId, configuredRoles, nextSeenRoleIds),
|
|
44
|
+
...directPermissions
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createRoleDescriptor(roleSid, configuredDefinition, configuredRoles = {}) {
|
|
24
49
|
const source = asRecord(configuredDefinition);
|
|
25
50
|
const assignable = roleSid === OWNER_ROLE_ID ? false : source.assignable === true;
|
|
26
|
-
const permissions =
|
|
51
|
+
const permissions = resolveInheritedRolePermissions(roleSid, configuredRoles);
|
|
27
52
|
|
|
28
53
|
return Object.freeze({
|
|
29
54
|
id: roleSid,
|
|
@@ -38,12 +63,12 @@ function listConfiguredRoleIds(appConfig = {}) {
|
|
|
38
63
|
}
|
|
39
64
|
|
|
40
65
|
function resolveConfiguredDefaultInviteRole(appConfig = {}) {
|
|
41
|
-
return normalizeRoleId(appConfig?.
|
|
66
|
+
return normalizeRoleId(appConfig?.roleCatalog?.workspace?.defaultInviteRole);
|
|
42
67
|
}
|
|
43
68
|
|
|
44
69
|
function normalizeConfiguredRoles(appConfig = {}) {
|
|
45
|
-
const
|
|
46
|
-
const configuredRoles = asRecord(
|
|
70
|
+
const roleCatalog = asRecord(appConfig?.roleCatalog);
|
|
71
|
+
const configuredRoles = asRecord(roleCatalog.roles);
|
|
47
72
|
const normalizedRoles = {};
|
|
48
73
|
|
|
49
74
|
for (const [roleSid, roleDefinition] of Object.entries(configuredRoles)) {
|
|
@@ -60,7 +85,7 @@ function normalizeConfiguredRoles(appConfig = {}) {
|
|
|
60
85
|
function createWorkspaceRoleCatalog(appConfig = {}) {
|
|
61
86
|
const configuredRoles = normalizeConfiguredRoles(appConfig);
|
|
62
87
|
const roleIds = listConfiguredRoleIds(appConfig);
|
|
63
|
-
const roles = roleIds.map((roleSid) => createRoleDescriptor(roleSid, configuredRoles[roleSid]));
|
|
88
|
+
const roles = roleIds.map((roleSid) => createRoleDescriptor(roleSid, configuredRoles[roleSid], configuredRoles));
|
|
64
89
|
const assignableRoleIds = roles.filter((role) => role.assignable).map((role) => role.id);
|
|
65
90
|
const configuredDefaultInviteRole = resolveConfiguredDefaultInviteRole(appConfig);
|
|
66
91
|
const defaultInviteRole = assignableRoleIds.includes(configuredDefaultInviteRole)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const roleCatalog = {
|
|
2
|
+
workspace: {
|
|
3
|
+
defaultInviteRole: "member"
|
|
4
|
+
},
|
|
5
|
+
roles: {
|
|
6
|
+
owner: {
|
|
7
|
+
assignable: false,
|
|
8
|
+
permissions: ["*"]
|
|
9
|
+
},
|
|
10
|
+
admin: {
|
|
11
|
+
assignable: true,
|
|
12
|
+
inherits: "member",
|
|
13
|
+
permissions: [
|
|
14
|
+
"workspace.roles.view",
|
|
15
|
+
"workspace.settings.update",
|
|
16
|
+
"workspace.members.view",
|
|
17
|
+
"workspace.members.invite",
|
|
18
|
+
"workspace.members.manage",
|
|
19
|
+
"workspace.invites.revoke"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
member: {
|
|
23
|
+
assignable: true,
|
|
24
|
+
permissions: ["workspace.settings.view"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -7,7 +7,7 @@ test("authProfileSyncService.syncIdentityProfile uses shared transaction for pro
|
|
|
7
7
|
const transaction = { trxId: "tx-1" };
|
|
8
8
|
|
|
9
9
|
const service = createService({
|
|
10
|
-
|
|
10
|
+
usersRepository: {
|
|
11
11
|
async findByIdentity(_identity, options = {}) {
|
|
12
12
|
calls.push({ step: "find", trx: options.trx || null });
|
|
13
13
|
return null;
|
|
@@ -64,7 +64,7 @@ test("authProfileSyncService.syncIdentityProfile skips write path when profile i
|
|
|
64
64
|
let provisionCalls = 0;
|
|
65
65
|
|
|
66
66
|
const service = createService({
|
|
67
|
-
|
|
67
|
+
usersRepository: {
|
|
68
68
|
async findByIdentity() {
|
|
69
69
|
return {
|
|
70
70
|
id: 7,
|
|
@@ -109,7 +109,7 @@ test("authProfileSyncService.syncIdentityProfile skips write path when profile i
|
|
|
109
109
|
test("authProfileSyncService.findByIdentity normalizes provider identity input", async () => {
|
|
110
110
|
let capturedIdentity = null;
|
|
111
111
|
const service = createService({
|
|
112
|
-
|
|
112
|
+
usersRepository: {
|
|
113
113
|
async findByIdentity(identity) {
|
|
114
114
|
capturedIdentity = identity;
|
|
115
115
|
return null;
|
|
@@ -59,7 +59,7 @@ test("avatarService uploadForUser stores bytes and updates profile avatar fields
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
const avatarService = createService({
|
|
62
|
-
|
|
62
|
+
usersRepository: repository,
|
|
63
63
|
avatarStorageService
|
|
64
64
|
});
|
|
65
65
|
|
|
@@ -99,7 +99,7 @@ test("avatarService clearForUser removes stored avatar and clears profile fields
|
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
const avatarService = createService({
|
|
102
|
-
|
|
102
|
+
usersRepository: repository,
|
|
103
103
|
avatarStorageService
|
|
104
104
|
});
|
|
105
105
|
|
package/test/roles.test.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
hasPermission
|
|
8
8
|
} from "../src/shared/roles.js";
|
|
9
9
|
|
|
10
|
-
test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.
|
|
10
|
+
test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.roleCatalog", () => {
|
|
11
11
|
const emptyCatalog = createWorkspaceRoleCatalog();
|
|
12
12
|
assert.deepEqual(emptyCatalog.roles, []);
|
|
13
13
|
assert.deepEqual(emptyCatalog.assignableRoleIds, []);
|
|
@@ -15,8 +15,10 @@ test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.w
|
|
|
15
15
|
assert.equal(emptyCatalog.collaborationEnabled, false);
|
|
16
16
|
|
|
17
17
|
const appConfig = {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
roleCatalog: {
|
|
19
|
+
workspace: {
|
|
20
|
+
defaultInviteRole: "editor"
|
|
21
|
+
},
|
|
20
22
|
roles: {
|
|
21
23
|
owner: {
|
|
22
24
|
assignable: false,
|
|
@@ -24,7 +26,7 @@ test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.w
|
|
|
24
26
|
},
|
|
25
27
|
editor: {
|
|
26
28
|
assignable: true,
|
|
27
|
-
permissions: ["
|
|
29
|
+
permissions: ["crud.contacts.*"]
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
}
|
|
@@ -35,7 +37,90 @@ test("createWorkspaceRoleCatalog resolves role descriptors only from appConfig.w
|
|
|
35
37
|
assert.equal(roleCatalog.defaultInviteRole, "editor");
|
|
36
38
|
assert.equal(roleCatalog.assignableRoleIds.includes("editor"), true);
|
|
37
39
|
assert.deepEqual(resolveRolePermissions("owner", appConfig), ["workspace.settings.update"]);
|
|
38
|
-
assert.equal(hasPermission(editorRole?.permissions, "
|
|
40
|
+
assert.equal(hasPermission(editorRole?.permissions, "crud.contacts.update"), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("createWorkspaceRoleCatalog resolves inherited role permissions with parent permissions first", () => {
|
|
44
|
+
const appConfig = {
|
|
45
|
+
roleCatalog: {
|
|
46
|
+
workspace: {
|
|
47
|
+
defaultInviteRole: "member"
|
|
48
|
+
},
|
|
49
|
+
roles: {
|
|
50
|
+
member: {
|
|
51
|
+
assignable: true,
|
|
52
|
+
permissions: [
|
|
53
|
+
"workspace.settings.view",
|
|
54
|
+
"crud.contacts.list"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
admin: {
|
|
58
|
+
assignable: true,
|
|
59
|
+
inherits: "member",
|
|
60
|
+
permissions: [
|
|
61
|
+
"workspace.settings.update",
|
|
62
|
+
"workspace.members.manage",
|
|
63
|
+
"workspace.settings.view"
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const roleCatalog = createWorkspaceRoleCatalog(appConfig);
|
|
71
|
+
const adminRole = roleCatalog.roles.find((role) => role.id === "admin");
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(adminRole, {
|
|
74
|
+
id: "admin",
|
|
75
|
+
assignable: true,
|
|
76
|
+
permissions: [
|
|
77
|
+
"workspace.settings.view",
|
|
78
|
+
"crud.contacts.list",
|
|
79
|
+
"workspace.settings.update",
|
|
80
|
+
"workspace.members.manage"
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("createWorkspaceRoleCatalog rejects unknown inherited roles", () => {
|
|
86
|
+
assert.throws(
|
|
87
|
+
() =>
|
|
88
|
+
createWorkspaceRoleCatalog({
|
|
89
|
+
roleCatalog: {
|
|
90
|
+
roles: {
|
|
91
|
+
admin: {
|
|
92
|
+
assignable: true,
|
|
93
|
+
inherits: "member",
|
|
94
|
+
permissions: []
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
/inherits unknown role "member"/
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("createWorkspaceRoleCatalog rejects circular inherited roles", () => {
|
|
104
|
+
assert.throws(
|
|
105
|
+
() =>
|
|
106
|
+
createWorkspaceRoleCatalog({
|
|
107
|
+
roleCatalog: {
|
|
108
|
+
roles: {
|
|
109
|
+
member: {
|
|
110
|
+
assignable: true,
|
|
111
|
+
inherits: "admin",
|
|
112
|
+
permissions: []
|
|
113
|
+
},
|
|
114
|
+
admin: {
|
|
115
|
+
assignable: true,
|
|
116
|
+
inherits: "member",
|
|
117
|
+
permissions: []
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}),
|
|
122
|
+
/circular inheritance/
|
|
123
|
+
);
|
|
39
124
|
});
|
|
40
125
|
|
|
41
126
|
test("cloneWorkspaceRoleCatalog normalizes role ids and returns detached arrays", () => {
|
|
@@ -24,7 +24,8 @@ test("workspace action context contributor resolves workspace context for worksp
|
|
|
24
24
|
permissions: ["workspace.settings.update"]
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
-
}
|
|
27
|
+
},
|
|
28
|
+
workspaceSurfaceIds: ["admin", "app"]
|
|
28
29
|
});
|
|
29
30
|
|
|
30
31
|
const request = {
|
|
@@ -35,7 +36,10 @@ test("workspace action context contributor resolves workspace context for worksp
|
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
const contribution = await contributor.contribute({
|
|
38
|
-
|
|
39
|
+
definition: {
|
|
40
|
+
id: "workspace.settings.update",
|
|
41
|
+
surfaces: ["admin", "app"]
|
|
42
|
+
},
|
|
39
43
|
input: {
|
|
40
44
|
workspaceSlug: "Acme"
|
|
41
45
|
},
|
|
@@ -118,7 +122,8 @@ test("workspace action context contributor always resolves and stores resolved c
|
|
|
118
122
|
permissions: ["workspace.settings.update"]
|
|
119
123
|
};
|
|
120
124
|
}
|
|
121
|
-
}
|
|
125
|
+
},
|
|
126
|
+
workspaceSurfaceIds: ["admin", "app"]
|
|
122
127
|
});
|
|
123
128
|
|
|
124
129
|
const request = {
|
|
@@ -128,7 +133,10 @@ test("workspace action context contributor always resolves and stores resolved c
|
|
|
128
133
|
};
|
|
129
134
|
|
|
130
135
|
const contribution = await contributor.contribute({
|
|
131
|
-
|
|
136
|
+
definition: {
|
|
137
|
+
id: "workspace.members.list",
|
|
138
|
+
surfaces: ["admin", "app"]
|
|
139
|
+
},
|
|
132
140
|
input: {
|
|
133
141
|
workspaceSlug: "acme"
|
|
134
142
|
},
|
|
@@ -205,7 +213,10 @@ test("workspace action context contributor resolves context for workspace-visibl
|
|
|
205
213
|
};
|
|
206
214
|
|
|
207
215
|
const contribution = await contributor.contribute({
|
|
208
|
-
|
|
216
|
+
definition: {
|
|
217
|
+
id: "assistant.conversations.list",
|
|
218
|
+
surfaces: ["admin"]
|
|
219
|
+
},
|
|
209
220
|
input: {
|
|
210
221
|
workspaceSlug: "acme"
|
|
211
222
|
},
|
|
@@ -249,3 +260,85 @@ test("workspace action context contributor resolves context for workspace-visibl
|
|
|
249
260
|
permissions: ["assistant.chat.use"]
|
|
250
261
|
});
|
|
251
262
|
});
|
|
263
|
+
|
|
264
|
+
test("workspace action context contributor resolves context for workspace surfaces even when route visibility is public", async () => {
|
|
265
|
+
const calls = [];
|
|
266
|
+
const contributor = createWorkspaceActionContextContributor({
|
|
267
|
+
workspaceService: {
|
|
268
|
+
async resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options) {
|
|
269
|
+
calls.push({ user, workspaceSlug, options });
|
|
270
|
+
return {
|
|
271
|
+
workspace: {
|
|
272
|
+
id: 77,
|
|
273
|
+
slug: "acme"
|
|
274
|
+
},
|
|
275
|
+
membership: {
|
|
276
|
+
roleSid: "member"
|
|
277
|
+
},
|
|
278
|
+
permissions: ["crud.breeds.list"]
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
workspaceSurfaceIds: ["admin", "app"]
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const request = {
|
|
286
|
+
user: {
|
|
287
|
+
id: 42
|
|
288
|
+
},
|
|
289
|
+
routeOptions: {
|
|
290
|
+
config: {
|
|
291
|
+
surface: "admin",
|
|
292
|
+
visibility: "public"
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const contribution = await contributor.contribute({
|
|
298
|
+
definition: {
|
|
299
|
+
id: "crud.breeds.list",
|
|
300
|
+
surfaces: ["admin"]
|
|
301
|
+
},
|
|
302
|
+
input: {
|
|
303
|
+
workspaceSlug: "acme"
|
|
304
|
+
},
|
|
305
|
+
context: {
|
|
306
|
+
requestMeta: {
|
|
307
|
+
request
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
request
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
assert.deepEqual(calls, [
|
|
314
|
+
{
|
|
315
|
+
user: request.user,
|
|
316
|
+
workspaceSlug: "acme",
|
|
317
|
+
options: {
|
|
318
|
+
request
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
]);
|
|
322
|
+
assert.deepEqual(contribution, {
|
|
323
|
+
requestMeta: {
|
|
324
|
+
resolvedWorkspaceContext: {
|
|
325
|
+
workspace: {
|
|
326
|
+
id: 77,
|
|
327
|
+
slug: "acme"
|
|
328
|
+
},
|
|
329
|
+
membership: {
|
|
330
|
+
roleSid: "member"
|
|
331
|
+
},
|
|
332
|
+
permissions: ["crud.breeds.list"]
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
workspace: {
|
|
336
|
+
id: 77,
|
|
337
|
+
slug: "acme"
|
|
338
|
+
},
|
|
339
|
+
membership: {
|
|
340
|
+
roleSid: "member"
|
|
341
|
+
},
|
|
342
|
+
permissions: ["crud.breeds.list"]
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -39,7 +39,7 @@ test("workspace bootstrap contributor passes actor context to pending invites se
|
|
|
39
39
|
return [];
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
|
-
|
|
42
|
+
usersRepository: {
|
|
43
43
|
async findByIdentity() {
|
|
44
44
|
return profile;
|
|
45
45
|
}
|
|
@@ -101,7 +101,7 @@ test("workspace bootstrap contributor seeds the initial console owner on authent
|
|
|
101
101
|
return [];
|
|
102
102
|
}
|
|
103
103
|
},
|
|
104
|
-
|
|
104
|
+
usersRepository: {
|
|
105
105
|
async findByIdentity() {
|
|
106
106
|
return profile;
|
|
107
107
|
}
|
|
@@ -162,7 +162,7 @@ test("workspace bootstrap contributor emits canonical tenancy profile from users
|
|
|
162
162
|
return [];
|
|
163
163
|
}
|
|
164
164
|
},
|
|
165
|
-
|
|
165
|
+
usersRepository: {
|
|
166
166
|
async findByIdentity() {
|
|
167
167
|
return null;
|
|
168
168
|
}
|
|
@@ -228,7 +228,7 @@ test("workspace bootstrap contributor resolves workspace slug from bootstrap que
|
|
|
228
228
|
return [];
|
|
229
229
|
}
|
|
230
230
|
},
|
|
231
|
-
|
|
231
|
+
usersRepository: {
|
|
232
232
|
async findByIdentity() {
|
|
233
233
|
return profile;
|
|
234
234
|
}
|
|
@@ -291,7 +291,7 @@ test("workspace bootstrap contributor returns global payload with requestedWorks
|
|
|
291
291
|
return [];
|
|
292
292
|
}
|
|
293
293
|
},
|
|
294
|
-
|
|
294
|
+
usersRepository: {
|
|
295
295
|
async findByIdentity() {
|
|
296
296
|
return profile;
|
|
297
297
|
}
|
|
@@ -363,7 +363,7 @@ test("workspace bootstrap contributor returns requestedWorkspace=not_found when
|
|
|
363
363
|
return [];
|
|
364
364
|
}
|
|
365
365
|
},
|
|
366
|
-
|
|
366
|
+
usersRepository: {
|
|
367
367
|
async findByIdentity() {
|
|
368
368
|
return profile;
|
|
369
369
|
}
|
|
@@ -427,7 +427,7 @@ test("workspace bootstrap contributor returns requestedWorkspace=unauthenticated
|
|
|
427
427
|
return [];
|
|
428
428
|
}
|
|
429
429
|
},
|
|
430
|
-
|
|
430
|
+
usersRepository: {
|
|
431
431
|
async findByIdentity() {
|
|
432
432
|
return null;
|
|
433
433
|
}
|
|
@@ -16,8 +16,10 @@ function authorizedOptions(permissions = []) {
|
|
|
16
16
|
|
|
17
17
|
function createRoleCatalog() {
|
|
18
18
|
return createWorkspaceRoleCatalog({
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
roleCatalog: {
|
|
20
|
+
workspace: {
|
|
21
|
+
defaultInviteRole: "member"
|
|
22
|
+
},
|
|
21
23
|
roles: {
|
|
22
24
|
owner: {
|
|
23
25
|
assignable: false,
|
|
@@ -2,9 +2,11 @@ import test from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { createService } from "../src/server/common/services/workspaceContextService.js";
|
|
4
4
|
|
|
5
|
-
function
|
|
5
|
+
function createRoleCatalog() {
|
|
6
6
|
return {
|
|
7
|
-
|
|
7
|
+
workspace: {
|
|
8
|
+
defaultInviteRole: "member"
|
|
9
|
+
},
|
|
8
10
|
roles: {
|
|
9
11
|
owner: {
|
|
10
12
|
assignable: false,
|
|
@@ -21,7 +23,7 @@ function createWorkspaceRoles() {
|
|
|
21
23
|
function createWorkspaceServiceFixture({
|
|
22
24
|
tenancyMode = "workspaces",
|
|
23
25
|
tenancyPolicy = {},
|
|
24
|
-
|
|
26
|
+
roleCatalog = createRoleCatalog(),
|
|
25
27
|
additionalWorkspaces = [],
|
|
26
28
|
userWorkspaceRows = null,
|
|
27
29
|
membershipResolver = null,
|
|
@@ -69,7 +71,7 @@ function createWorkspaceServiceFixture({
|
|
|
69
71
|
appConfig: {
|
|
70
72
|
tenancyMode,
|
|
71
73
|
tenancyPolicy,
|
|
72
|
-
|
|
74
|
+
roleCatalog: roleCatalog && typeof roleCatalog === "object" ? { ...roleCatalog } : roleCatalog
|
|
73
75
|
},
|
|
74
76
|
workspacesRepository: {
|
|
75
77
|
async findBySlug(slug) {
|
|
@@ -404,7 +406,7 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug grants owner access
|
|
|
404
406
|
const service = createService({
|
|
405
407
|
appConfig: {
|
|
406
408
|
tenancyMode: "personal",
|
|
407
|
-
|
|
409
|
+
roleCatalog: createRoleCatalog()
|
|
408
410
|
},
|
|
409
411
|
workspacesRepository: {
|
|
410
412
|
async findBySlug(slug) {
|
|
@@ -468,10 +470,12 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug grants owner access
|
|
|
468
470
|
assert.deepEqual(context.permissions, ["*"]);
|
|
469
471
|
});
|
|
470
472
|
|
|
471
|
-
test("workspaceService.resolveWorkspaceContextForUserBySlug resolves permissions from appConfig.
|
|
473
|
+
test("workspaceService.resolveWorkspaceContextForUserBySlug resolves permissions from appConfig.roleCatalog", async () => {
|
|
472
474
|
const { service } = createWorkspaceServiceFixture({
|
|
473
|
-
|
|
474
|
-
|
|
475
|
+
roleCatalog: {
|
|
476
|
+
workspace: {
|
|
477
|
+
defaultInviteRole: "member"
|
|
478
|
+
},
|
|
475
479
|
roles: {
|
|
476
480
|
owner: {
|
|
477
481
|
assignable: false,
|
|
@@ -8,8 +8,10 @@ import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
|
|
|
8
8
|
|
|
9
9
|
function createRoleCatalog() {
|
|
10
10
|
return createWorkspaceRoleCatalog({
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
roleCatalog: {
|
|
12
|
+
workspace: {
|
|
13
|
+
defaultInviteRole: "member"
|
|
14
|
+
},
|
|
13
15
|
roles: {
|
|
14
16
|
owner: {
|
|
15
17
|
assignable: false,
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
export const workspaceRoles = {};
|
|
2
|
-
|
|
3
|
-
workspaceRoles.defaultInviteRole = "member";
|
|
4
|
-
workspaceRoles.roles = {};
|
|
5
|
-
|
|
6
|
-
workspaceRoles.roles.owner = {
|
|
7
|
-
assignable: false,
|
|
8
|
-
permissions: []
|
|
9
|
-
};
|
|
10
|
-
workspaceRoles.roles.owner.permissions.push("*");
|
|
11
|
-
|
|
12
|
-
workspaceRoles.roles.admin = {
|
|
13
|
-
assignable: true,
|
|
14
|
-
permissions: []
|
|
15
|
-
};
|
|
16
|
-
workspaceRoles.roles.admin.permissions.push(
|
|
17
|
-
"workspace.roles.view",
|
|
18
|
-
"workspace.settings.view",
|
|
19
|
-
"workspace.settings.update",
|
|
20
|
-
"workspace.members.view",
|
|
21
|
-
"workspace.members.invite",
|
|
22
|
-
"workspace.members.manage",
|
|
23
|
-
"workspace.invites.revoke"
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
workspaceRoles.roles.member = {
|
|
27
|
-
assignable: true,
|
|
28
|
-
permissions: []
|
|
29
|
-
};
|
|
30
|
-
workspaceRoles.roles.member.permissions.push("workspace.settings.view");
|