@jskit-ai/workspaces-core 0.1.137 → 0.1.138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +38 -238
- package/patterns/workspace-server/PATTERN.md +60 -0
- package/src/server/WorkspacesFeature.js +226 -0
- package/src/server/WorkspacesIntegrationsProvider.js +74 -0
- package/src/server/common/contributors/workspaceActionContextContributor.js +18 -6
- package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +25 -4
- package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +22 -4
- package/src/server/common/services/workspaceContextService.js +7 -2
- package/src/server/common/support/realtimeServiceEvents.js +102 -61
- package/src/server/support/workspaceActionSurfaces.js +18 -13
- package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +5 -11
- package/src/server/workspaceDirectory/workspaceDirectoryActions.js +24 -14
- package/src/server/workspaceMembers/bootWorkspaceMembers.js +5 -12
- package/src/server/workspaceMembers/workspaceMembersActions.js +63 -23
- package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +4 -6
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +31 -12
- package/src/server/workspaceSettings/bootWorkspaceSettings.js +5 -8
- package/src/server/workspaceSettings/workspaceSettingsActions.js +26 -8
- package/src/server/workspaceSettings/workspaceSettingsRepository.js +4 -4
- package/test/exportsContract.test.js +2 -1
- package/test/featureRuntime.test.js +116 -0
- package/test/packageMetadata.test.js +20 -54
- package/test/workspaceActionContextContributor.test.js +49 -8
- package/test/workspaceActionSurfaces.test.js +36 -1
- package/test/workspaceAuthPolicyContextResolver.test.js +61 -0
- package/test/workspaceRouteVisibilityResolver.test.js +31 -0
- package/test/workspaceService.test.js +62 -0
- package/test/workspaceSettingsActions.test.js +102 -16
- package/test/workspacesRouteRequestInputValidator.test.js +14 -65
- package/src/server/WorkspacesCoreServiceProvider.js +0 -64
- package/src/server/registerWorkspaceBootstrap.js +0 -27
- package/src/server/registerWorkspaceCore.js +0 -101
- package/src/server/registerWorkspaceRepositories.js +0 -30
- package/src/server/support/workspaceServerScopeSupport.js +0 -18
- package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +0 -19
- package/src/server/workspaceMembers/registerWorkspaceMembers.js +0 -137
- package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +0 -130
- package/src/server/workspaceSettings/registerWorkspaceSettings.js +0 -66
- package/test/registerServiceRealtimeEvents.test.js +0 -116
- package/test/registerWorkspaceBootstrap.test.js +0 -86
- package/test/registerWorkspaceDirectory.test.js +0 -31
- package/test/registerWorkspaceMembers.test.js +0 -140
- package/test/registerWorkspaceSettings.test.js +0 -40
- package/test/workspaceServerScopeSupport.test.js +0 -131
- /package/{templates/migrations → migrations}/workspaces_core_initial.cjs +0 -0
- /package/{templates/migrations → migrations}/workspaces_core_workspace_settings_single_name_source.cjs +0 -0
- /package/{templates/migrations → migrations}/workspaces_core_workspaces_drop_color.cjs +0 -0
- /package/{templates → patterns/workspace-server/example}/config/roles.js +0 -0
- /package/{templates → patterns/workspace-server/example}/packages/main/src/server/email/workspaceInviteEmail.js +0 -0
|
@@ -17,20 +17,6 @@ function findRoute(method, routePath) {
|
|
|
17
17
|
: null;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function findFileMutation(id) {
|
|
21
|
-
const fileMutations = packageMetadata?.mutations?.files;
|
|
22
|
-
return Array.isArray(fileMutations)
|
|
23
|
-
? fileMutations.find((entry) => String(entry?.id || "").trim() === id) || null
|
|
24
|
-
: null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function findSourceMutation(id) {
|
|
28
|
-
const sourceMutations = packageMetadata?.mutations?.source;
|
|
29
|
-
return Array.isArray(sourceMutations)
|
|
30
|
-
? sourceMutations.find((entry) => String(entry?.id || "").trim() === id) || null
|
|
31
|
-
: null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
20
|
test("workspaces-core packageMetadata advertises public invite resolution route metadata", () => {
|
|
35
21
|
assert.deepEqual(findRoute("GET", "/api/workspace/invitations/resolve"), {
|
|
36
22
|
method: "GET",
|
|
@@ -39,55 +25,35 @@ test("workspaces-core packageMetadata advertises public invite resolution route
|
|
|
39
25
|
});
|
|
40
26
|
});
|
|
41
27
|
|
|
42
|
-
test("workspaces-core
|
|
43
|
-
const source = await readFile(
|
|
28
|
+
test("workspaces-core publishes an app-owned editable role catalog pattern", async () => {
|
|
29
|
+
const source = await readFile(
|
|
30
|
+
path.join(PACKAGE_DIR, "patterns", "workspace-server", "example", "config", "roles.js"),
|
|
31
|
+
"utf8"
|
|
32
|
+
);
|
|
44
33
|
|
|
45
34
|
assert.match(source, /export const roleCatalog/);
|
|
46
|
-
assert.
|
|
47
|
-
from: "templates/config/roles.js",
|
|
48
|
-
to: "config/roles.js",
|
|
49
|
-
ownership: "app",
|
|
50
|
-
preserveOnRemove: true,
|
|
51
|
-
reason: "Install app-owned role catalog in a dedicated config file.",
|
|
52
|
-
category: "workspaces-core",
|
|
53
|
-
id: "users-core-app-owned-role-catalog-config"
|
|
54
|
-
});
|
|
35
|
+
assert.equal(Object.hasOwn(packageMetadata, "mutations"), false);
|
|
55
36
|
});
|
|
56
37
|
|
|
57
|
-
test("workspaces-core
|
|
38
|
+
test("workspaces-core publishes an app-owned editable workspace invite email pattern", async () => {
|
|
58
39
|
const source = await readFile(
|
|
59
|
-
path.join(
|
|
40
|
+
path.join(
|
|
41
|
+
PACKAGE_DIR,
|
|
42
|
+
"patterns",
|
|
43
|
+
"workspace-server",
|
|
44
|
+
"example",
|
|
45
|
+
"packages",
|
|
46
|
+
"main",
|
|
47
|
+
"src",
|
|
48
|
+
"server",
|
|
49
|
+
"email",
|
|
50
|
+
"workspaceInviteEmail.js"
|
|
51
|
+
),
|
|
60
52
|
"utf8"
|
|
61
53
|
);
|
|
62
54
|
|
|
63
55
|
assert.match(source, /function renderWorkspaceInviteEmail/);
|
|
64
56
|
assert.match(source, /export \{ renderWorkspaceInviteEmail \}/);
|
|
65
57
|
assert.match(source, /inviteUrl/);
|
|
66
|
-
assert.deepEqual(
|
|
67
|
-
from: "templates/packages/main/src/server/email/workspaceInviteEmail.js",
|
|
68
|
-
to: "packages/main/src/server/email/workspaceInviteEmail.js",
|
|
69
|
-
ownership: "app",
|
|
70
|
-
preserveOnRemove: true,
|
|
71
|
-
reason: "Install app-owned editable workspace invite email template.",
|
|
72
|
-
category: "workspaces-core",
|
|
73
|
-
id: "workspaces-core-main-workspace-invite-email-template"
|
|
74
|
-
});
|
|
75
|
-
assert.deepEqual(findSourceMutation("workspaces-core-server-config-workspace-invite-email-import"), {
|
|
76
|
-
op: "ensure-import",
|
|
77
|
-
file: "config/server.js",
|
|
78
|
-
namedImports: ["renderWorkspaceInviteEmail"],
|
|
79
|
-
from: "../packages/main/src/server/email/workspaceInviteEmail.js",
|
|
80
|
-
reason: "Load app-owned workspace invite email renderer from packages/main.",
|
|
81
|
-
category: "workspaces-core",
|
|
82
|
-
id: "workspaces-core-server-config-workspace-invite-email-import"
|
|
83
|
-
});
|
|
84
|
-
assert.deepEqual(findSourceMutation("workspaces-core-server-config-workspace-invite-email-template"), {
|
|
85
|
-
op: "ensure-assignment",
|
|
86
|
-
file: "config/server.js",
|
|
87
|
-
target: "config.workspaceInviteEmailTemplate",
|
|
88
|
-
value: "renderWorkspaceInviteEmail",
|
|
89
|
-
reason: "Bind app-owned workspace invite email renderer into server config.",
|
|
90
|
-
category: "workspaces-core",
|
|
91
|
-
id: "workspaces-core-server-config-workspace-invite-email-template"
|
|
92
|
-
});
|
|
58
|
+
assert.deepEqual(packageMetadata.migrations, { directories: ["migrations"] });
|
|
93
59
|
});
|
|
@@ -47,8 +47,7 @@ test("workspace action context contributor resolves workspace context for worksp
|
|
|
47
47
|
requestMeta: {
|
|
48
48
|
request
|
|
49
49
|
}
|
|
50
|
-
}
|
|
51
|
-
request
|
|
50
|
+
}
|
|
52
51
|
});
|
|
53
52
|
|
|
54
53
|
assert.deepEqual(calls, [
|
|
@@ -147,8 +146,7 @@ test("workspace action context contributor always resolves and stores resolved c
|
|
|
147
146
|
requestMeta: {
|
|
148
147
|
request
|
|
149
148
|
}
|
|
150
|
-
}
|
|
151
|
-
request
|
|
149
|
+
}
|
|
152
150
|
});
|
|
153
151
|
|
|
154
152
|
assert.deepEqual(calls, [
|
|
@@ -224,8 +222,7 @@ test("workspace action context contributor resolves context for workspace-visibl
|
|
|
224
222
|
requestMeta: {
|
|
225
223
|
request
|
|
226
224
|
}
|
|
227
|
-
}
|
|
228
|
-
request
|
|
225
|
+
}
|
|
229
226
|
});
|
|
230
227
|
|
|
231
228
|
assert.deepEqual(calls, [
|
|
@@ -306,8 +303,7 @@ test("workspace action context contributor resolves context for workspace surfac
|
|
|
306
303
|
requestMeta: {
|
|
307
304
|
request
|
|
308
305
|
}
|
|
309
|
-
}
|
|
310
|
-
request
|
|
306
|
+
}
|
|
311
307
|
});
|
|
312
308
|
|
|
313
309
|
assert.deepEqual(calls, [
|
|
@@ -342,3 +338,48 @@ test("workspace action context contributor resolves context for workspace surfac
|
|
|
342
338
|
permissions: ["crud.breeds.list"]
|
|
343
339
|
});
|
|
344
340
|
});
|
|
341
|
+
|
|
342
|
+
test("workspace action context contributor permits missing membership only for an explicitly optional active surface", async () => {
|
|
343
|
+
const calls = [];
|
|
344
|
+
const contributor = createWorkspaceActionContextContributor({
|
|
345
|
+
workspaceService: {
|
|
346
|
+
async resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options) {
|
|
347
|
+
calls.push({ user, workspaceSlug, options });
|
|
348
|
+
return {
|
|
349
|
+
workspace: { id: 77, slug: "acme" },
|
|
350
|
+
membership: null,
|
|
351
|
+
permissions: []
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
workspaceMembershipOptionalSurfaceIds: ["app"],
|
|
356
|
+
workspaceSurfaceIds: ["admin", "app"]
|
|
357
|
+
});
|
|
358
|
+
const request = {
|
|
359
|
+
user: { id: 42 },
|
|
360
|
+
routeOptions: {
|
|
361
|
+
config: {
|
|
362
|
+
surface: "app",
|
|
363
|
+
visibility: "public"
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const contribution = await contributor.contribute({
|
|
369
|
+
definition: {
|
|
370
|
+
id: "customer.pets.list",
|
|
371
|
+
surfaces: ["app"]
|
|
372
|
+
},
|
|
373
|
+
input: { workspaceSlug: "acme" },
|
|
374
|
+
context: {
|
|
375
|
+
requestMeta: { request },
|
|
376
|
+
surface: "app"
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
assert.equal(calls.length, 1);
|
|
381
|
+
assert.equal(calls[0].options.requireMembership, false);
|
|
382
|
+
assert.deepEqual(contribution.workspace, { id: 77, slug: "acme" });
|
|
383
|
+
assert.equal(contribution.membership, null);
|
|
384
|
+
assert.deepEqual(contribution.permissions, []);
|
|
385
|
+
});
|
|
@@ -2,9 +2,44 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
materializeWorkspaceActionSurfacesFromAppConfig,
|
|
5
|
-
resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig
|
|
5
|
+
resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig,
|
|
6
|
+
resolveWorkspaceMembershipOptionalSurfaceIdsFromAppConfig
|
|
6
7
|
} from "../src/server/support/workspaceActionSurfaces.js";
|
|
7
8
|
|
|
9
|
+
test("resolveWorkspaceMembershipOptionalSurfaceIdsFromAppConfig honors explicit surface policy overrides", () => {
|
|
10
|
+
const surfaceIds = resolveWorkspaceMembershipOptionalSurfaceIdsFromAppConfig({
|
|
11
|
+
surfaceAccessPolicies: {
|
|
12
|
+
workspace_authenticated: {
|
|
13
|
+
requireWorkspaceMembership: false
|
|
14
|
+
},
|
|
15
|
+
workspace_member: {
|
|
16
|
+
requireWorkspaceMembership: true
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
surfaceDefinitions: {
|
|
20
|
+
app: {
|
|
21
|
+
id: "app",
|
|
22
|
+
enabled: true,
|
|
23
|
+
requiresWorkspace: true,
|
|
24
|
+
accessPolicyId: "workspace_authenticated"
|
|
25
|
+
},
|
|
26
|
+
admin: {
|
|
27
|
+
id: "admin",
|
|
28
|
+
enabled: true,
|
|
29
|
+
requiresWorkspace: true,
|
|
30
|
+
accessPolicyId: "workspace_member"
|
|
31
|
+
},
|
|
32
|
+
implicit: {
|
|
33
|
+
id: "implicit",
|
|
34
|
+
enabled: true,
|
|
35
|
+
requiresWorkspace: true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.deepEqual(surfaceIds, ["app"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
8
43
|
test("materializeWorkspaceActionSurfacesFromAppConfig resolves workspace surfaces from appConfig", () => {
|
|
9
44
|
const actionDefinitions = [
|
|
10
45
|
{
|
|
@@ -117,3 +117,64 @@ test("workspace auth policy context resolver skips workspace lookup when workspa
|
|
|
117
117
|
assert.equal(called, false);
|
|
118
118
|
assert.deepEqual(resolved, {});
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
test("workspace auth policy context resolver treats optional context as membership-optional", async () => {
|
|
122
|
+
const calls = [];
|
|
123
|
+
const request = {
|
|
124
|
+
params: { workspaceSlug: "acme" }
|
|
125
|
+
};
|
|
126
|
+
const actor = { id: 7 };
|
|
127
|
+
const resolver = createWorkspaceAuthPolicyContextResolver({
|
|
128
|
+
workspaceService: {
|
|
129
|
+
async resolveWorkspaceContextForUserBySlug(resolvedActor, workspaceSlug, options) {
|
|
130
|
+
calls.push({ resolvedActor, workspaceSlug, options });
|
|
131
|
+
return {
|
|
132
|
+
workspace: { id: 11, slug: workspaceSlug },
|
|
133
|
+
membership: null,
|
|
134
|
+
permissions: []
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const resolved = await resolver({
|
|
141
|
+
request,
|
|
142
|
+
actor,
|
|
143
|
+
meta: { contextPolicy: "optional" }
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
assert.equal(calls.length, 1);
|
|
147
|
+
assert.equal(calls[0].options.requireMembership, false);
|
|
148
|
+
assert.equal(resolved.membership, null);
|
|
149
|
+
assert.deepEqual(resolved.permissions, []);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("workspace auth policy honors membership-optional workspace surfaces", async () => {
|
|
153
|
+
const calls = [];
|
|
154
|
+
const request = {
|
|
155
|
+
params: { workspaceSlug: "dog-and-groom" },
|
|
156
|
+
routeOptions: { config: { surface: "customer" } }
|
|
157
|
+
};
|
|
158
|
+
const resolver = createWorkspaceAuthPolicyContextResolver({
|
|
159
|
+
workspaceMembershipOptionalSurfaceIds: ["customer"],
|
|
160
|
+
workspaceService: {
|
|
161
|
+
async resolveWorkspaceContextForUserBySlug(actor, workspaceSlug, options) {
|
|
162
|
+
calls.push({ actor, workspaceSlug, options });
|
|
163
|
+
return {
|
|
164
|
+
workspace: { id: 11, slug: workspaceSlug },
|
|
165
|
+
membership: null,
|
|
166
|
+
permissions: []
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const resolved = await resolver({
|
|
173
|
+
request,
|
|
174
|
+
actor: { id: 7 },
|
|
175
|
+
meta: { contextPolicy: "required" }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.equal(calls[0].options.requireMembership, false);
|
|
179
|
+
assert.equal(resolved.membership, null);
|
|
180
|
+
});
|
|
@@ -81,3 +81,34 @@ test("workspace route visibility resolver still marks workspace_user as actor-sc
|
|
|
81
81
|
userId: "user_99"
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
test("workspace route visibility honors membership-optional workspace surfaces", async () => {
|
|
86
|
+
const calls = [];
|
|
87
|
+
const request = {
|
|
88
|
+
routeOptions: { config: { surface: "customer" } }
|
|
89
|
+
};
|
|
90
|
+
const resolver = createWorkspaceRouteVisibilityResolver({
|
|
91
|
+
workspaceMembershipOptionalSurfaceIds: ["customer"],
|
|
92
|
+
workspaceService: {
|
|
93
|
+
async resolveWorkspaceContextForUserBySlug(actor, workspaceSlug, options) {
|
|
94
|
+
calls.push({ actor, workspaceSlug, options });
|
|
95
|
+
return { workspace: { id: 11 } };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const contribution = await resolver.resolve({
|
|
101
|
+
visibility: "workspace_user",
|
|
102
|
+
context: { actor: { id: "user_99" } },
|
|
103
|
+
input: { workspaceSlug: "dog-and-groom" },
|
|
104
|
+
request
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.equal(calls[0].options.requireMembership, false);
|
|
108
|
+
assert.deepEqual(contribution, {
|
|
109
|
+
scopeKind: "workspace_user",
|
|
110
|
+
requiresActorScope: true,
|
|
111
|
+
scopeOwnerId: "11",
|
|
112
|
+
userId: "user_99"
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -522,6 +522,68 @@ test("workspaceService.resolveWorkspaceContextForUserBySlug grants owner access
|
|
|
522
522
|
assert.deepEqual(context.permissions, ["*"]);
|
|
523
523
|
});
|
|
524
524
|
|
|
525
|
+
test("workspaceService.resolveWorkspaceContextForUserBySlug can resolve an existing workspace without membership", async () => {
|
|
526
|
+
const service = createService({
|
|
527
|
+
appConfig: {
|
|
528
|
+
tenancyMode: "personal",
|
|
529
|
+
roleCatalog: createRoleCatalog()
|
|
530
|
+
},
|
|
531
|
+
workspacesRepository: {
|
|
532
|
+
async findBySlug() {
|
|
533
|
+
return {
|
|
534
|
+
id: "42",
|
|
535
|
+
slug: "dog-and-groom",
|
|
536
|
+
name: "Dog And Groom",
|
|
537
|
+
ownerUserId: "99",
|
|
538
|
+
isPersonal: false,
|
|
539
|
+
avatarUrl: ""
|
|
540
|
+
};
|
|
541
|
+
},
|
|
542
|
+
async findPersonalByOwnerUserId() {
|
|
543
|
+
return null;
|
|
544
|
+
},
|
|
545
|
+
async listForUserId() {
|
|
546
|
+
return [];
|
|
547
|
+
},
|
|
548
|
+
async insert() {
|
|
549
|
+
throw new Error("not implemented");
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
workspaceMembershipsRepository: {
|
|
553
|
+
async findByWorkspaceIdAndUserId() {
|
|
554
|
+
return null;
|
|
555
|
+
},
|
|
556
|
+
async ensureOwnerMembership() {
|
|
557
|
+
throw new Error("must not create membership for a non-owner");
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
workspaceSettingsRepository: {
|
|
561
|
+
async ensureForWorkspaceId() {
|
|
562
|
+
return { invitesEnabled: true };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
await assert.rejects(
|
|
568
|
+
() =>
|
|
569
|
+
service.resolveWorkspaceContextForUserBySlug(
|
|
570
|
+
{ id: "7", email: "customer@example.com" },
|
|
571
|
+
"dog-and-groom"
|
|
572
|
+
),
|
|
573
|
+
/You do not have access to this workspace/
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const context = await service.resolveWorkspaceContextForUserBySlug(
|
|
577
|
+
{ id: "7", email: "customer@example.com" },
|
|
578
|
+
"dog-and-groom",
|
|
579
|
+
{ requireMembership: false }
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
assert.equal(context.workspace.slug, "dog-and-groom");
|
|
583
|
+
assert.equal(context.membership, null);
|
|
584
|
+
assert.deepEqual(context.permissions, []);
|
|
585
|
+
});
|
|
586
|
+
|
|
525
587
|
test("workspaceService.resolveWorkspaceContextForUserBySlug resolves permissions from appConfig.roleCatalog", async () => {
|
|
526
588
|
const { service } = createWorkspaceServiceFixture({
|
|
527
589
|
roleCatalog: {
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { workspaceDirectoryActionSpecifications } from "../src/server/workspaceDirectory/workspaceDirectoryActions.js";
|
|
4
|
+
import { workspacePendingInvitationsActionSpecifications } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js";
|
|
5
|
+
import { workspaceMembersActionSpecifications } from "../src/server/workspaceMembers/workspaceMembersActions.js";
|
|
6
|
+
import { workspaceSettingsActionSpecifications } from "../src/server/workspaceSettings/workspaceSettingsActions.js";
|
|
7
7
|
|
|
8
|
-
test("workspace settings
|
|
8
|
+
test("workspace settings action specifications stay explicit", () => {
|
|
9
9
|
assert.deepEqual(
|
|
10
|
-
|
|
10
|
+
workspaceSettingsActionSpecifications.map((action) => action.id),
|
|
11
11
|
["workspace.settings.read", "workspace.settings.update"]
|
|
12
12
|
);
|
|
13
|
-
assert.
|
|
14
|
-
assert.
|
|
15
|
-
assert.deepEqual(
|
|
16
|
-
assert.equal(
|
|
13
|
+
assert.deepEqual(workspaceSettingsActionSpecifications[0].surfaces, ["*"]);
|
|
14
|
+
assert.deepEqual(workspaceSettingsActionSpecifications[1].surfaces, ["*"]);
|
|
15
|
+
assert.deepEqual(workspaceSettingsActionSpecifications[1].channels, ["api", "assistant_tool", "automation", "internal"]);
|
|
16
|
+
assert.equal(workspaceSettingsActionSpecifications[1].extensions?.assistant?.description, "Update workspace settings.");
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test("workspace actions array excludes workspace settings actions", () => {
|
|
20
20
|
const otherWorkspaceActionIds = [
|
|
21
|
-
...
|
|
22
|
-
...
|
|
23
|
-
...
|
|
21
|
+
...workspaceDirectoryActionSpecifications,
|
|
22
|
+
...workspacePendingInvitationsActionSpecifications,
|
|
23
|
+
...workspaceMembersActionSpecifications
|
|
24
24
|
].map((action) => action.id);
|
|
25
25
|
|
|
26
26
|
assert.equal(
|
|
@@ -34,17 +34,103 @@ test("workspace actions array excludes workspace settings actions", () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
test("workspace directory actions stay thin and defer output validation to routes", () => {
|
|
37
|
-
const listAction =
|
|
37
|
+
const listAction = workspaceDirectoryActionSpecifications.find((action) => action.id === "workspace.workspaces.list");
|
|
38
38
|
assert.ok(listAction);
|
|
39
39
|
assert.equal(listAction.output, null);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test("workspace directory read/update actions stay thin and defer output validation to routes", () => {
|
|
43
|
-
const readAction =
|
|
44
|
-
const updateAction =
|
|
43
|
+
const readAction = workspaceDirectoryActionSpecifications.find((action) => action.id === "workspace.workspaces.read");
|
|
44
|
+
const updateAction = workspaceDirectoryActionSpecifications.find((action) => action.id === "workspace.workspaces.update");
|
|
45
45
|
|
|
46
46
|
assert.ok(readAction);
|
|
47
47
|
assert.ok(updateAction);
|
|
48
48
|
assert.equal(readAction.output, null);
|
|
49
49
|
assert.equal(updateAction.output, null);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("workspace mutation actions declare their domain and bootstrap changes explicitly", async () => {
|
|
53
|
+
const context = {
|
|
54
|
+
actor: { id: 7 },
|
|
55
|
+
visibilityContext: { visibility: "workspace", scopeKind: "workspace", scopeOwnerId: 11 },
|
|
56
|
+
requestMeta: {
|
|
57
|
+
resolvedWorkspaceContext: {
|
|
58
|
+
workspace: { id: 11, slug: "acme" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const settingsAction = workspaceSettingsActionSpecifications.find(
|
|
63
|
+
(action) => action.id === "workspace.settings.update"
|
|
64
|
+
);
|
|
65
|
+
const events = await Promise.all(settingsAction.events.map((builder) => builder({
|
|
66
|
+
input: { workspaceSlug: "acme", invitesEnabled: true },
|
|
67
|
+
result: { value: { invitesEnabled: true } },
|
|
68
|
+
context
|
|
69
|
+
})));
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(events.map((event) => event.realtime.event), [
|
|
72
|
+
"workspace.settings.changed",
|
|
73
|
+
"users.bootstrap.changed"
|
|
74
|
+
]);
|
|
75
|
+
assert.deepEqual(events[0].scope, { kind: "workspace", id: "11" });
|
|
76
|
+
assert.equal(events[0].entityId, "11");
|
|
77
|
+
assert.deepEqual(events[0].realtime.payload, { workspaceSlug: "acme" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("workspace invite actions carry recipient lookup as delivery policy, not service metadata", async () => {
|
|
81
|
+
const action = workspaceMembersActionSpecifications.find((entry) => entry.id === "workspace.invite.create");
|
|
82
|
+
const context = {
|
|
83
|
+
actor: { id: 7 },
|
|
84
|
+
visibilityContext: { visibility: "workspace", scopeKind: "workspace", scopeOwnerId: 11 },
|
|
85
|
+
requestMeta: { resolvedWorkspaceContext: { workspace: { id: 11, slug: "acme" } } }
|
|
86
|
+
};
|
|
87
|
+
const events = await Promise.all(action.events.map((builder) => builder({
|
|
88
|
+
input: { workspaceSlug: "acme", email: "reader@example.test", roleSid: "member" },
|
|
89
|
+
result: { value: { createdInviteId: 91 } },
|
|
90
|
+
context
|
|
91
|
+
})));
|
|
92
|
+
assert.equal(events[0].operation, "created");
|
|
93
|
+
assert.equal(events[1].entityId, "91");
|
|
94
|
+
assert.equal(typeof events[1].realtime.audience.userQuery, "function");
|
|
95
|
+
const rows = await events[1].realtime.audience.userQuery({
|
|
96
|
+
event: events[1],
|
|
97
|
+
knex() {
|
|
98
|
+
return {
|
|
99
|
+
join() { return this; },
|
|
100
|
+
where(field, value) {
|
|
101
|
+
assert.equal(field, "wi.id");
|
|
102
|
+
assert.equal(value, "91");
|
|
103
|
+
return this;
|
|
104
|
+
},
|
|
105
|
+
async first() { return { user_id: 55 }; }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
assert.deepEqual(rows, [{ userId: "55" }]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("accepting an invitation declares directory/member refresh while refusal does not", async () => {
|
|
113
|
+
const action = workspacePendingInvitationsActionSpecifications.find(
|
|
114
|
+
(entry) => entry.id === "workspace.invite.redeem"
|
|
115
|
+
);
|
|
116
|
+
async function emittedNames(decision) {
|
|
117
|
+
const built = await Promise.all(action.events.map((builder) => builder({
|
|
118
|
+
input: { decision, token: "opaque" },
|
|
119
|
+
result: { value: { workspaceId: 11 } },
|
|
120
|
+
context: { actor: { id: 7 } }
|
|
121
|
+
})));
|
|
122
|
+
return built.filter(Boolean).map((event) => event.realtime.event);
|
|
123
|
+
}
|
|
124
|
+
assert.deepEqual(await emittedNames("accept"), [
|
|
125
|
+
"workspace.invitations.pending.changed",
|
|
126
|
+
"users.bootstrap.changed",
|
|
127
|
+
"workspaces.changed",
|
|
128
|
+
"workspace.members.changed",
|
|
129
|
+
"workspace.invites.changed"
|
|
130
|
+
]);
|
|
131
|
+
assert.deepEqual(await emittedNames("refuse"), [
|
|
132
|
+
"workspace.invitations.pending.changed",
|
|
133
|
+
"users.bootstrap.changed",
|
|
134
|
+
"workspace.invites.changed"
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { UsersCoreServiceProvider } from "../../users-core/src/server/UsersCoreServiceProvider.js";
|
|
4
|
-
import { INTERNAL_JSON_REST_API } from "../../json-rest-api-core/src/server/jsonRestApiHost.js";
|
|
5
3
|
import { resolveTenancyProfile } from "../src/shared/tenancyProfile.js";
|
|
6
|
-
import {
|
|
4
|
+
import { registerWorkspaceDirectoryRoutes } from "../src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js";
|
|
5
|
+
import { registerWorkspaceMembersRoutes } from "../src/server/workspaceMembers/bootWorkspaceMembers.js";
|
|
6
|
+
import { registerWorkspacePendingInvitationsRoutes } from "../src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js";
|
|
7
|
+
import { registerWorkspaceSettingsRoutes } from "../src/server/workspaceSettings/bootWorkspaceSettings.js";
|
|
7
8
|
|
|
8
9
|
function createReplyDouble() {
|
|
9
10
|
return {
|
|
@@ -30,20 +31,11 @@ function findRoute(routes, { method, path }) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
async function registerRoutes({
|
|
33
|
-
authService = {},
|
|
34
|
-
consoleService = null,
|
|
35
34
|
workspaceEnabled = true,
|
|
36
|
-
workspaceTenancyEnabled = true,
|
|
37
35
|
workspaceInvitationsEnabled = true,
|
|
38
|
-
workspaceSelfCreateEnabled = true
|
|
36
|
+
workspaceSelfCreateEnabled = true,
|
|
37
|
+
config = {}
|
|
39
38
|
} = {}) {
|
|
40
|
-
const internalApi = {
|
|
41
|
-
resources: {},
|
|
42
|
-
async addResource(scopeName) {
|
|
43
|
-
this.resources[scopeName] = {};
|
|
44
|
-
return this.resources[scopeName];
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
39
|
const registeredRoutes = [];
|
|
48
40
|
const router = {
|
|
49
41
|
register(method, path, route, handler) {
|
|
@@ -55,53 +47,14 @@ async function registerRoutes({
|
|
|
55
47
|
});
|
|
56
48
|
}
|
|
57
49
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"users.accountProfile.service",
|
|
65
|
-
{
|
|
66
|
-
async readAvatar() {
|
|
67
|
-
return {
|
|
68
|
-
mimeType: "image/png",
|
|
69
|
-
buffer: Buffer.from([])
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
],
|
|
74
|
-
["actionExecutor", {}],
|
|
75
|
-
["workspaces.enabled", workspaceEnabled],
|
|
76
|
-
["workspaces.tenancy.enabled", workspaceTenancyEnabled],
|
|
77
|
-
["workspaces.invitations.enabled", workspaceInvitationsEnabled],
|
|
78
|
-
["workspaces.self-create.enabled", workspaceSelfCreateEnabled]
|
|
79
|
-
]);
|
|
80
|
-
|
|
81
|
-
if (consoleService) {
|
|
82
|
-
bindings.set("consoleService", consoleService);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const app = {
|
|
86
|
-
has(token) {
|
|
87
|
-
return bindings.has(token);
|
|
88
|
-
},
|
|
89
|
-
instance(token, value) {
|
|
90
|
-
bindings.set(token, value);
|
|
91
|
-
return this;
|
|
92
|
-
},
|
|
93
|
-
make(token) {
|
|
94
|
-
if (!bindings.has(token)) {
|
|
95
|
-
throw new Error(`Missing test binding for token: ${String(token)}`);
|
|
96
|
-
}
|
|
97
|
-
return bindings.get(token);
|
|
50
|
+
if (workspaceEnabled) {
|
|
51
|
+
registerWorkspaceDirectoryRoutes(router, { config, workspaceSelfCreateEnabled });
|
|
52
|
+
registerWorkspaceSettingsRoutes(router, { config });
|
|
53
|
+
registerWorkspaceMembersRoutes(router, { config, workspaceInvitationsEnabled });
|
|
54
|
+
if (workspaceInvitationsEnabled) {
|
|
55
|
+
registerWorkspacePendingInvitationsRoutes(router);
|
|
98
56
|
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const usersCoreProvider = new UsersCoreServiceProvider();
|
|
102
|
-
const workspacesCoreProvider = new WorkspacesCoreServiceProvider();
|
|
103
|
-
await usersCoreProvider.boot(app);
|
|
104
|
-
await workspacesCoreProvider.boot(app);
|
|
57
|
+
}
|
|
105
58
|
|
|
106
59
|
return registeredRoutes;
|
|
107
60
|
}
|
|
@@ -115,8 +68,8 @@ async function registerRoutesForMode({
|
|
|
115
68
|
tenancyPolicy
|
|
116
69
|
});
|
|
117
70
|
return registerRoutes({
|
|
71
|
+
config: { tenancyMode, tenancyPolicy },
|
|
118
72
|
workspaceEnabled: tenancyProfile.workspace.enabled === true,
|
|
119
|
-
workspaceTenancyEnabled: tenancyProfile.mode === "workspaces",
|
|
120
73
|
workspaceInvitationsEnabled:
|
|
121
74
|
tenancyProfile.workspace.enabled === true && tenancyProfile.mode !== "none",
|
|
122
75
|
workspaceSelfCreateEnabled: tenancyProfile.workspace.allowSelfCreate === true
|
|
@@ -220,7 +173,6 @@ test("workspace core/settings routes mount one canonical workspace endpoint", as
|
|
|
220
173
|
test("workspaces-core boot skips workspace routes when workspace policy is disabled", async () => {
|
|
221
174
|
const routes = await registerRoutes({
|
|
222
175
|
workspaceEnabled: false,
|
|
223
|
-
workspaceTenancyEnabled: false,
|
|
224
176
|
workspaceInvitationsEnabled: false,
|
|
225
177
|
workspaceSelfCreateEnabled: false
|
|
226
178
|
});
|
|
@@ -230,13 +182,11 @@ test("workspaces-core boot skips workspace routes when workspace policy is disab
|
|
|
230
182
|
assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug" }), null);
|
|
231
183
|
assert.equal(findRoute(routes, { method: "PATCH", path: "/api/w/:workspaceSlug" }), null);
|
|
232
184
|
assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug/settings" }), null);
|
|
233
|
-
assert.equal(findRoute(routes, { method: "GET", path: "/api/settings" })?.path, "/api/settings");
|
|
234
185
|
});
|
|
235
186
|
|
|
236
187
|
test("workspaces-core boot skips workspace create route when self-create policy is disabled", async () => {
|
|
237
188
|
const routes = await registerRoutes({
|
|
238
189
|
workspaceEnabled: true,
|
|
239
|
-
workspaceTenancyEnabled: true,
|
|
240
190
|
workspaceInvitationsEnabled: true,
|
|
241
191
|
workspaceSelfCreateEnabled: false
|
|
242
192
|
});
|
|
@@ -327,7 +277,6 @@ test("workspaces-core route registration follows tenancy mode matrix", async ()
|
|
|
327
277
|
test("workspaces-core boot skips invitation redeem/list routes when workspace invitations are disabled", async () => {
|
|
328
278
|
const routes = await registerRoutes({
|
|
329
279
|
workspaceEnabled: true,
|
|
330
|
-
workspaceTenancyEnabled: true,
|
|
331
280
|
workspaceInvitationsEnabled: false,
|
|
332
281
|
workspaceSelfCreateEnabled: false
|
|
333
282
|
});
|