@jskit-ai/workspaces-core 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/locales/en.json +2 -2
  2. package/package.json +38 -11
  3. package/patterns/workspace-server/PATTERN.md +42 -17
  4. package/patterns/workspace-server/example/packages/main/src/server/WorkspaceInvitationsFeature.js +27 -0
  5. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteEmail.js +11 -10
  6. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteMailer.js +27 -0
  7. package/src/server/WorkspacesFeature.js +74 -183
  8. package/src/server/WorkspacesIntegrationsProvider.js +26 -10
  9. package/src/server/common/contributors/workspaceActionContextContributor.js +60 -70
  10. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +12 -27
  11. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +48 -63
  12. package/src/server/common/formatters/workspaceFormatter.js +12 -10
  13. package/src/server/common/repositories/workspaceInvitesRepository.js +74 -75
  14. package/src/server/common/repositories/workspaceMembershipsRepository.js +23 -45
  15. package/src/server/common/repositories/workspacesRepository.js +17 -25
  16. package/src/server/common/services/{workspaceContextService.js → workspaceDirectoryService.js} +52 -72
  17. package/src/server/common/support/realtimeServiceEvents.js +20 -12
  18. package/src/server/common/support/workspaceRecordId.js +21 -0
  19. package/src/server/common/validators/routeParamsValidator.js +3 -2
  20. package/src/server/i18n.js +5 -2
  21. package/src/server/support/resolveWorkspace.js +10 -7
  22. package/src/server/support/workspaceActionSurfaces.js +5 -41
  23. package/src/server/support/workspaceCrud.js +25 -0
  24. package/src/server/support/workspaceInvitationsPolicy.js +16 -27
  25. package/src/server/support/workspaceRouteInput.js +19 -2
  26. package/src/server/{common/support → support}/workspaceRoutePaths.js +8 -2
  27. package/src/server/types.js +47 -0
  28. package/src/server/workspaceBootstrapContributor.js +105 -142
  29. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +105 -137
  30. package/src/server/workspaceDirectory/{bootWorkspaceDirectoryRoutes.js → workspaceDirectoryRoutes.js} +7 -19
  31. package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +15 -29
  32. package/src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js +25 -0
  33. package/src/server/workspaceMembers/workspaceInviteUrls.js +24 -23
  34. package/src/server/workspaceMembers/workspaceMembersActions.js +189 -219
  35. package/src/server/workspaceMembers/{bootWorkspaceMembers.js → workspaceMembersRoutes.js} +14 -26
  36. package/src/server/workspaceMembers/workspaceMembersService.js +110 -97
  37. package/src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js +8 -0
  38. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +79 -112
  39. package/src/server/workspacePendingInvitations/{bootWorkspacePendingInvitations.js → workspacePendingInvitationsRoutes.js} +11 -8
  40. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +54 -57
  41. package/src/server/workspaceSettings/workspaceSettingsActions.js +53 -73
  42. package/src/server/workspaceSettings/workspaceSettingsRepository.js +20 -20
  43. package/src/server/workspaceSettings/{bootWorkspaceSettings.js → workspaceSettingsRoutes.js} +9 -21
  44. package/src/server/workspaceSettings/workspaceSettingsService.js +24 -26
  45. package/src/server/workspacesRuntime.js +185 -0
  46. package/src/shared/operationMessages.js +6 -15
  47. package/src/shared/resources/workspaceMembersResource.js +2 -2
  48. package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
  49. package/src/shared/resources/workspaceResource.js +4 -1
  50. package/src/shared/roles.js +15 -12
  51. package/src/shared/tenancyProfile.js +2 -2
  52. package/test/exportsContract.test.js +5 -0
  53. package/test/featureRuntime.test.js +5 -6
  54. package/test/packageMetadata.test.js +12 -0
  55. package/test/repositories.integration.test.js +1 -1
  56. package/test/requestedWorkspaceScope.test.js +24 -0
  57. package/test/roleLabels.test.js +29 -7
  58. package/test/support/invitationTransactions.js +35 -19
  59. package/test/workspaceActionSurfaces.test.js +0 -55
  60. package/test/workspaceAuthPolicyContextResolver.test.js +1 -1
  61. package/test/workspaceBootstrapContributor.test.js +7 -6
  62. package/test/workspaceCrud.test.js +23 -0
  63. package/test/workspaceInvitesRepository.test.js +9 -8
  64. package/test/workspaceMembersService.test.js +131 -28
  65. package/test/workspacePendingInvitationsResource.test.js +2 -2
  66. package/test/workspacePendingInvitationsService.test.js +69 -22
  67. package/test/workspaceService.test.js +58 -47
  68. package/test/workspaceSettingsActions.test.js +14 -7
  69. package/test/workspaceSettingsRepository.test.js +11 -0
  70. package/test/workspaceSettingsService.test.js +11 -9
  71. package/test/workspacesRepository.test.js +16 -16
  72. package/test/workspacesRouteRequestInputValidator.test.js +4 -4
  73. package/src/server/common/repositories/repositoryUtils.js +0 -43
  74. package/src/server/common/support/deepFreeze.js +0 -1
  75. package/src/server/common/support/resolveActionUser.js +0 -9
@@ -1,12 +1,11 @@
1
1
  import {
2
- normalizeLowerText,
3
- normalizeRecordId,
2
+ isLostDuplicateInsertRace,
4
3
  normalizeDbRecordId,
5
- normalizeText,
6
- isDuplicateEntryError,
7
- toIsoString,
8
- retryDeadlocks
9
- } from "./repositoryUtils.js";
4
+ resolveTransaction,
5
+ retryDeadlocks,
6
+ toIsoString
7
+ } from "@jskit-ai/database-runtime/shared";
8
+ import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
10
9
  import {
11
10
  createJsonRestContext,
12
11
  extractJsonRestCollectionRows
@@ -29,20 +28,6 @@ function normalizeMembershipRecord(payload) {
29
28
  };
30
29
  }
31
30
 
32
- function normalizeMembershipPatchPayload(payload = {}) {
33
- const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
34
- const normalized = {};
35
-
36
- if (Object.hasOwn(source, "roleSid")) {
37
- normalized.roleSid = normalizeLowerText(source.roleSid);
38
- }
39
- if (Object.hasOwn(source, "status")) {
40
- normalized.status = normalizeLowerText(source.status);
41
- }
42
-
43
- return normalized;
44
- }
45
-
46
31
  function normalizeMemberSummaryRow(row) {
47
32
  if (!row) {
48
33
  return null;
@@ -57,11 +42,8 @@ function normalizeMemberSummaryRow(row) {
57
42
  };
58
43
  }
59
44
 
60
- function createRepository({ api } = {}) {
61
- if (!api?.resources?.workspaceMemberships) {
62
- throw new TypeError("workspaceMembershipsRepository requires json-rest-api workspaceMemberships resource.");
63
- }
64
-
45
+ /** @param {{ api: import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi }} dependencies */
46
+ function createRepository({ api }) {
65
47
  const withTransaction = (work) => retryDeadlocks(() => api.transaction(work));
66
48
 
67
49
  async function queryMemberships(filters = {}, options = {}, { include = [] } = {}) {
@@ -79,10 +61,10 @@ function createRepository({ api } = {}) {
79
61
  filters,
80
62
  ...(normalizedInclude.length > 0 ? { include: normalizedInclude } : {})
81
63
  },
82
- transaction: options?.trx || null,
64
+ transaction: resolveTransaction(options),
83
65
  format: "plain"
84
66
  },
85
- createJsonRestContext(options?.context || null)
67
+ createJsonRestContext(options.context)
86
68
  )
87
69
  );
88
70
  }
@@ -127,9 +109,9 @@ function createRepository({ api } = {}) {
127
109
  },
128
110
  format: "plain",
129
111
  returning: "full",
130
- transaction: options?.trx || null
112
+ transaction: resolveTransaction(options)
131
113
  },
132
- createJsonRestContext(options?.context || null)
114
+ createJsonRestContext(options.context)
133
115
  );
134
116
  }
135
117
  return findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, options);
@@ -149,12 +131,12 @@ function createRepository({ api } = {}) {
149
131
  },
150
132
  format: "plain",
151
133
  returning: "full",
152
- transaction: options?.trx || null
134
+ transaction: resolveTransaction(options)
153
135
  },
154
- createJsonRestContext(options?.context || null)
136
+ createJsonRestContext(options.context)
155
137
  );
156
138
  } catch (error) {
157
- if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
139
+ if (!isLostDuplicateInsertRace(error, options)) {
158
140
  throw error;
159
141
  }
160
142
  }
@@ -162,7 +144,7 @@ function createRepository({ api } = {}) {
162
144
  return findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, options);
163
145
  }
164
146
 
165
- async function upsertMembership(workspaceId, userId, patch = {}, options = {}) {
147
+ async function upsertMembership(workspaceId, userId, patch, options = {}) {
166
148
  const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
167
149
  const normalizedUserId = normalizeRecordId(userId, { fallback: null });
168
150
  if (!normalizedWorkspaceId || !normalizedUserId) {
@@ -170,12 +152,8 @@ function createRepository({ api } = {}) {
170
152
  }
171
153
 
172
154
  const existing = await findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, options);
173
- const normalizedPatch = normalizeMembershipPatchPayload({
174
- roleSid: patch?.roleSid ?? existing?.roleSid ?? "member",
175
- status: patch?.status ?? existing?.status ?? "active"
176
- });
177
- const roleSid = normalizeLowerText(normalizedPatch.roleSid || "member") || "member";
178
- const status = normalizeLowerText(normalizedPatch.status || "active") || "active";
155
+ const roleSid = normalizeLowerText(patch?.roleSid ?? existing?.roleSid) || "member";
156
+ const status = normalizeLowerText(patch?.status ?? existing?.status) || "active";
179
157
 
180
158
  if (!existing) {
181
159
  try {
@@ -192,12 +170,12 @@ function createRepository({ api } = {}) {
192
170
  },
193
171
  format: "plain",
194
172
  returning: "full",
195
- transaction: options?.trx || null
173
+ transaction: resolveTransaction(options)
196
174
  },
197
- createJsonRestContext(options?.context || null)
175
+ createJsonRestContext(options.context)
198
176
  );
199
177
  } catch (error) {
200
- if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
178
+ if (!isLostDuplicateInsertRace(error, options)) {
201
179
  throw error;
202
180
  }
203
181
  }
@@ -215,9 +193,9 @@ function createRepository({ api } = {}) {
215
193
  },
216
194
  format: "plain",
217
195
  returning: "full",
218
- transaction: options?.trx || null
196
+ transaction: resolveTransaction(options)
219
197
  },
220
- createJsonRestContext(options?.context || null)
198
+ createJsonRestContext(options.context)
221
199
  );
222
200
 
223
201
  return findByWorkspaceIdAndUserId(normalizedWorkspaceId, normalizedUserId, options);
@@ -1,10 +1,5 @@
1
- import {
2
- normalizeRecordId,
3
- isDuplicateEntryError,
4
- normalizeText,
5
- toIsoString,
6
- retryDeadlocks
7
- } from "./repositoryUtils.js";
1
+ import { isLostDuplicateInsertRace, resolveTransaction, retryDeadlocks, toIsoString } from "@jskit-ai/database-runtime/shared";
2
+ import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
8
3
  import {
9
4
  createJsonRestContext,
10
5
  extractJsonRestCollectionRows
@@ -28,11 +23,8 @@ function normalizeWorkspaceRecord(payload = null) {
28
23
  };
29
24
  }
30
25
 
31
- function createRepository({ api } = {}) {
32
- if (!api?.resources?.workspaces || !api?.resources?.workspaceMemberships) {
33
- throw new TypeError("workspacesRepository requires json-rest-api workspaces and workspaceMemberships resources.");
34
- }
35
-
26
+ /** @param {{ api: import("@jskit-ai/json-rest-api-core/server/types").RuntimeJsonRestApi }} dependencies */
27
+ function createRepository({ api }) {
36
28
  const withTransaction = (work) => retryDeadlocks(() => api.transaction(work));
37
29
 
38
30
  async function queryFirst(filters = {}, options = {}) {
@@ -42,10 +34,10 @@ function createRepository({ api } = {}) {
42
34
  queryParams: {
43
35
  filters
44
36
  },
45
- transaction: options?.trx || null,
37
+ transaction: resolveTransaction(options),
46
38
  format: "plain"
47
39
  },
48
- createJsonRestContext(options?.context || null)
40
+ createJsonRestContext(options.context)
49
41
  )
50
42
  );
51
43
 
@@ -85,10 +77,10 @@ function createRepository({ api } = {}) {
85
77
  isPersonal: true
86
78
  }
87
79
  },
88
- transaction: options?.trx || null,
80
+ transaction: resolveTransaction(options),
89
81
  format: "plain"
90
82
  },
91
- createJsonRestContext(options?.context || null)
83
+ createJsonRestContext(options.context)
92
84
  )
93
85
  );
94
86
 
@@ -104,7 +96,7 @@ function createRepository({ api } = {}) {
104
96
  return normalizedRows[0] || null;
105
97
  }
106
98
 
107
- async function insert(payload = {}, options = {}) {
99
+ async function insert(payload, options = {}) {
108
100
  const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
109
101
  const ownerUserId = normalizeRecordId(source.ownerUserId, { fallback: null });
110
102
  if (!ownerUserId) {
@@ -130,14 +122,14 @@ function createRepository({ api } = {}) {
130
122
  },
131
123
  format: "plain",
132
124
  returning: "full",
133
- transaction: options?.trx || null
125
+ transaction: resolveTransaction(options)
134
126
  },
135
- createJsonRestContext(options?.context || null)
127
+ createJsonRestContext(options.context)
136
128
  );
137
129
 
138
130
  return normalizeWorkspaceRecord(created);
139
131
  } catch (error) {
140
- if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
132
+ if (!isLostDuplicateInsertRace(error, options)) {
141
133
  throw error;
142
134
  }
143
135
  if (!Object.hasOwn(createPayload, "slug")) {
@@ -152,7 +144,7 @@ function createRepository({ api } = {}) {
152
144
  }
153
145
  }
154
146
 
155
- async function updateById(workspaceId, patch = {}, options = {}) {
147
+ async function updateById(workspaceId, patch, options = {}) {
156
148
  const normalizedWorkspaceId = normalizeRecordId(workspaceId, { fallback: null });
157
149
  if (!normalizedWorkspaceId) {
158
150
  return null;
@@ -174,9 +166,9 @@ function createRepository({ api } = {}) {
174
166
  data: workspacePatch,
175
167
  format: "plain",
176
168
  returning: "full",
177
- transaction: options?.trx || null
169
+ transaction: resolveTransaction(options)
178
170
  },
179
- createJsonRestContext(options?.context || null)
171
+ createJsonRestContext(options.context)
180
172
  );
181
173
 
182
174
  return normalizeWorkspaceRecord(updated);
@@ -198,10 +190,10 @@ function createRepository({ api } = {}) {
198
190
  },
199
191
  include: ["workspace"]
200
192
  },
201
- transaction: options?.trx || null,
193
+ transaction: resolveTransaction(options),
202
194
  format: "plain"
203
195
  },
204
- createJsonRestContext(options?.context || null)
196
+ createJsonRestContext(options.context)
205
197
  )
206
198
  );
207
199
 
@@ -1,18 +1,11 @@
1
- import { createHash } from "node:crypto";
1
+ import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
2
+ import { pickOwnProperties } from "@jskit-ai/kernel/shared/support";
2
3
  import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
3
- import {
4
- TENANCY_MODE_NONE,
5
- resolveTenancyProfile
6
- } from "../../../shared/tenancyProfile.js";
7
- import {
8
- resolveRolePermissions,
9
- createWorkspaceRoleCatalog
10
- } from "../../../shared/roles.js";
11
- import {
12
- mapWorkspaceSummary
13
- } from "../formatters/workspaceFormatter.js";
14
- import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
15
- import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
+ import { WORKSPACE_PATCH_FIELD_KEYS } from "../../../shared/resources/workspaceResource.js";
6
+ import { listCatalogRolePermissions } from "../../../shared/roles.js";
7
+ import { TENANCY_MODE_NONE } from "../../../shared/tenancyProfile.js";
8
+ import { mapWorkspaceSummary } from "../formatters/workspaceFormatter.js";
16
9
 
17
10
  function toSlugPart(value) {
18
11
  const normalized = normalizeLowerText(value)
@@ -22,7 +15,7 @@ function toSlugPart(value) {
22
15
  return normalized || "workspace";
23
16
  }
24
17
 
25
- function buildWorkspaceBaseSlug(user = {}) {
18
+ function buildWorkspaceBaseSlug(user) {
26
19
  const username = normalizeLowerText(user.username);
27
20
  if (username) {
28
21
  return toSlugPart(username);
@@ -38,7 +31,12 @@ function buildWorkspaceBaseSlug(user = {}) {
38
31
  return "workspace";
39
32
  }
40
33
 
41
- function buildWorkspaceName(user = {}) {
34
+ /**
35
+ * The `workspaces.personal-workspace-name` default: the user's display name or
36
+ * email, followed by "'s Workspace".
37
+ * @type {import("../../types.js").PersonalWorkspaceName}
38
+ */
39
+ function buildWorkspaceName(user) {
42
40
  const displayName = normalizeText(user.displayName);
43
41
  if (displayName) {
44
42
  return `${displayName}'s Workspace`;
@@ -50,42 +48,26 @@ function buildWorkspaceName(user = {}) {
50
48
  return "Workspace";
51
49
  }
52
50
 
53
- function buildPermissionsFromMembership(membership, appConfig = {}) {
54
- const roleSid = normalizeLowerText(membership?.roleSid || "member");
55
- return resolveRolePermissions(roleSid, appConfig);
56
- }
57
-
58
- function hashInviteToken(token) {
59
- return createHash("sha256").update(normalizeText(token)).digest("hex");
60
- }
61
-
62
- function normalizeWorkspaceCreationInput(payload = {}) {
63
- const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
64
- return {
65
- name: normalizeText(source.name),
66
- requestedSlug: normalizeLowerText(source.slug)
67
- };
68
- }
69
-
51
+ /**
52
+ * @param {object} dependencies
53
+ * @param {import("../../types.js").WorkspaceTenancyProfile} dependencies.tenancyProfile
54
+ * @param {import("../../types.js").WorkspaceRoleCatalog} dependencies.roleCatalog
55
+ * @param {import("../../types.js").PersonalWorkspaceName} dependencies.personalWorkspaceName
56
+ * @param {import("../../types.js").WorkspacesRepository} dependencies.workspacesRepository
57
+ * @param {import("../../types.js").WorkspaceMembershipsRepository} dependencies.workspaceMembershipsRepository
58
+ * @param {import("../../types.js").WorkspaceSettingsRepository} dependencies.workspaceSettingsRepository
59
+ */
70
60
  function createService({
71
- appConfig = {},
61
+ tenancyProfile,
62
+ roleCatalog,
63
+ personalWorkspaceName,
72
64
  workspacesRepository,
73
65
  workspaceMembershipsRepository,
74
66
  workspaceSettingsRepository
75
- } = {}) {
76
- if (
77
- !workspacesRepository ||
78
- !workspaceMembershipsRepository ||
79
- !workspaceSettingsRepository
80
- ) {
81
- throw new Error("workspaceService requires repositories.");
82
- }
67
+ }) {
68
+ const resolvedTenancyMode = tenancyProfile.mode;
69
+ const workspacePolicy = tenancyProfile.workspace;
83
70
 
84
- const resolvedTenancyProfile = resolveTenancyProfile(appConfig);
85
- const resolvedTenancyMode = resolvedTenancyProfile.mode;
86
- const workspacePolicy = resolvedTenancyProfile.workspace;
87
- const personalWorkspaceName =
88
- typeof appConfig.personalWorkspaceName === "function" ? appConfig.personalWorkspaceName : null;
89
71
  async function ensureUniqueWorkspaceSlug(baseSlug, options = {}) {
90
72
  let suffix = 0;
91
73
  while (suffix < 1000) {
@@ -96,11 +78,11 @@ function createService({
96
78
  return candidate;
97
79
  }
98
80
  }
99
- throw new AppError(500, "Unable to generate unique workspace slug.", { code: "workspaces.workspace.slugUnavailable" });
81
+ throw new Error("Unable to generate unique workspace slug.");
100
82
  }
101
83
 
102
84
  async function ensureWorkspaceSettingsForWorkspace(workspace, options = {}) {
103
- return workspaceSettingsRepository.ensureForWorkspaceId(workspace?.id, {
85
+ return workspaceSettingsRepository.ensureForWorkspaceId(workspace.id, {
104
86
  ...options,
105
87
  workspace
106
88
  });
@@ -123,7 +105,7 @@ function createService({
123
105
  const inserted = await workspacesRepository.insert(
124
106
  {
125
107
  slug,
126
- name: normalizeText(personalWorkspaceName?.(user)) || buildWorkspaceName(user),
108
+ name: normalizeText(personalWorkspaceName(user)) || buildWorkspaceName(user),
127
109
  ownerUserId: normalizedUserId,
128
110
  isPersonal: true,
129
111
  avatarUrl: ""
@@ -147,7 +129,6 @@ function createService({
147
129
  }
148
130
 
149
131
  const list = await workspacesRepository.listForUserId(normalizedUserId, options);
150
- const roleCatalog = createWorkspaceRoleCatalog(appConfig);
151
132
  const accessible = list
152
133
  .map((entry) => mapWorkspaceSummary(
153
134
  entry,
@@ -160,7 +141,7 @@ function createService({
160
141
  }
161
142
 
162
143
  async function listWorkspacesForAuthenticatedUser(user, options = {}) {
163
- return listWorkspacesForUser(user, options);
144
+ return returnJsonApiData({ items: await listWorkspacesForUser(user, options), nextCursor: null });
164
145
  }
165
146
 
166
147
  async function ensureProvisionedWorkspaceForAuthenticatedUser(user, options = {}) {
@@ -179,7 +160,14 @@ function createService({
179
160
  }, options);
180
161
  }
181
162
 
182
- async function createWorkspaceForAuthenticatedUser(user, payload = {}, options = {}) {
163
+ /**
164
+ * Creates a workspace the user owns. The action's input schema has already
165
+ * trimmed the name and slug and lowercased the slug.
166
+ * @param {{ id?: unknown } | null} user
167
+ * @param {{ name: string, slug?: string }} input
168
+ * @param {object} [options]
169
+ */
170
+ async function createWorkspaceForAuthenticatedUser(user, { name, slug: requestedSlug }, options = {}) {
183
171
  const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
184
172
  if (!normalizedUserId) {
185
173
  throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
@@ -191,17 +179,11 @@ function createService({
191
179
  });
192
180
  }
193
181
 
194
- const createInput = normalizeWorkspaceCreationInput(payload);
195
- if (!createInput.name) {
196
- throw new AppError(400, "Workspace name is required.", { code: "workspaces.workspace.nameRequired" });
197
- }
198
-
199
- const slugBase = createInput.requestedSlug || toSlugPart(createInput.name);
200
- const slug = await ensureUniqueWorkspaceSlug(slugBase, options);
182
+ const slug = await ensureUniqueWorkspaceSlug(requestedSlug || toSlugPart(name), options);
201
183
  const inserted = await workspacesRepository.insert(
202
184
  {
203
185
  slug,
204
- name: createInput.name,
186
+ name,
205
187
  ownerUserId: normalizedUserId,
206
188
  isPersonal: false,
207
189
  avatarUrl: ""
@@ -211,21 +193,23 @@ function createService({
211
193
 
212
194
  await workspaceMembershipsRepository.ensureOwnerMembership(inserted.id, normalizedUserId, options);
213
195
  await ensureWorkspaceSettingsForWorkspace(inserted, options);
214
- return inserted;
196
+ return returnJsonApiData(inserted);
215
197
  }
216
198
 
217
199
  async function getWorkspaceForAuthenticatedUser(user, workspaceSlug, options = {}) {
218
200
  const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
219
- return workspaceContext.workspace;
201
+ return returnJsonApiData(workspaceContext.workspace);
220
202
  }
221
203
 
222
- async function updateWorkspaceForAuthenticatedUser(user, workspaceSlug, patch = {}, options = {}) {
204
+ /** The repository writes every field it receives, so only the patchable fields go to it. */
205
+ async function updateWorkspaceForAuthenticatedUser(user, workspaceSlug, input, options = {}) {
223
206
  const workspaceContext = await resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options);
224
- return workspacesRepository.updateById(workspaceContext.workspace.id, patch, options);
207
+ const patch = pickOwnProperties(input, WORKSPACE_PATCH_FIELD_KEYS);
208
+ return returnJsonApiData(await workspacesRepository.updateById(workspaceContext.workspace.id, patch, options));
225
209
  }
226
210
 
227
211
  async function resolveWorkspaceContextForUserBySlug(user, workspaceSlug, options = {}) {
228
- const requireMembership = options?.requireMembership !== false;
212
+ const requireMembership = options.requireMembership !== false;
229
213
  const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
230
214
  if (!normalizedUserId) {
231
215
  throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
@@ -269,7 +253,7 @@ function createService({
269
253
  }
270
254
 
271
255
  const workspaceSettings = await ensureWorkspaceSettingsForWorkspace(workspace, options);
272
- const permissions = membership ? buildPermissionsFromMembership(membership, appConfig) : [];
256
+ const permissions = membership ? listCatalogRolePermissions(roleCatalog, membership.roleSid) : [];
273
257
 
274
258
  return {
275
259
  workspace,
@@ -280,10 +264,6 @@ function createService({
280
264
  }
281
265
 
282
266
  return Object.freeze({
283
- toSlugPart,
284
- buildWorkspaceName,
285
- buildWorkspaceBaseSlug,
286
- hashInviteToken,
287
267
  ensurePersonalWorkspaceForUser,
288
268
  ensureProvisionedWorkspaceForAuthenticatedUser,
289
269
  createWorkspaceForAuthenticatedUser,
@@ -295,4 +275,4 @@ function createService({
295
275
  });
296
276
  }
297
277
 
298
- export { createService };
278
+ export { buildWorkspaceName, createService };
@@ -1,18 +1,16 @@
1
1
  import { normalizeDbRecordId } from "@jskit-ai/database-runtime/shared";
2
+ import { jsonApiResultValue } from "@jskit-ai/http-runtime/shared";
2
3
  import { createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
4
+ import { resolveActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
3
5
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
4
6
  import { resolveWorkspace } from "../../support/resolveWorkspace.js";
5
7
 
6
- function resultValue({ result } = {}) {
7
- return result?.value ?? result ?? {};
8
- }
9
-
10
- function actorId({ context } = {}) {
11
- return context?.actor?.id;
8
+ function actorId({ context }) {
9
+ return resolveActorId(context);
12
10
  }
13
11
 
14
12
  function workspaceId(execution = {}) {
15
- return resolveWorkspace(execution.context, execution.input)?.id || resultValue(execution)?.workspaceId;
13
+ return resolveWorkspace(execution.context, execution.input)?.id || jsonApiResultValue(execution.result)?.workspaceId;
16
14
  }
17
15
 
18
16
  function workspaceSlugPayload(execution = {}) {
@@ -25,8 +23,8 @@ function workspaceSlugPayload(execution = {}) {
25
23
 
26
24
  const INVITE_RECIPIENT_BOOTSTRAP_AUDIENCE = Object.freeze({
27
25
  preset: "event_scope",
28
- async userQuery({ knex, event } = {}) {
29
- if (typeof knex !== "function") return [];
26
+ /** @param {{ knex: import("@jskit-ai/database-runtime/server/types").RuntimeKnex, event: { entityId?: unknown } }} input */
27
+ async userQuery({ knex, event }) {
30
28
  const inviteId = normalizeRecordId(event?.entityId, { fallback: null });
31
29
  if (!inviteId) return [];
32
30
  const row = await knex("workspace_invites as wi")
@@ -38,13 +36,21 @@ const INVITE_RECIPIENT_BOOTSTRAP_AUDIENCE = Object.freeze({
38
36
  }
39
37
  });
40
38
 
39
+ /**
40
+ * @param {object} event
41
+ * @param {string} event.workspaceEntity
42
+ * @param {string} event.workspaceOperation
43
+ * @param {string} event.workspaceRealtimeEvent
44
+ * @param {typeof workspaceId} [event.bootstrapEntityId]
45
+ * @param {string} [event.bootstrapAudience]
46
+ */
41
47
  function createWorkspaceEntityAndBootstrapEvents({
42
48
  workspaceEntity,
43
49
  workspaceOperation,
44
50
  workspaceRealtimeEvent,
45
51
  bootstrapEntityId = workspaceId,
46
52
  bootstrapAudience = "event_scope"
47
- } = {}) {
53
+ }) {
48
54
  return Object.freeze([
49
55
  createEntityChangedActionEvent({
50
56
  source: "workspace",
@@ -70,6 +76,7 @@ function createWorkspaceEntityAndBootstrapEvents({
70
76
  ]);
71
77
  }
72
78
 
79
+ /** @param {{ source: string, entity: string, realtimeEvent: string }} event */
73
80
  function createActorEvent({ source, entity, realtimeEvent }) {
74
81
  return createEntityChangedActionEvent({
75
82
  source,
@@ -80,15 +87,16 @@ function createActorEvent({ source, entity, realtimeEvent }) {
80
87
  });
81
88
  }
82
89
 
90
+ /** @param {{ entity: string, realtimeEvent: string }} event */
83
91
  function createWorkspaceAudienceEvent({ entity, realtimeEvent }) {
84
92
  return createEntityChangedActionEvent({
85
93
  source: "workspace",
86
94
  entity,
87
95
  operation: "updated",
88
- entityId: ({ result }) => result?.value?.workspaceId ?? result?.workspaceId,
96
+ entityId: ({ result }) => jsonApiResultValue(result).workspaceId,
89
97
  realtime: {
90
98
  event: realtimeEvent,
91
- audience: ({ result }) => ({ workspaceId: result?.value?.workspaceId ?? result?.workspaceId })
99
+ audience: ({ result }) => ({ workspaceId: jsonApiResultValue(result).workspaceId })
92
100
  }
93
101
  });
94
102
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The JSON:API record id of a workspace response: the workspace's id, or the
3
+ * route's workspace slug.
4
+ * @param {{ id?: unknown, workspace?: { id?: unknown } }} record
5
+ * @param {{ request?: { params?: { workspaceSlug?: unknown } } }} context
6
+ */
7
+ function resolveWorkspaceRecordId(record, context = {}) {
8
+ const workspaceId = record?.workspace?.id ?? record?.id;
9
+ if (workspaceId != null && String(workspaceId).trim()) {
10
+ return workspaceId;
11
+ }
12
+
13
+ const workspaceSlug = context.request?.params?.workspaceSlug;
14
+ if (workspaceSlug != null && String(workspaceSlug).trim()) {
15
+ return workspaceSlug;
16
+ }
17
+
18
+ throw new Error("Workspace JSON:API response requires workspace id.");
19
+ }
20
+
21
+ export { resolveWorkspaceRecordId };
@@ -37,9 +37,10 @@ const routeParamsValidator = deepFreeze({
37
37
  mode: "patch"
38
38
  });
39
39
 
40
+ // No mode: an action takes it as create, a route or a composition as patch; with
41
+ // no required field and no default, both validate it the same.
40
42
  const workspaceSlugParamsValidator = deepFreeze({
41
- schema: workspaceSlugParamsSchema,
42
- mode: "patch"
43
+ schema: workspaceSlugParamsSchema
43
44
  });
44
45
 
45
46
  export { routeParamsValidator, workspaceSlugParamsValidator };
@@ -1,8 +1,11 @@
1
1
  import { createServerI18n } from "@jskit-ai/kernel/server/i18n";
2
2
  import en from "../../locales/en.json" with { type: "json" };
3
3
 
4
- // Workspace texts rendered on the server (invitation emails) in the recipient's
5
- // language, from the app's translations under appRoot, or English.
4
+ /**
5
+ * Workspace texts rendered on the server (invitation emails) in the recipient's
6
+ * language, from the app's translations under appRoot, or English.
7
+ * @param {{ appRoot?: import("@jskit-ai/kernel/server/types").RuntimeAppRoot }} [options]
8
+ */
6
9
  function createWorkspacesServerI18n({ appRoot = "" } = {}) {
7
10
  return createServerI18n({ appRoot, catalogs: [en] });
8
11
  }
@@ -1,16 +1,19 @@
1
+ import { resolveRequest } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
1
2
  import { normalizeObject } from "@jskit-ai/kernel/shared/support/normalize";
2
3
 
3
- function resolveRequest(context = {}) {
4
- const requestMeta = normalizeObject(context?.requestMeta);
5
- return normalizeObject(requestMeta.request);
6
- }
7
-
4
+ /**
5
+ * The workspace an action runs in: the input's workspace, else the context's,
6
+ * else the one the workspace context contributor resolved, else the request's.
7
+ * This is json-rest-api-core's resolveWorkspaceScopeValue order.
8
+ * @param {object} context
9
+ * @param {unknown} [input]
10
+ */
8
11
  function resolveWorkspace(context = {}, input = {}) {
9
12
  const payload = normalizeObject(input);
10
- const requestMeta = normalizeObject(context?.requestMeta);
13
+ const requestMeta = normalizeObject(context.requestMeta);
11
14
  const resolvedWorkspaceContext = normalizeObject(requestMeta.resolvedWorkspaceContext);
12
15
 
13
- return payload.workspace || resolvedWorkspaceContext.workspace || context?.workspace || resolveRequest(context)?.workspace || null;
16
+ return payload.workspace || context.workspace || resolvedWorkspaceContext.workspace || resolveRequest(context)?.workspace || null;
14
17
  }
15
18
 
16
19
  export { resolveWorkspace };