@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.
Files changed (49) hide show
  1. package/package.json +38 -238
  2. package/patterns/workspace-server/PATTERN.md +60 -0
  3. package/src/server/WorkspacesFeature.js +226 -0
  4. package/src/server/WorkspacesIntegrationsProvider.js +74 -0
  5. package/src/server/common/contributors/workspaceActionContextContributor.js +18 -6
  6. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +25 -4
  7. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +22 -4
  8. package/src/server/common/services/workspaceContextService.js +7 -2
  9. package/src/server/common/support/realtimeServiceEvents.js +102 -61
  10. package/src/server/support/workspaceActionSurfaces.js +18 -13
  11. package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +5 -11
  12. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +24 -14
  13. package/src/server/workspaceMembers/bootWorkspaceMembers.js +5 -12
  14. package/src/server/workspaceMembers/workspaceMembersActions.js +63 -23
  15. package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +4 -6
  16. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +31 -12
  17. package/src/server/workspaceSettings/bootWorkspaceSettings.js +5 -8
  18. package/src/server/workspaceSettings/workspaceSettingsActions.js +26 -8
  19. package/src/server/workspaceSettings/workspaceSettingsRepository.js +4 -4
  20. package/test/exportsContract.test.js +2 -1
  21. package/test/featureRuntime.test.js +116 -0
  22. package/test/packageMetadata.test.js +20 -54
  23. package/test/workspaceActionContextContributor.test.js +49 -8
  24. package/test/workspaceActionSurfaces.test.js +36 -1
  25. package/test/workspaceAuthPolicyContextResolver.test.js +61 -0
  26. package/test/workspaceRouteVisibilityResolver.test.js +31 -0
  27. package/test/workspaceService.test.js +62 -0
  28. package/test/workspaceSettingsActions.test.js +102 -16
  29. package/test/workspacesRouteRequestInputValidator.test.js +14 -65
  30. package/src/server/WorkspacesCoreServiceProvider.js +0 -64
  31. package/src/server/registerWorkspaceBootstrap.js +0 -27
  32. package/src/server/registerWorkspaceCore.js +0 -101
  33. package/src/server/registerWorkspaceRepositories.js +0 -30
  34. package/src/server/support/workspaceServerScopeSupport.js +0 -18
  35. package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +0 -19
  36. package/src/server/workspaceMembers/registerWorkspaceMembers.js +0 -137
  37. package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +0 -130
  38. package/src/server/workspaceSettings/registerWorkspaceSettings.js +0 -66
  39. package/test/registerServiceRealtimeEvents.test.js +0 -116
  40. package/test/registerWorkspaceBootstrap.test.js +0 -86
  41. package/test/registerWorkspaceDirectory.test.js +0 -31
  42. package/test/registerWorkspaceMembers.test.js +0 -140
  43. package/test/registerWorkspaceSettings.test.js +0 -40
  44. package/test/workspaceServerScopeSupport.test.js +0 -131
  45. /package/{templates/migrations → migrations}/workspaces_core_initial.cjs +0 -0
  46. /package/{templates/migrations → migrations}/workspaces_core_workspace_settings_single_name_source.cjs +0 -0
  47. /package/{templates/migrations → migrations}/workspaces_core_workspaces_drop_color.cjs +0 -0
  48. /package/{templates → patterns/workspace-server/example}/config/roles.js +0 -0
  49. /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 installs an app-owned editable role catalog", async () => {
43
- const source = await readFile(path.join(PACKAGE_DIR, "templates", "config", "roles.js"), "utf8");
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.deepEqual(findFileMutation("users-core-app-owned-role-catalog-config"), {
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 installs an app-owned editable workspace invite email template", async () => {
38
+ test("workspaces-core publishes an app-owned editable workspace invite email pattern", async () => {
58
39
  const source = await readFile(
59
- path.join(PACKAGE_DIR, "templates", "packages", "main", "src", "server", "email", "workspaceInviteEmail.js"),
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(findFileMutation("workspaces-core-main-workspace-invite-email-template"), {
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 { workspaceDirectoryActions } from "../src/server/workspaceDirectory/workspaceDirectoryActions.js";
4
- import { workspacePendingInvitationsActions } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js";
5
- import { workspaceMembersActions } from "../src/server/workspaceMembers/workspaceMembersActions.js";
6
- import { workspaceSettingsActions } from "../src/server/workspaceSettings/workspaceSettingsActions.js";
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 actions live in their own action array", () => {
8
+ test("workspace settings action specifications stay explicit", () => {
9
9
  assert.deepEqual(
10
- workspaceSettingsActions.map((action) => action.id),
10
+ workspaceSettingsActionSpecifications.map((action) => action.id),
11
11
  ["workspace.settings.read", "workspace.settings.update"]
12
12
  );
13
- assert.equal(workspaceSettingsActions[0].surfacesFrom, "workspace");
14
- assert.equal(workspaceSettingsActions[1].surfacesFrom, "workspace");
15
- assert.deepEqual(workspaceSettingsActions[1].channels, ["api", "assistant_tool", "automation", "internal"]);
16
- assert.equal(workspaceSettingsActions[1].extensions?.assistant?.description, "Update workspace settings.");
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
- ...workspaceDirectoryActions,
22
- ...workspacePendingInvitationsActions,
23
- ...workspaceMembersActions
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 = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.list");
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 = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.read");
44
- const updateAction = workspaceDirectoryActions.find((action) => action.id === "workspace.workspaces.update");
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 { WorkspacesCoreServiceProvider } from "../src/server/WorkspacesCoreServiceProvider.js";
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
- const bindings = new Map([
60
- ["jskit.http.router", router],
61
- ["authService", authService],
62
- [INTERNAL_JSON_REST_API, internalApi],
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
  });