@jskit-ai/workspaces-core 0.1.223 → 0.1.225

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 (28) hide show
  1. package/package.json +9 -9
  2. package/patterns/workspace-server/PATTERN.md +6 -0
  3. package/src/server/WorkspacesFeature.js +18 -1
  4. package/src/server/WorkspacesIntegrationsProvider.js +22 -1
  5. package/src/server/common/contributors/workspaceActionContextContributor.js +2 -4
  6. package/src/server/common/formatters/workspaceFormatter.js +1 -1
  7. package/src/server/common/services/workspaceContextService.js +4 -2
  8. package/src/server/workspaceBootstrapContributor.js +2 -2
  9. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +1 -17
  10. package/src/server/workspaceMembers/workspaceMembersActions.js +0 -28
  11. package/src/server/workspaceMembers/workspaceMembersService.js +1 -1
  12. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +1 -13
  13. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +1 -1
  14. package/src/server/workspaceSettings/workspaceSettingsActions.js +0 -8
  15. package/src/server/workspaceSettings/workspaceSettingsRepository.js +11 -29
  16. package/src/shared/resources/workspaceSettingsResource.js +7 -12
  17. package/src/shared/settings.js +12 -0
  18. package/src/shared/tenancyProfile.js +3 -2
  19. package/test/featureRuntime.test.js +52 -1
  20. package/test/resourcesCanonical.test.js +2 -2
  21. package/test/tenancyProfile.test.js +16 -0
  22. package/test/usersRouteResources.test.js +3 -3
  23. package/test/workspaceInternalCrudResources.test.js +2 -2
  24. package/test/workspaceMembersResource.test.js +2 -2
  25. package/test/workspacePendingInvitationsResource.test.js +4 -4
  26. package/test/workspaceService.test.js +28 -0
  27. package/test/workspaceSettingsRepository.test.js +19 -0
  28. package/test/workspaceSettingsResource.test.js +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.1.223",
3
+ "version": "0.1.225",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -18,11 +18,11 @@
18
18
  "./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js"
19
19
  },
20
20
  "dependencies": {
21
- "@jskit-ai/database-runtime": "0.1.245",
22
- "@jskit-ai/json-rest-api-core": "0.1.189",
23
- "@jskit-ai/resource-core": "0.1.187",
24
- "@jskit-ai/resource-crud-core": "0.1.187",
25
- "@jskit-ai/users-core": "0.1.258",
21
+ "@jskit-ai/database-runtime": "0.1.247",
22
+ "@jskit-ai/json-rest-api-core": "0.1.191",
23
+ "@jskit-ai/resource-core": "0.1.189",
24
+ "@jskit-ai/resource-crud-core": "0.1.189",
25
+ "@jskit-ai/users-core": "0.1.260",
26
26
  "json-rest-schema": "^1.0.17"
27
27
  },
28
28
  "description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
@@ -169,8 +169,8 @@
169
169
  }
170
170
  },
171
171
  "peerDependencies": {
172
- "@jskit-ai/auth-core": "0.1.243",
173
- "@jskit-ai/http-runtime": "0.1.243",
174
- "@jskit-ai/kernel": "0.1.245"
172
+ "@jskit-ai/auth-core": "0.1.245",
173
+ "@jskit-ai/http-runtime": "0.1.245",
174
+ "@jskit-ai/kernel": "0.1.247"
175
175
  }
176
176
  }
@@ -23,6 +23,12 @@ substitute for a simpler domain ownership column.
23
23
  Choose workspace creation policy, roles, invitation policy, expiry, email copy,
24
24
  member permissions, personal-workspace behavior, and surface access rules.
25
25
 
26
+ In `workspaces` mode, `tenancyPolicy.workspace.autoProvision: true` gives every
27
+ user a personal workspace in addition to shared ones; the server config function
28
+ `personalWorkspaceName(user)` names it. `workspaceSettings.defaults` sets
29
+ `invitesEnabled` and the colours (`lightPrimaryColor`, `darkPrimaryColor` and
30
+ the other palette fields) that new workspaces start with.
31
+
26
32
  ## Framework APIs
27
33
 
28
34
  Use workspace routes, resources, scope helpers, tenancy profiles, and settings
@@ -9,6 +9,7 @@ import { workspaceMembershipsResource } from "../shared/resources/workspaceMembe
9
9
  import { workspaceResource } from "../shared/resources/workspaceResource.js";
10
10
  import { workspaceSettingsResource } from "../shared/resources/workspaceSettingsResource.js";
11
11
  import { createWorkspaceRoleCatalog } from "../shared/roles.js";
12
+ import { WORKSPACE_SETTINGS_COLOR_FIELDS, normalizeWorkspaceHexColor } from "../shared/settings.js";
12
13
  import { resolveTenancyProfile } from "../shared/tenancyProfile.js";
13
14
  import { createRepository as createWorkspaceInvitesRepository } from "./common/repositories/workspaceInvitesRepository.js";
14
15
  import { createRepository as createWorkspaceMembershipsRepository } from "./common/repositories/workspaceMembershipsRepository.js";
@@ -49,6 +50,21 @@ function requireBoolean(value, label) {
49
50
  return value;
50
51
  }
51
52
 
53
+ function requireWorkspaceColorDefaults(defaults) {
54
+ const colors = {};
55
+ for (const fieldName of WORKSPACE_SETTINGS_COLOR_FIELDS) {
56
+ if (defaults?.[fieldName] === undefined) {
57
+ continue;
58
+ }
59
+ const color = normalizeWorkspaceHexColor(defaults[fieldName]);
60
+ if (!color) {
61
+ throw new TypeError(`runtime.config.workspaceSettings.defaults.${fieldName} must be a hex color like #1867C0.`);
62
+ }
63
+ colors[fieldName] = color;
64
+ }
65
+ return colors;
66
+ }
67
+
52
68
  function resolveInviteEmailTemplate(config) {
53
69
  const template =
54
70
  config.workspaceInviteEmailTemplate ||
@@ -92,7 +108,8 @@ function createWorkspacesRuntime({ config, env, jsonRestApi } = {}) {
92
108
  defaultInvitesEnabled: requireBoolean(
93
109
  config?.workspaceSettings?.defaults?.invitesEnabled,
94
110
  "runtime.config.workspaceSettings.defaults.invitesEnabled"
95
- )
111
+ ),
112
+ defaultColorSettings: requireWorkspaceColorDefaults(config?.workspaceSettings?.defaults)
96
113
  });
97
114
  const workspacesRepository = createWorkspacesRepository({ api: jsonRestApi });
98
115
  const workspaceMembershipsRepository = createWorkspaceMembershipsRepository({ api: jsonRestApi });
@@ -7,6 +7,16 @@ import {
7
7
  resolveWorkspaceSurfaceIdsFromAppConfig
8
8
  } from "./support/workspaceActionSurfaces.js";
9
9
 
10
+ // Every active membership puts the actor in the workspace audience and in its
11
+ // own workspace-user audience.
12
+ async function resolveWorkspaceRealtimeScopes(workspaceMemberships, actorId) {
13
+ const workspaceIds = await workspaceMemberships.listActiveWorkspaceIdsByUserId(actorId);
14
+ return workspaceIds.flatMap((workspaceId) => [
15
+ { kind: "workspace", id: workspaceId },
16
+ { kind: "workspace_user", workspaceId, userId: actorId }
17
+ ]);
18
+ }
19
+
10
20
  const WorkspacesIntegrationsProvider = defineProvider({
11
21
  id: "workspaces.integrations",
12
22
  requires: {
@@ -17,7 +27,10 @@ const WorkspacesIntegrationsProvider = defineProvider({
17
27
  usersExtensions: "users.extensions",
18
28
  workspaces: "workspaces.core"
19
29
  },
20
- setup({ actions, authExtensions, config, http, usersExtensions, workspaces }) {
30
+ optional: {
31
+ realtime: "runtime.realtime"
32
+ },
33
+ setup({ actions, authExtensions, config, http, realtime, usersExtensions, workspaces }) {
21
34
  const workspaceService = workspaces.services.directory;
22
35
  const membershipOptionalSurfaceIds = resolveWorkspaceMembershipOptionalSurfaceIdsFromAppConfig(config);
23
36
  const workspaceSurfaceIds = resolveWorkspaceSurfaceIdsFromAppConfig(config);
@@ -58,6 +71,14 @@ const WorkspacesIntegrationsProvider = defineProvider({
58
71
  }
59
72
  });
60
73
 
74
+ realtime?.registerAudienceContributor({
75
+ id: "workspaces.memberships",
76
+ resolveActorScopes: (actorId) => resolveWorkspaceRealtimeScopes(
77
+ workspaces.repositories.workspaceMemberships,
78
+ actorId
79
+ )
80
+ });
81
+
61
82
  if (workspaces.invitationsEnabled) {
62
83
  authExtensions.registerInvitationContextResolver({
63
84
  resolverId: "workspaces.invitation",
@@ -1,7 +1,5 @@
1
- import {
2
- normalizeObject,
3
- requireServiceMethod
4
- } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
1
+ import { requireServiceMethod } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
2
+ import { normalizeObject } from "@jskit-ai/kernel/shared/support/normalize";
5
3
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
6
4
  import {
7
5
  checkRouteVisibility,
@@ -1,5 +1,5 @@
1
1
  import { resolveWorkspaceThemePalettes } from "@jskit-ai/workspaces-core/shared/settings";
2
- import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
2
+ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
4
4
 
5
5
  function mapWorkspaceSummary(workspace, membership, roleCatalog = null) {
@@ -11,7 +11,7 @@ import {
11
11
  import {
12
12
  mapWorkspaceSummary
13
13
  } from "../formatters/workspaceFormatter.js";
14
- import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
14
+ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
15
15
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
16
16
 
17
17
  function toSlugPart(value) {
@@ -84,6 +84,8 @@ function createService({
84
84
  const resolvedTenancyProfile = resolveTenancyProfile(appConfig);
85
85
  const resolvedTenancyMode = resolvedTenancyProfile.mode;
86
86
  const workspacePolicy = resolvedTenancyProfile.workspace;
87
+ const personalWorkspaceName =
88
+ typeof appConfig.personalWorkspaceName === "function" ? appConfig.personalWorkspaceName : null;
87
89
  async function ensureUniqueWorkspaceSlug(baseSlug, options = {}) {
88
90
  let suffix = 0;
89
91
  while (suffix < 1000) {
@@ -121,7 +123,7 @@ function createService({
121
123
  const inserted = await workspacesRepository.insert(
122
124
  {
123
125
  slug,
124
- name: buildWorkspaceName(user),
126
+ name: normalizeText(personalWorkspaceName?.(user)) || buildWorkspaceName(user),
125
127
  ownerUserId: normalizedUserId,
126
128
  isPersonal: true,
127
129
  avatarUrl: ""
@@ -1,6 +1,6 @@
1
- import { requireServiceMethod } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
1
+ import { requireServiceMethod } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
2
2
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
3
- import { normalizeLowerText } from "@jskit-ai/kernel/shared/actions/textNormalization";
3
+ import { normalizeLowerText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
  import {
5
5
  TENANCY_MODE_NONE,
6
6
  resolveTenancyProfile
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  emptyInputValidator,
3
3
  resolveRequest
4
- } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
4
+ } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
5
5
  import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
6
6
  import { composeSchemaDefinitions } from "@jskit-ai/kernel/shared/validators";
7
7
  import { workspaceResource } from "../../shared/resources/workspaceResource.js";
@@ -40,10 +40,6 @@ const workspaceDirectoryActionSpecifications = Object.freeze([
40
40
  }
41
41
  },
42
42
  idempotency: "none",
43
- audit: {
44
- actionName: "workspace.workspaces.create"
45
- },
46
- observability: {},
47
43
  async run(workspaceService, input, context) {
48
44
  return returnJsonApiData(await workspaceService.createWorkspaceForAuthenticatedUser(resolveActionUser(context, input), input, {
49
45
  request: resolveRequest(context),
@@ -74,10 +70,6 @@ const workspaceDirectoryActionSpecifications = Object.freeze([
74
70
  }
75
71
  },
76
72
  idempotency: "none",
77
- audit: {
78
- actionName: "workspace.workspaces.list"
79
- },
80
- observability: {},
81
73
  async run(workspaceService, input, context) {
82
74
  return returnJsonApiData({
83
75
  items: await workspaceService.listWorkspacesForAuthenticatedUser(resolveActionUser(context, input), {
@@ -108,10 +100,6 @@ const workspaceDirectoryActionSpecifications = Object.freeze([
108
100
  }
109
101
  },
110
102
  idempotency: "none",
111
- audit: {
112
- actionName: "workspace.workspaces.read"
113
- },
114
- observability: {},
115
103
  async run(workspaceService, input, context) {
116
104
  return returnJsonApiData(await workspaceService.getWorkspaceForAuthenticatedUser(
117
105
  resolveActionUser(context, input),
@@ -143,10 +131,6 @@ const workspaceDirectoryActionSpecifications = Object.freeze([
143
131
  }
144
132
  },
145
133
  idempotency: "optional",
146
- audit: {
147
- actionName: "workspace.workspaces.update"
148
- },
149
- observability: {},
150
134
  async run(workspaceService, input, context) {
151
135
  const { workspaceSlug, ...patch } = input;
152
136
  return returnJsonApiData(await workspaceService.updateWorkspaceForAuthenticatedUser(
@@ -85,10 +85,6 @@ const workspaceMembersActionSpecifications = Object.freeze([
85
85
  }
86
86
  },
87
87
  idempotency: "none",
88
- audit: {
89
- actionName: "workspace.roles.list"
90
- },
91
- observability: {},
92
88
  async run(workspaceMembersService, _input, context) {
93
89
  return returnJsonApiData(await workspaceMembersService.listRoles({ context }));
94
90
  }
@@ -113,10 +109,6 @@ const workspaceMembersActionSpecifications = Object.freeze([
113
109
  }
114
110
  },
115
111
  idempotency: "none",
116
- audit: {
117
- actionName: "workspace.members.list"
118
- },
119
- observability: {},
120
112
  async run(workspaceMembersService, input, context) {
121
113
  return returnJsonApiData(await workspaceMembersService.listMembers(resolveWorkspace(context, input), {
122
114
  context
@@ -143,10 +135,6 @@ const workspaceMembersActionSpecifications = Object.freeze([
143
135
  }
144
136
  },
145
137
  idempotency: "optional",
146
- audit: {
147
- actionName: "workspace.member.role.update"
148
- },
149
- observability: {},
150
138
  events: WORKSPACE_MEMBER_CHANGED_EVENTS,
151
139
  async run(workspaceMembersService, input, context) {
152
140
  return returnJsonApiData(await workspaceMembersService.updateMemberRole(resolveWorkspace(context, input), {
@@ -177,10 +165,6 @@ const workspaceMembersActionSpecifications = Object.freeze([
177
165
  }
178
166
  },
179
167
  idempotency: "optional",
180
- audit: {
181
- actionName: "workspace.member.remove"
182
- },
183
- observability: {},
184
168
  events: WORKSPACE_MEMBER_CHANGED_EVENTS,
185
169
  async run(workspaceMembersService, input, context) {
186
170
  return returnJsonApiData(await workspaceMembersService.removeMember(resolveWorkspace(context, input), {
@@ -210,10 +194,6 @@ const workspaceMembersActionSpecifications = Object.freeze([
210
194
  }
211
195
  },
212
196
  idempotency: "none",
213
- audit: {
214
- actionName: "workspace.invites.list"
215
- },
216
- observability: {},
217
197
  async run(workspaceMembersService, input, context) {
218
198
  return returnJsonApiData(await workspaceMembersService.listInvites(resolveWorkspace(context, input), {
219
199
  context
@@ -245,10 +225,6 @@ const workspaceMembersActionSpecifications = Object.freeze([
245
225
  }
246
226
  },
247
227
  idempotency: "optional",
248
- audit: {
249
- actionName: "workspace.invite.create"
250
- },
251
- observability: {},
252
228
  events: WORKSPACE_INVITE_CREATED_EVENTS,
253
229
  async run(workspaceMembersService, input, context) {
254
230
  return returnJsonApiData(await workspaceMembersService.createInvite(
@@ -284,10 +260,6 @@ const workspaceMembersActionSpecifications = Object.freeze([
284
260
  }
285
261
  },
286
262
  idempotency: "optional",
287
- audit: {
288
- actionName: "workspace.invite.revoke"
289
- },
290
- observability: {},
291
263
  events: WORKSPACE_INVITE_REVOKED_EVENTS,
292
264
  async run(workspaceMembersService, input, context) {
293
265
  return returnJsonApiData(await workspaceMembersService.revokeInvite(resolveWorkspace(context, input), input.inviteId, {
@@ -1,5 +1,5 @@
1
1
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
2
- import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
2
+ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
4
4
  import { resolveInviteTokenHash, buildInviteToken, hashInviteToken } from "@jskit-ai/auth-core/server/inviteTokens";
5
5
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
@@ -1,7 +1,7 @@
1
1
  import { pendingInvitationsAssistantOutput } from "../../shared/resources/workspaceAssistantOutputs.js";
2
2
  import {
3
3
  emptyInputValidator
4
- } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
4
+ } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
5
5
  import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
6
6
  import { workspaceMembersResource } from "../../shared/resources/workspaceMembersResource.js";
7
7
  import { workspacePendingInvitationsResource } from "../../shared/resources/workspacePendingInvitationsResource.js";
@@ -28,10 +28,6 @@ const workspacePendingInvitationsActionSpecifications = Object.freeze([
28
28
  }
29
29
  },
30
30
  idempotency: "none",
31
- audit: {
32
- actionName: "workspace.invitation.resolve"
33
- },
34
- observability: {},
35
31
  async run(workspacePendingInvitationsService, input, context) {
36
32
  return returnJsonApiData(
37
33
  await workspacePendingInvitationsService.resolveInviteByToken(input?.token, {
@@ -64,10 +60,6 @@ const workspacePendingInvitationsActionSpecifications = Object.freeze([
64
60
  }
65
61
  },
66
62
  idempotency: "none",
67
- audit: {
68
- actionName: "workspace.invitations.pending.list"
69
- },
70
- observability: {},
71
63
  async run(workspacePendingInvitationsService, input, context) {
72
64
  return returnJsonApiData({
73
65
  pendingInvites: await workspacePendingInvitationsService.listPendingInvitesForUser(resolveActionUser(context, input), {
@@ -93,10 +85,6 @@ const workspacePendingInvitationsActionSpecifications = Object.freeze([
93
85
  }
94
86
  },
95
87
  idempotency: "optional",
96
- audit: {
97
- actionName: "workspace.invite.redeem"
98
- },
99
- observability: {},
100
88
  events: WORKSPACE_INVITE_DECISION_EVENTS,
101
89
  async run(workspacePendingInvitationsService, input, context) {
102
90
  const payload = input || {};
@@ -1,7 +1,7 @@
1
1
  import { resolveInviteTokenHash } from "@jskit-ai/auth-core/server/inviteTokens";
2
2
  import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
3
3
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
4
- import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
4
+ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
5
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
6
6
 
7
7
  function createService({
@@ -40,10 +40,6 @@ const workspaceSettingsActionSpecifications = Object.freeze([
40
40
  }
41
41
  },
42
42
  idempotency: "none",
43
- audit: {
44
- actionName: "workspace.settings.read"
45
- },
46
- observability: {},
47
43
  async run(workspaceSettingsService, input, context) {
48
44
  const response = await workspaceSettingsService.getWorkspaceSettings(resolveWorkspace(context, input), {
49
45
  context
@@ -72,10 +68,6 @@ const workspaceSettingsActionSpecifications = Object.freeze([
72
68
  }
73
69
  },
74
70
  idempotency: "optional",
75
- audit: {
76
- actionName: "workspace.settings.update"
77
- },
78
- observability: {},
79
71
  events: WORKSPACE_SETTINGS_CHANGED_EVENTS,
80
72
  async run(workspaceSettingsService, input, context) {
81
73
  const { workspaceSlug, ...patch } = input;
@@ -6,34 +6,12 @@ import {
6
6
  createJsonRestContext,
7
7
  extractJsonRestCollectionRows
8
8
  } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
9
+ import { pickOwnProperties } from "@jskit-ai/kernel/shared/support";
10
+ import { WORKSPACE_SETTINGS_FIELD_KEYS } from "../../shared/resources/workspaceSettingsResource.js";
9
11
  import { resolveWorkspaceThemePalettes } from "../../shared/settings.js";
10
12
 
11
- const WORKSPACE_SETTINGS_PATCH_FIELDS = Object.freeze([
12
- "lightPrimaryColor",
13
- "lightSecondaryColor",
14
- "lightSurfaceColor",
15
- "lightSurfaceVariantColor",
16
- "darkPrimaryColor",
17
- "darkSecondaryColor",
18
- "darkSurfaceColor",
19
- "darkSurfaceVariantColor",
20
- "invitesEnabled"
21
- ]);
22
-
23
- function pickPatchFields(source = {}) {
24
- const patch = {};
25
-
26
- for (const fieldName of WORKSPACE_SETTINGS_PATCH_FIELDS) {
27
- if (Object.hasOwn(source, fieldName)) {
28
- patch[fieldName] = source[fieldName];
29
- }
30
- }
31
-
32
- return patch;
33
- }
34
-
35
- function createDefaultWorkspaceSettingsCreatePayload(workspaceId, defaultInvitesEnabled) {
36
- const palettes = resolveWorkspaceThemePalettes({});
13
+ function createDefaultWorkspaceSettingsCreatePayload(workspaceId, defaultInvitesEnabled, defaultColorSettings = {}) {
14
+ const palettes = resolveWorkspaceThemePalettes(defaultColorSettings);
37
15
 
38
16
  return {
39
17
  id: workspaceId,
@@ -49,7 +27,7 @@ function createDefaultWorkspaceSettingsCreatePayload(workspaceId, defaultInvites
49
27
  };
50
28
  }
51
29
 
52
- function createRepository({ api, defaultInvitesEnabled = true } = {}) {
30
+ function createRepository({ api, defaultInvitesEnabled = true, defaultColorSettings = {} } = {}) {
53
31
  if (!api?.resources?.workspaceSettings) {
54
32
  throw new TypeError("workspaceSettingsRepository requires json-rest-api workspaceSettings resource.");
55
33
  }
@@ -95,7 +73,11 @@ function createRepository({ api, defaultInvitesEnabled = true } = {}) {
95
73
  try {
96
74
  await api.resources.workspaceSettings.post(
97
75
  {
98
- data: createDefaultWorkspaceSettingsCreatePayload(normalizedWorkspaceId, defaultInvitesEnabled === true),
76
+ data: createDefaultWorkspaceSettingsCreatePayload(
77
+ normalizedWorkspaceId,
78
+ defaultInvitesEnabled === true,
79
+ defaultColorSettings
80
+ ),
99
81
  format: "plain",
100
82
  returning: "full",
101
83
  transaction: options?.trx || null
@@ -119,7 +101,7 @@ function createRepository({ api, defaultInvitesEnabled = true } = {}) {
119
101
 
120
102
  await ensureForWorkspaceId(normalizedWorkspaceId, options);
121
103
  const source = patch && typeof patch === "object" && !Array.isArray(patch) ? patch : {};
122
- const updatePayload = pickPatchFields(source);
104
+ const updatePayload = pickOwnProperties(source, WORKSPACE_SETTINGS_FIELD_KEYS);
123
105
 
124
106
  if (Object.keys(updatePayload).length < 1) {
125
107
  return findByWorkspaceId(normalizedWorkspaceId, options);
@@ -3,19 +3,14 @@ import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
3
3
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
4
4
  import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
5
5
  import { workspaceRoleCatalogSchema } from "./workspaceRoleCatalogSchema.js";
6
- import { normalizeWorkspaceHexColor, DEFAULT_WORKSPACE_DARK_PALETTE, DEFAULT_WORKSPACE_LIGHT_PALETTE } from "../settings.js";
6
+ import {
7
+ normalizeWorkspaceHexColor,
8
+ DEFAULT_WORKSPACE_DARK_PALETTE,
9
+ DEFAULT_WORKSPACE_LIGHT_PALETTE,
10
+ WORKSPACE_SETTINGS_COLOR_FIELDS
11
+ } from "../settings.js";
7
12
 
8
- const WORKSPACE_SETTINGS_FIELD_KEYS = deepFreeze([
9
- "lightPrimaryColor",
10
- "lightSecondaryColor",
11
- "lightSurfaceColor",
12
- "lightSurfaceVariantColor",
13
- "darkPrimaryColor",
14
- "darkSecondaryColor",
15
- "darkSurfaceColor",
16
- "darkSurfaceVariantColor",
17
- "invitesEnabled"
18
- ]);
13
+ const WORKSPACE_SETTINGS_FIELD_KEYS = deepFreeze([...WORKSPACE_SETTINGS_COLOR_FIELDS, "invitesEnabled"]);
19
14
 
20
15
  const workspaceSettingsBodySchema = createSchema({
21
16
  lightPrimaryColor: {
@@ -18,6 +18,17 @@ const DEFAULT_WORKSPACE_DARK_PALETTE = Object.freeze({
18
18
 
19
19
  const DEFAULT_WORKSPACE_COLOR = DEFAULT_WORKSPACE_LIGHT_PALETTE.color;
20
20
 
21
+ const WORKSPACE_SETTINGS_COLOR_FIELDS = Object.freeze([
22
+ "lightPrimaryColor",
23
+ "lightSecondaryColor",
24
+ "lightSurfaceColor",
25
+ "lightSurfaceVariantColor",
26
+ "darkPrimaryColor",
27
+ "darkSecondaryColor",
28
+ "darkSurfaceColor",
29
+ "darkSurfaceVariantColor"
30
+ ]);
31
+
21
32
  function normalizeWorkspaceHexColor(value) {
22
33
  const normalized = String(value || "").trim();
23
34
  if (!HEX_COLOR_PATTERN.test(normalized)) {
@@ -113,6 +124,7 @@ export {
113
124
  normalizeWorkspaceThemeMode,
114
125
  resolveWorkspaceThemeDefaultPalette,
115
126
  resolveWorkspaceThemePalettes,
127
+ WORKSPACE_SETTINGS_COLOR_FIELDS,
116
128
  WORKSPACE_THEME_MODE_DARK,
117
129
  WORKSPACE_THEME_MODE_LIGHT,
118
130
  resolveWorkspaceThemePalette
@@ -15,7 +15,8 @@ function resolveWorkspacePolicyOverrides(appConfig = {}) {
15
15
  const workspacePolicy = isRecord(tenancyPolicy.workspace) ? tenancyPolicy.workspace : {};
16
16
 
17
17
  return Object.freeze({
18
- allowSelfCreate: typeof workspacePolicy.allowSelfCreate === "boolean" ? workspacePolicy.allowSelfCreate : null
18
+ allowSelfCreate: typeof workspacePolicy.allowSelfCreate === "boolean" ? workspacePolicy.allowSelfCreate : null,
19
+ autoProvision: typeof workspacePolicy.autoProvision === "boolean" ? workspacePolicy.autoProvision : null
19
20
  });
20
21
  }
21
22
 
@@ -40,7 +41,7 @@ function resolveWorkspacePolicy(mode, overrides = {}) {
40
41
 
41
42
  return Object.freeze({
42
43
  enabled: true,
43
- autoProvision: false,
44
+ autoProvision: overrides.autoProvision === true,
44
45
  allowSelfCreate: overrides.allowSelfCreate === true,
45
46
  slugPolicy: WORKSPACE_SLUG_POLICY_USER_SELECTED
46
47
  });
@@ -5,7 +5,7 @@ import { createActionProvider } from "@jskit-ai/kernel/server/actions";
5
5
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
6
6
  import { createBootstrapRuntime } from "@jskit-ai/kernel/server/runtime";
7
7
  import { createUsersExtensions } from "@jskit-ai/users-core/server/usersExtensions";
8
- import { WorkspacesFeature } from "../src/server/WorkspacesFeature.js";
8
+ import { WorkspacesFeature, createWorkspacesRuntime } from "../src/server/WorkspacesFeature.js";
9
9
  import { WorkspacesIntegrationsProvider } from "../src/server/WorkspacesIntegrationsProvider.js";
10
10
 
11
11
  function createJsonRestApiFixture() {
@@ -50,6 +50,7 @@ function runtimeConfig() {
50
50
  test("WorkspacesFeature assembles workspace behavior and explicit integrations", async () => {
51
51
  const routes = [];
52
52
  const visibilityResolvers = [];
53
+ const audienceContributors = [];
53
54
  const bootstrap = createBootstrapRuntime();
54
55
  const authExtensions = createAuthExtensions();
55
56
  const usersExtensions = createUsersExtensions();
@@ -86,6 +87,11 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
86
87
  }
87
88
  },
88
89
  "runtime.json-rest-api": jsonRestApi,
90
+ "runtime.realtime": {
91
+ registerAudienceContributor(contributor) {
92
+ audienceContributors.push(contributor);
93
+ }
94
+ },
89
95
  "users.core": {
90
96
  repositories: {
91
97
  userProfiles: { async findById() { return null; } }
@@ -117,4 +123,49 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
117
123
  assert.deepEqual(usersExtensions.diagnostics().profileSyncLifecycleContributorIds, ["workspaces.provisioning"]);
118
124
  assert.deepEqual(authExtensions.diagnostics().invitationContextResolverIds, ["workspaces.invitation"]);
119
125
  assert.deepEqual(authExtensions.diagnostics().policyContextResolverIds, ["workspaces.policy"]);
126
+ assert.deepEqual(audienceContributors.map((entry) => entry.id), ["workspaces.memberships"]);
127
+ });
128
+
129
+ test("workspace realtime audiences are the actor's active memberships", async () => {
130
+ const audienceContributors = [];
131
+ const noop = () => {};
132
+ WorkspacesIntegrationsProvider.setup({
133
+ actions: { registerContextContributor: noop },
134
+ authExtensions: { registerPolicyContextResolver: noop },
135
+ config: runtimeConfig(),
136
+ http: { registerVisibilityResolver: noop },
137
+ realtime: { registerAudienceContributor: (entry) => audienceContributors.push(entry) },
138
+ usersExtensions: { registerProfileSyncLifecycleContributor: noop },
139
+ workspaces: {
140
+ invitationsEnabled: false,
141
+ services: { directory: { async resolveWorkspaceContextForUserBySlug() { return null; } } },
142
+ repositories: {
143
+ workspaceMemberships: {
144
+ async listActiveWorkspaceIdsByUserId(userId) {
145
+ assert.equal(userId, "9");
146
+ return ["11", "12"];
147
+ }
148
+ }
149
+ }
150
+ }
151
+ });
152
+ assert.equal(audienceContributors.length, 1);
153
+ assert.deepEqual(await audienceContributors[0].resolveActorScopes("9"), [
154
+ { kind: "workspace", id: "11" },
155
+ { kind: "workspace_user", workspaceId: "11", userId: "9" },
156
+ { kind: "workspace", id: "12" },
157
+ { kind: "workspace_user", workspaceId: "12", userId: "9" }
158
+ ]);
159
+ });
160
+
161
+ test("workspace settings color defaults must be hex colors", () => {
162
+ const config = runtimeConfig();
163
+ config.workspaceSettings.defaults.lightPrimaryColor = "charcoal";
164
+ const jsonRestApi = createJsonRestApiFixture();
165
+ jsonRestApi.resources.workspaceSettings = {};
166
+
167
+ assert.throws(
168
+ () => createWorkspacesRuntime({ config, env: {}, jsonRestApi }),
169
+ /runtime\.config\.workspaceSettings\.defaults\.lightPrimaryColor must be a hex color/
170
+ );
120
171
  });
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import path from "node:path";
4
4
  import { existsSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
6
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
7
7
  import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
8
8
  import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
9
9
  import { workspaceInvitesResource } from "../src/shared/resources/workspaceInvitesResource.js";
@@ -87,7 +87,7 @@ test("workspaces-core specialized resource operations expose messages and valida
87
87
  for (const { label, operation } of operationSpecs) {
88
88
  assert.equal(typeof operation?.messages, "object", `${label}.messages must be an object.`);
89
89
  assert.equal(
90
- typeof resolveStructuredSchemaTransportSchema(operation?.output, {
90
+ typeof resolveSchemaTransportSchemaDefinition(operation?.output, {
91
91
  context: `${label}.output`,
92
92
  defaultMode: "replace"
93
93
  }),
@@ -65,3 +65,19 @@ test("resolveTenancyProfile allows explicit workspace self-create policy overrid
65
65
  assert.equal(workspaceProfile.mode, TENANCY_MODE_WORKSPACES);
66
66
  assert.equal(workspaceProfile.workspace.allowSelfCreate, true);
67
67
  });
68
+
69
+ test("resolveTenancyProfile lets workspace mode opt into a personal workspace for every user", () => {
70
+ const tenancyPolicy = { workspace: { autoProvision: true } };
71
+ assert.equal(
72
+ resolveTenancyProfile({ tenancyMode: TENANCY_MODE_WORKSPACES, tenancyPolicy }).workspace.autoProvision,
73
+ true
74
+ );
75
+ assert.equal(resolveTenancyProfile({ tenancyMode: TENANCY_MODE_NONE, tenancyPolicy }).workspace.autoProvision, false);
76
+ assert.equal(
77
+ resolveTenancyProfile({
78
+ tenancyMode: TENANCY_MODE_PERSONAL,
79
+ tenancyPolicy: { workspace: { autoProvision: false } }
80
+ }).workspace.autoProvision,
81
+ true
82
+ );
83
+ });
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { existsSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { deriveResourceRequiredMetadata } from "@jskit-ai/kernel/_testable";
7
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
7
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
8
8
  import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
9
9
  import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
10
10
  import { workspaceResource } from "../src/shared/resources/workspaceResource.js";
@@ -29,7 +29,7 @@ function assertResourceShape(resource, label) {
29
29
  `${label}.operations.${operationName} must resolve messages from operation.messages or resource.messages.`
30
30
  );
31
31
  assert.equal(
32
- typeof resolveStructuredSchemaTransportSchema(operation.output, {
32
+ typeof resolveSchemaTransportSchemaDefinition(operation.output, {
33
33
  context: `${label}.operations.${operationName}.output`,
34
34
  defaultMode: "replace"
35
35
  }),
@@ -76,7 +76,7 @@ test("workspace settings and invite operations expose canonical validators", ()
76
76
  for (const { label, operation } of operationSpecs) {
77
77
  assert.equal(typeof operation?.method, "string", `${label}.method must exist.`);
78
78
  assert.equal(
79
- typeof resolveStructuredSchemaTransportSchema(operation?.output, {
79
+ typeof resolveSchemaTransportSchemaDefinition(operation?.output, {
80
80
  context: `${label}.output`,
81
81
  defaultMode: "replace"
82
82
  }),
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
4
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
4
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
5
5
  import { workspaceInvitesResource } from "../src/shared/resources/workspaceInvitesResource.js";
6
6
  import { workspaceMembershipsResource } from "../src/shared/resources/workspaceMembershipsResource.js";
7
7
 
@@ -24,7 +24,7 @@ test("workspace internal CRUD resources expose canonical derived operation sets"
24
24
  assert.equal(typeof resource.operations.create.body?.schema, "object", `${label}.operations.create.body.schema is required.`);
25
25
  assert.equal(typeof resource.operations.patch.body?.schema, "object", `${label}.operations.patch.body.schema is required.`);
26
26
  assert.equal(
27
- typeof resolveStructuredSchemaTransportSchema(resource.operations.view.output, {
27
+ typeof resolveSchemaTransportSchemaDefinition(resource.operations.view.output, {
28
28
  context: `${label}.operations.view.output`,
29
29
  defaultMode: "replace"
30
30
  }),
@@ -1,10 +1,10 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
3
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
4
4
  import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
5
5
 
6
6
  function resolveOutputSchema(operationName) {
7
- return resolveStructuredSchemaTransportSchema(workspaceMembersResource.operations[operationName].output, {
7
+ return resolveSchemaTransportSchemaDefinition(workspaceMembersResource.operations[operationName].output, {
8
8
  context: `workspaceMembers.${operationName}.output`,
9
9
  defaultMode: "replace"
10
10
  });
@@ -1,10 +1,10 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
3
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
4
4
  import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
5
5
 
6
6
  test("workspacePendingInvitationsResource output schema accepts already-shaped invite payloads", () => {
7
- const outputSchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.list.output, {
7
+ const outputSchema = resolveSchemaTransportSchemaDefinition(workspacePendingInvitationsResource.operations.list.output, {
8
8
  context: "workspacePendingInvitations.list.output",
9
9
  defaultMode: "replace"
10
10
  });
@@ -33,11 +33,11 @@ test("workspacePendingInvitationsResource output schema accepts already-shaped i
33
33
  });
34
34
 
35
35
  test("workspacePendingInvitationsResource exposes public resolve query and terminal statuses", () => {
36
- const querySchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.resolve.query, {
36
+ const querySchema = resolveSchemaTransportSchemaDefinition(workspacePendingInvitationsResource.operations.resolve.query, {
37
37
  context: "workspacePendingInvitations.resolve.query",
38
38
  defaultMode: "replace"
39
39
  });
40
- const outputSchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.resolve.output, {
40
+ const outputSchema = resolveSchemaTransportSchemaDefinition(workspacePendingInvitationsResource.operations.resolve.output, {
41
41
  context: "workspacePendingInvitations.resolve.output",
42
42
  defaultMode: "replace"
43
43
  });
@@ -27,6 +27,7 @@ function createWorkspaceServiceFixture({
27
27
  additionalWorkspaces = [],
28
28
  userWorkspaceRows = null,
29
29
  membershipResolver = null,
30
+ personalWorkspaceName = undefined,
30
31
  personalWorkspace = {
31
32
  id: "1",
32
33
  slug: "tonymobily3",
@@ -72,6 +73,7 @@ function createWorkspaceServiceFixture({
72
73
  appConfig: {
73
74
  tenancyMode,
74
75
  tenancyPolicy,
76
+ personalWorkspaceName,
75
77
  roleCatalog: roleCatalog && typeof roleCatalog === "object" ? { ...roleCatalog } : roleCatalog
76
78
  },
77
79
  workspacesRepository: {
@@ -278,6 +280,32 @@ test("workspaceService.ensureProvisionedWorkspaceForAuthenticatedUser provisions
278
280
  assert.equal(calls.ensureOwnerMembership, 1);
279
281
  assert.equal(calls.ensureWorkspaceSettings, 1);
280
282
  assert.equal(insertedPayloads[0].isPersonal, true);
283
+ assert.equal(insertedPayloads[0].name, "Chiara's Workspace");
284
+ });
285
+
286
+ test("workspaceService.ensureProvisionedWorkspaceForAuthenticatedUser provisions a named personal workspace when workspace tenancy opts in", async () => {
287
+ const { service, calls, insertedPayloads } = createWorkspaceServiceFixture({
288
+ tenancyMode: "workspaces",
289
+ tenancyPolicy: {
290
+ workspace: {
291
+ autoProvision: true
292
+ }
293
+ },
294
+ personalWorkspaceName: (user) => `${user.displayName}'s home`,
295
+ personalWorkspace: null
296
+ });
297
+
298
+ const workspace = await service.ensureProvisionedWorkspaceForAuthenticatedUser({
299
+ id: "7",
300
+ email: "chiaramobily@gmail.com",
301
+ displayName: "Chiara"
302
+ });
303
+
304
+ assert.equal(Number(workspace.ownerUserId), 7);
305
+ assert.equal(calls.insert, 1);
306
+ assert.equal(calls.ensureOwnerMembership, 1);
307
+ assert.equal(insertedPayloads[0].isPersonal, true);
308
+ assert.equal(insertedPayloads[0].name, "Chiara's home");
281
309
  });
282
310
 
283
311
  test("workspaceService.ensureProvisionedWorkspaceForAuthenticatedUser reuses the existing personal workspace in personal tenancy", async () => {
@@ -181,6 +181,25 @@ test("workspaceSettingsRepository.ensureForWorkspaceId delegates defaults to the
181
181
  assert.equal(record.id, "5");
182
182
  });
183
183
 
184
+ test("workspaceSettingsRepository.ensureForWorkspaceId starts new workspaces from the app's default colors", async () => {
185
+ const { api, state, DEFAULT_WORKSPACE_THEME } = createWorkspaceSettingsApiStub();
186
+ state.row = null;
187
+ const repository = createRepository({
188
+ api,
189
+ defaultColorSettings: {
190
+ lightPrimaryColor: "#1C1C1C",
191
+ darkPrimaryColor: "#F5F5F5"
192
+ }
193
+ });
194
+
195
+ await repository.ensureForWorkspaceId("5");
196
+
197
+ assert.equal(state.postPayload.data.lightPrimaryColor, "#1C1C1C");
198
+ assert.equal(state.postPayload.data.darkPrimaryColor, "#F5F5F5");
199
+ assert.equal(state.postPayload.data.lightSecondaryColor, DEFAULT_WORKSPACE_THEME.light.secondaryColor);
200
+ assert.equal(state.postPayload.data.darkSurfaceColor, DEFAULT_WORKSPACE_THEME.dark.surfaceColor);
201
+ });
202
+
184
203
  test("workspaceSettingsRepository.updateSettingsByWorkspaceId updates workspace settings fields", async () => {
185
204
  const { api, state } = createWorkspaceSettingsApiStub();
186
205
  const repository = createRepository({
@@ -1,7 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
4
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
4
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
5
5
  import { resolveWorkspaceThemePalettes } from "@jskit-ai/workspaces-core/shared/settings";
6
6
  import {
7
7
  WORKSPACE_SETTINGS_FIELD_KEYS,
@@ -102,7 +102,7 @@ test("workspace settings create body requires full-write fields", async () => {
102
102
  });
103
103
 
104
104
  test("workspace settings output schema accepts already-shaped service payloads", () => {
105
- const outputSchema = resolveStructuredSchemaTransportSchema(workspaceSettingsResource.operations.view.output, {
105
+ const outputSchema = resolveSchemaTransportSchemaDefinition(workspaceSettingsResource.operations.view.output, {
106
106
  context: "workspaceSettings.view.output",
107
107
  defaultMode: "replace"
108
108
  });