@jskit-ai/workspaces-core 0.2.4 → 0.2.6

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,11 +1,14 @@
1
- import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
2
- import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
1
+ import { joinOrOpenTransaction } from "@jskit-ai/database-runtime/shared";
2
+ import { normalizeLowerText, normalizeRecordId, 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
+ import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
5
6
  import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
6
- import { OWNER_ROLE_ID, createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
7
- import { createWorkspacesServerI18n } from "../i18n.js";
8
- import { renderDefaultWorkspaceInviteEmail } from "./defaultWorkspaceInviteEmail.js";
7
+ import { OWNER_ROLE_ID, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
8
+ import {
9
+ buildMailerUnconfiguredDelivery,
10
+ unconfiguredWorkspaceInviteMailer
11
+ } from "./unconfiguredWorkspaceInviteMailer.js";
9
12
 
10
13
  const DEFAULT_LOCALE = "en";
11
14
 
@@ -15,60 +18,65 @@ function roleNotAssignableError() {
15
18
  });
16
19
  }
17
20
 
21
+ function memberUserIdRequiredError() {
22
+ return createValidationError({
23
+ memberUserId: { code: "workspaces.member.userIdRequired", message: "Member user id is required." }
24
+ });
25
+ }
26
+
27
+ function inviteIdRequiredError() {
28
+ return createValidationError({
29
+ inviteId: { code: "workspaces.invite.idRequired", message: "Invite id is required." }
30
+ });
31
+ }
32
+
33
+ /**
34
+ * @param {object} dependencies
35
+ * @param {import("../types.js").WorkspaceMembershipsRepository} dependencies.workspaceMembershipsRepository
36
+ * @param {import("../types.js").WorkspaceInvitesRepository} dependencies.workspaceInvitesRepository
37
+ * @param {number} dependencies.inviteExpiresInMs
38
+ * @param {import("../types.js").WorkspaceRoleCatalog} dependencies.roleCatalog
39
+ * @param {boolean} dependencies.workspaceInvitationsEnabled
40
+ * @param {import("../types.js").WorkspaceInviteUrlBuilder} dependencies.inviteUrlBuilder
41
+ * @param {import("../types.js").WorkspaceInviteMailer} dependencies.workspaceInviteMailer
42
+ * The provided `workspaces.invite-mailer`, or unconfiguredWorkspaceInviteMailer when no feature provides one.
43
+ * @param {import("../types.js").WorkspaceInviteEmailTemplate} dependencies.workspaceInviteEmailTemplate
44
+ * @param {import("../types.js").WorkspacesServerI18n} dependencies.i18n
45
+ * @param {import("@jskit-ai/users-core/server/types").UserProfilesRepository} dependencies.userProfilesRepository
46
+ * @param {import("@jskit-ai/users-core/server/types").UserSettingsRepository} dependencies.userSettingsRepository
47
+ */
18
48
  function createService({
19
49
  workspaceMembershipsRepository,
20
50
  workspaceInvitesRepository,
21
51
  inviteExpiresInMs,
22
- roleCatalog = null,
23
- workspaceInvitationsEnabled = true,
24
- inviteUrlBuilder = null,
25
- workspaceInviteMailer = null,
26
- workspaceInviteEmailTemplate = renderDefaultWorkspaceInviteEmail,
27
- i18n = createWorkspacesServerI18n(),
28
- userProfilesRepository = null,
29
- userSettingsRepository = null
30
- } = {}) {
31
- if (!workspaceMembershipsRepository || !workspaceInvitesRepository) {
32
- throw new Error("workspaceMembersService requires membership and invite repositories.");
33
- }
34
- const resolvedInviteExpiresInMs = Number(inviteExpiresInMs);
35
- if (!Number.isInteger(resolvedInviteExpiresInMs) || resolvedInviteExpiresInMs < 1) {
36
- throw new Error("workspaceMembersService requires inviteExpiresInMs.");
37
- }
38
-
39
- const resolvedRoleCatalog = roleCatalog && typeof roleCatalog === "object" ? roleCatalog : createWorkspaceRoleCatalog();
40
- const assignableRoleIds = Array.isArray(resolvedRoleCatalog.assignableRoleIds)
41
- ? [...resolvedRoleCatalog.assignableRoleIds]
42
- : [];
43
- const resolvedWorkspaceInvitationsEnabled = workspaceInvitationsEnabled === true;
44
- const resolvedInviteUrlBuilder = typeof inviteUrlBuilder === "function"
45
- ? inviteUrlBuilder
46
- : ({ token }) => `/invite/${encodeURIComponent(String(token || "").trim())}`;
47
- const resolvedWorkspaceInviteMailer =
48
- workspaceInviteMailer && typeof workspaceInviteMailer === "object" ? workspaceInviteMailer : null;
49
- const resolvedWorkspaceInviteEmailTemplate =
50
- typeof workspaceInviteEmailTemplate === "function"
51
- ? workspaceInviteEmailTemplate
52
- : renderDefaultWorkspaceInviteEmail;
52
+ roleCatalog,
53
+ workspaceInvitationsEnabled,
54
+ inviteUrlBuilder,
55
+ workspaceInviteMailer,
56
+ workspaceInviteEmailTemplate,
57
+ i18n,
58
+ userProfilesRepository,
59
+ userSettingsRepository
60
+ }) {
61
+ const { assignableRoleIds } = roleCatalog;
62
+ const inviteMailerProvided = workspaceInviteMailer !== unconfiguredWorkspaceInviteMailer;
53
63
 
54
64
  function ensureWorkspaceInvitationsEnabled() {
55
- if (resolvedWorkspaceInvitationsEnabled) {
65
+ if (workspaceInvitationsEnabled) {
56
66
  return;
57
67
  }
58
68
  throw new AppError(403, "Workspace invitations are disabled.", { code: "workspaces.invite.disabled" });
59
69
  }
60
70
 
61
- function withRoleCatalog(payload = {}) {
71
+ function withRoleCatalog(payload) {
62
72
  return {
63
73
  ...payload,
64
- roleCatalog: cloneWorkspaceRoleCatalog({
65
- ...resolvedRoleCatalog,
66
- assignableRoleIds
67
- })
74
+ roleCatalog: cloneWorkspaceRoleCatalog(roleCatalog)
68
75
  };
69
76
  }
70
77
 
71
- function mapWorkspaceSummary(workspace = {}) {
78
+ // The workspace as the members, invites and invitation email show it.
79
+ function mapMembersWorkspace(workspace) {
72
80
  return {
73
81
  id: normalizeRecordId(workspace.id, { fallback: "" }),
74
82
  slug: normalizeText(workspace.slug),
@@ -78,7 +86,7 @@ function createService({
78
86
  };
79
87
  }
80
88
 
81
- function mapMemberSummary(member = {}, workspace = {}) {
89
+ function mapMemberSummary(member, workspace) {
82
90
  const userId = normalizeRecordId(member.userId, { fallback: "" });
83
91
  const roleSid = normalizeLowerText(member.roleSid || "member") || "member";
84
92
 
@@ -92,7 +100,7 @@ function createService({
92
100
  };
93
101
  }
94
102
 
95
- function mapInviteSummary(invite = {}) {
103
+ function mapInviteSummary(invite) {
96
104
  return {
97
105
  id: normalizeRecordId(invite.id, { fallback: "" }),
98
106
  email: normalizeLowerText(invite.email),
@@ -103,11 +111,19 @@ function createService({
103
111
  };
104
112
  }
105
113
 
106
- function normalizeInviteDeliveryResult(value = {}) {
114
+ /**
115
+ * A mailer's delivery outcome; the status defaults to "sent".
116
+ * @param {unknown} value
117
+ */
118
+ function normalizeInviteDeliveryResult(value) {
119
+ /** @type {{ status?: string, message?: string, messageCode?: string, messageParams?: object, providerMessageId?: string }} */
107
120
  const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
108
121
  return {
109
122
  status: normalizeLowerText(source.status || "sent") || "sent",
110
123
  message: normalizeText(source.message),
124
+ ...(source.messageCode
125
+ ? { messageCode: normalizeText(source.messageCode), messageParams: { ...source.messageParams } }
126
+ : {}),
111
127
  providerMessageId: normalizeText(source.providerMessageId)
112
128
  };
113
129
  }
@@ -115,10 +131,6 @@ function createService({
115
131
  // The saved locale of an existing account with the invited email, else English.
116
132
  // A failed lookup must not stop the invitation, so it also falls back to English.
117
133
  async function resolveRecipientLocale(email, options = {}) {
118
- if (typeof userProfilesRepository?.findByEmail !== "function" ||
119
- typeof userSettingsRepository?.findByUserId !== "function") {
120
- return DEFAULT_LOCALE;
121
- }
122
134
  try {
123
135
  const profile = await userProfilesRepository.findByEmail(email, options);
124
136
  const settings = profile?.id ? await userSettingsRepository.findByUserId(profile.id, options) : null;
@@ -128,24 +140,19 @@ function createService({
128
140
  }
129
141
  }
130
142
 
131
- async function deliverInviteEmail({ workspace, user, invite, inviteUrl } = {}, options = {}) {
132
- if (!resolvedWorkspaceInviteMailer || typeof resolvedWorkspaceInviteMailer.sendWorkspaceInvite !== "function") {
133
- return {
134
- status: "mailer_unconfigured",
135
- message: "No workspace invite mailer is configured.",
136
- messageCode: "workspaces.invite.mailerUnconfigured",
137
- messageParams: {},
138
- providerMessageId: ""
139
- };
143
+ async function deliverInviteEmail({ workspace, user, invite, inviteUrl }, options) {
144
+ // With no mailer provided there is nothing to send, so the recipient's
145
+ // locale is not looked up and no email is rendered.
146
+ if (!inviteMailerProvided) {
147
+ return buildMailerUnconfiguredDelivery();
140
148
  }
141
-
142
149
  try {
143
- const roleLabel = resolvedRoleCatalog.roles?.find((role) => role.id === invite.roleSid)?.label;
150
+ const roleLabel = roleCatalog.roles.find((role) => role.id === invite.roleSid)?.label;
144
151
  const locale = await resolveRecipientLocale(invite.email, options);
145
- const message = await resolvedWorkspaceInviteEmailTemplate({
152
+ const message = await workspaceInviteEmailTemplate({
146
153
  email: invite.email,
147
154
  inviteUrl,
148
- workspace: mapWorkspaceSummary(workspace),
155
+ workspace: mapMembersWorkspace(workspace),
149
156
  inviter: user || null,
150
157
  roleSid: invite.roleSid,
151
158
  ...(roleLabel ? { roleLabel } : {}),
@@ -153,17 +160,17 @@ function createService({
153
160
  locale,
154
161
  t: (key, params) => i18n.t(locale, key, params)
155
162
  });
156
- const result = await resolvedWorkspaceInviteMailer.sendWorkspaceInvite({
163
+ const result = await workspaceInviteMailer.sendWorkspaceInvite({
157
164
  email: invite.email,
158
165
  inviteUrl,
159
- workspace: mapWorkspaceSummary(workspace),
166
+ workspace: mapMembersWorkspace(workspace),
160
167
  inviter: user || null,
161
168
  roleSid: invite.roleSid,
162
169
  expiresAt: invite.expiresAt,
163
170
  locale,
164
171
  message
165
172
  }, options);
166
- return normalizeInviteDeliveryResult(result || { status: "sent" });
173
+ return normalizeInviteDeliveryResult(result);
167
174
  } catch {
168
175
  return {
169
176
  status: "failed",
@@ -176,31 +183,34 @@ function createService({
176
183
  }
177
184
  }
178
185
 
179
- async function listRoles(options = {}) {
180
- return cloneWorkspaceRoleCatalog({
181
- ...resolvedRoleCatalog,
182
- assignableRoleIds
183
- });
186
+ // Applications read the role catalog and member list as they are; the
187
+ // *Response methods answer with them as JSON:API data, for the actions.
188
+ async function listRoles() {
189
+ return cloneWorkspaceRoleCatalog(roleCatalog);
184
190
  }
185
191
 
186
- async function listMembersPayload(workspace, options = {}) {
192
+ async function listRolesResponse() {
193
+ return returnJsonApiData(await listRoles());
194
+ }
195
+
196
+ async function listMembers(workspace, options = {}) {
187
197
  const members = await workspaceMembershipsRepository.listActiveByWorkspaceId(workspace.id, options);
188
198
 
189
199
  return withRoleCatalog({
190
- workspace: mapWorkspaceSummary(workspace),
200
+ workspace: mapMembersWorkspace(workspace),
191
201
  members: members.map((member) => mapMemberSummary(member, workspace))
192
202
  });
193
203
  }
194
204
 
195
- async function listMembers(workspace, options = {}) {
196
- return listMembersPayload(workspace, options);
205
+ async function listMembersResponse(workspace, options = {}) {
206
+ return returnJsonApiData(await listMembers(workspace, options));
197
207
  }
198
208
 
199
- async function updateMemberRole(workspace, payload = {}, options = {}) {
209
+ async function updateMemberRole(workspace, payload, options = {}) {
200
210
  const memberUserId = normalizeRecordId(payload.memberUserId, { fallback: null });
201
211
  const roleSid = normalizeLowerText(payload.roleSid || "");
202
212
  if (!memberUserId) {
203
- throw new AppError(400, "Validation failed.", { code: "validation_failed" });
213
+ throw memberUserIdRequiredError();
204
214
  }
205
215
  if (!assignableRoleIds.includes(roleSid)) {
206
216
  throw roleNotAssignableError();
@@ -224,13 +234,13 @@ function createService({
224
234
  options
225
235
  );
226
236
 
227
- return listMembersPayload(workspace, options);
237
+ return returnJsonApiData(await listMembers(workspace, options));
228
238
  }
229
239
 
230
- async function removeMember(workspace, payload = {}, options = {}) {
240
+ async function removeMember(workspace, payload, options = {}) {
231
241
  const memberUserId = normalizeRecordId(payload.memberUserId, { fallback: null });
232
242
  if (!memberUserId) {
233
- throw new AppError(400, "Validation failed.", { code: "validation_failed" });
243
+ throw memberUserIdRequiredError();
234
244
  }
235
245
 
236
246
  const existingMembership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(workspace.id, memberUserId, options);
@@ -251,7 +261,7 @@ function createService({
251
261
  options
252
262
  );
253
263
 
254
- return listMembersPayload(workspace, options);
264
+ return returnJsonApiData(await listMembers(workspace, options));
255
265
  }
256
266
 
257
267
  async function listInvitesPayload(workspace, options = {}) {
@@ -259,17 +269,18 @@ function createService({
259
269
  const invites = await workspaceInvitesRepository.listPendingByWorkspaceIdWithWorkspace(workspace.id, options);
260
270
 
261
271
  return withRoleCatalog({
262
- workspace: mapWorkspaceSummary(workspace),
272
+ workspace: mapMembersWorkspace(workspace),
263
273
  invites: invites.map((invite) => mapInviteSummary(invite))
264
274
  });
265
275
  }
266
276
 
267
- async function listInvites(workspace, options = {}) {
268
- return listInvitesPayload(workspace, options);
277
+ async function listInvitesResponse(workspace, options = {}) {
278
+ return returnJsonApiData(await listInvitesPayload(workspace, options));
269
279
  }
270
280
 
271
- async function prepareInvite(workspace, user, payload = {}, options = {}) {
281
+ async function prepareInvite(workspace, user, payload, options = {}) {
272
282
  ensureWorkspaceInvitationsEnabled();
283
+ // readable-check: allow required-collaborators - platform object: a Knex transaction, which prepareInvite writes in
273
284
  if (!options.trx) {
274
285
  throw new TypeError("prepareInvite requires a managed transaction.");
275
286
  }
@@ -281,7 +292,7 @@ function createService({
281
292
 
282
293
  const token = buildInviteToken();
283
294
  const tokenHash = hashInviteToken(token);
284
- const expiresAt = new Date(Date.now() + resolvedInviteExpiresInMs).toISOString();
295
+ const expiresAt = new Date(Date.now() + inviteExpiresInMs).toISOString();
285
296
  await workspaceInvitesRepository.lockWorkspaceForInvitations(workspace.id, options);
286
297
  await workspaceInvitesRepository.expirePendingByWorkspaceIdAndEmail(workspace.id, email, options);
287
298
  const createdInvite = await workspaceInvitesRepository.insert(
@@ -304,8 +315,9 @@ function createService({
304
315
  return { createdInviteId, inviteTokenPreview: token };
305
316
  }
306
317
 
307
- async function sendInvite(workspace, user, prepared = {}, options = {}) {
318
+ async function sendInvite(workspace, user, prepared, options = {}) {
308
319
  ensureWorkspaceInvitationsEnabled();
320
+ // readable-check: allow required-collaborators - platform object: a Knex transaction, which sendInvite must run after
309
321
  if (options.trx) {
310
322
  throw new TypeError("sendInvite must run after the invitation transaction commits.");
311
323
  }
@@ -335,7 +347,7 @@ function createService({
335
347
  name: invite.workspaceName,
336
348
  avatarUrl: invite.workspaceAvatarUrl
337
349
  };
338
- const inviteUrl = resolvedInviteUrlBuilder({ token, invite, workspace: savedWorkspace });
350
+ const inviteUrl = inviteUrlBuilder({ token, invite, workspace: savedWorkspace });
339
351
  const response = await listInvitesPayload(savedWorkspace, options);
340
352
  const inviteDelivery = await deliverInviteEmail({ workspace: savedWorkspace, user, invite, inviteUrl }, options);
341
353
  return {
@@ -347,22 +359,23 @@ function createService({
347
359
  };
348
360
  }
349
361
 
350
- async function createInvite(workspace, user, payload = {}, options = {}) {
362
+ async function createInvite(workspace, user, payload, options = {}) {
351
363
  ensureWorkspaceInvitationsEnabled();
364
+ // readable-check: allow required-collaborators - platform object: a Knex transaction, which createInvite opens itself
352
365
  if (options.trx) {
353
366
  throw new TypeError("Use prepareInvite inside a transaction, then sendInvite after commit.");
354
367
  }
355
368
  const prepared = await workspaceInvitesRepository.withTransaction((trx) =>
356
369
  prepareInvite(workspace, user, payload, { ...options, trx })
357
370
  );
358
- return sendInvite(workspace, user, prepared, options);
371
+ return returnJsonApiData(await sendInvite(workspace, user, prepared, options));
359
372
  }
360
373
 
361
374
  async function revokeInvite(workspace, inviteId, options = {}) {
362
375
  ensureWorkspaceInvitationsEnabled();
363
376
  const normalizedInviteId = normalizeRecordId(inviteId, { fallback: null });
364
377
  if (!normalizedInviteId) {
365
- throw new AppError(400, "Validation failed.", { code: "validation_failed" });
378
+ throw inviteIdRequiredError();
366
379
  }
367
380
 
368
381
  const revoke = async (trx) => {
@@ -378,23 +391,23 @@ function createService({
378
391
  await workspaceInvitesRepository.revokeById(normalizedInviteId, transactionOptions);
379
392
  return normalizeRecordId(invite.id, { fallback: null });
380
393
  };
381
- const revokedInviteId = options.trx
382
- ? await revoke(options.trx)
383
- : await workspaceInvitesRepository.withTransaction(revoke);
394
+ const revokedInviteId = await joinOrOpenTransaction(options, workspaceInvitesRepository.withTransaction, revoke);
384
395
 
385
396
  const response = await listInvitesPayload(workspace, options);
386
- return {
397
+ return returnJsonApiData({
387
398
  ...response,
388
399
  revokedInviteId
389
- };
400
+ });
390
401
  }
391
402
 
392
403
  return Object.freeze({
393
404
  listRoles,
405
+ listRolesResponse,
394
406
  listMembers,
407
+ listMembersResponse,
395
408
  updateMemberRole,
396
409
  removeMember,
397
- listInvites,
410
+ listInvitesResponse,
398
411
  createInvite,
399
412
  prepareInvite,
400
413
  sendInvite,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The `workspaces.invite-acceptance-participant` default when no feature
3
+ * provides one: accepting an invitation activates the membership only.
4
+ * @type {import("../types.js").WorkspaceInviteAcceptanceParticipant}
5
+ */
6
+ async function noInviteAcceptanceParticipant() {}
7
+
8
+ export { noInviteAcceptanceParticipant };
@@ -2,123 +2,90 @@ import { pendingInvitationsAssistantOutput } from "../../shared/resources/worksp
2
2
  import {
3
3
  emptyInputValidator
4
4
  } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
5
- import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
6
5
  import { workspaceMembersResource } from "../../shared/resources/workspaceMembersResource.js";
7
6
  import { workspacePendingInvitationsResource } from "../../shared/resources/workspacePendingInvitationsResource.js";
8
7
  import { createInviteDecisionEvents } from "../common/support/realtimeServiceEvents.js";
9
- import { resolveActionUser } from "../common/support/resolveActionUser.js";
8
+ import { resolveActionUser } from "@jskit-ai/users-core/server/resolveActionUser";
10
9
 
11
10
  const WORKSPACE_INVITE_DECISION_EVENTS = createInviteDecisionEvents();
12
11
 
13
- const workspacePendingInvitationsActionSpecifications = Object.freeze([
14
- {
15
- id: "workspace.invitation.resolve",
16
- version: 1,
17
- kind: "query",
18
- channels: ["api", "automation", "internal"],
19
- surfaces: ["*"],
20
- permission: {
21
- require: "none"
22
- },
23
- input: workspacePendingInvitationsResource.operations.resolve.query,
24
- output: null,
25
- extensions: {
26
- assistant: {
27
- exclude: "Use the invitation screen; raw invitation tokens must not enter assistant arguments."
28
- }
29
- },
30
- idempotency: "none",
31
- async run(workspacePendingInvitationsService, input, context) {
32
- return returnJsonApiData(
33
- await workspacePendingInvitationsService.resolveInviteByToken(input?.token, {
34
- context
35
- })
36
- );
37
- }
38
- },
39
- {
40
- id: "workspace.invitations.pending.list",
41
- version: 1,
42
- kind: "query",
43
- channels: ["api", "automation", "internal"],
44
- surfaces: ["*"],
45
- permission: {
46
- require: "authenticated"
47
- },
48
- input: emptyInputValidator,
49
- output: null,
50
- extensions: {
51
- assistant: {
52
- description: "List pending workspace invitations for the signed-in user without invitation tokens.",
53
- output: pendingInvitationsAssistantOutput,
54
- transformResult: (result) => ({
55
- pendingInvites: result.value.pendingInvites.map(
56
- ({ id, workspaceId, workspaceSlug, workspaceName, roleSid, status, expiresAt }) =>
57
- ({ id, workspaceId, workspaceSlug, workspaceName, roleSid, status, expiresAt })
58
- )
59
- })
60
- }
61
- },
62
- idempotency: "none",
63
- async run(workspacePendingInvitationsService, input, context) {
64
- return returnJsonApiData({
65
- pendingInvites: await workspacePendingInvitationsService.listPendingInvitesForUser(resolveActionUser(context, input), {
66
- context
67
- })
68
- });
69
- }
70
- },
71
- {
72
- id: "workspace.invite.redeem",
73
- version: 1,
74
- kind: "command",
75
- channels: ["api", "automation", "internal"],
76
- surfaces: ["*"],
77
- permission: {
78
- require: "authenticated"
79
- },
80
- input: workspaceMembersResource.operations.redeemInvite.body,
81
- output: null,
82
- extensions: {
83
- assistant: {
84
- exclude: "Use the authenticated invitation screen to accept or refuse; raw invitation tokens must not enter assistant arguments."
85
- }
86
- },
87
- idempotency: "optional",
88
- events: WORKSPACE_INVITE_DECISION_EVENTS,
89
- async run(workspacePendingInvitationsService, input, context) {
90
- const payload = input || {};
91
- const user = resolveActionUser(context);
92
-
93
- if (payload.decision === "accept") {
94
- return returnJsonApiData(await workspacePendingInvitationsService.acceptInviteByToken({
95
- user,
96
- token: payload.token
97
- }, {
98
- context
99
- }));
100
- }
101
-
102
- return returnJsonApiData(await workspacePendingInvitationsService.refuseInviteByToken({
103
- user,
104
- token: payload.token
105
- }, {
106
- context
107
- }));
108
- }
109
- }
110
- ]);
111
-
112
- function buildWorkspacePendingInvitationsActions({ workspacePendingInvitationsService } = {}) {
113
- if (!workspacePendingInvitationsService) {
114
- throw new TypeError("buildWorkspacePendingInvitationsActions requires workspacePendingInvitationsService.");
115
- }
116
- return workspacePendingInvitationsActionSpecifications.map(({ run, ...definition }) => Object.freeze({
117
- ...definition,
118
- execute(input, context) {
119
- return run(workspacePendingInvitationsService, input, context);
120
- }
121
- }));
12
+ /** @param {{ workspacePendingInvitationsService: import("../types.js").WorkspacePendingInvitationsService }} dependencies */
13
+ function createWorkspacePendingInvitationsActions({ workspacePendingInvitationsService }) {
14
+ return Object.freeze([
15
+ Object.freeze({
16
+ id: "workspace.invitation.resolve",
17
+ version: 1,
18
+ kind: "query",
19
+ channels: ["api", "automation", "internal"],
20
+ surfaces: ["*"],
21
+ permission: {
22
+ require: "none"
23
+ },
24
+ input: { schema: workspacePendingInvitationsResource.operations.resolve.query.schema },
25
+ output: null,
26
+ extensions: {
27
+ assistant: {
28
+ exclude: "Use the invitation screen; raw invitation tokens must not enter assistant arguments."
29
+ }
30
+ },
31
+ execute: (input, context) => workspacePendingInvitationsService.resolveInviteByToken(
32
+ input.token,
33
+ { context }
34
+ )
35
+ }),
36
+ Object.freeze({
37
+ id: "workspace.invitations.pending.list",
38
+ version: 1,
39
+ kind: "query",
40
+ channels: ["api", "automation", "internal"],
41
+ surfaces: ["*"],
42
+ permission: {
43
+ require: "authenticated"
44
+ },
45
+ input: emptyInputValidator,
46
+ output: null,
47
+ extensions: {
48
+ assistant: {
49
+ description: "List pending workspace invitations for the signed-in user without invitation tokens.",
50
+ output: pendingInvitationsAssistantOutput,
51
+ transformResult: (result) => ({
52
+ pendingInvites: result.value.pendingInvites.map(
53
+ ({ id, workspaceId, workspaceSlug, workspaceName, roleSid, status, expiresAt }) =>
54
+ ({ id, workspaceId, workspaceSlug, workspaceName, roleSid, status, expiresAt })
55
+ )
56
+ })
57
+ }
58
+ },
59
+ execute: (input, context) => workspacePendingInvitationsService.listPendingInvitesResponse(
60
+ resolveActionUser(context, input),
61
+ { context }
62
+ )
63
+ }),
64
+ Object.freeze({
65
+ id: "workspace.invite.redeem",
66
+ version: 1,
67
+ kind: "command",
68
+ channels: ["api", "automation", "internal"],
69
+ surfaces: ["*"],
70
+ permission: {
71
+ require: "authenticated"
72
+ },
73
+ input: workspaceMembersResource.operations.redeemInvite.body,
74
+ output: null,
75
+ extensions: {
76
+ assistant: {
77
+ exclude: "Use the authenticated invitation screen to accept or refuse; raw invitation tokens must not enter assistant arguments."
78
+ }
79
+ },
80
+ events: WORKSPACE_INVITE_DECISION_EVENTS,
81
+ // The signed-in actor decides; a user named in the input never stands in for them.
82
+ execute: (input, context) => workspacePendingInvitationsService.redeemInviteByToken(
83
+ resolveActionUser(context),
84
+ input,
85
+ { context }
86
+ )
87
+ })
88
+ ]);
122
89
  }
123
90
 
124
- export { workspacePendingInvitationsActionSpecifications, buildWorkspacePendingInvitationsActions };
91
+ export { createWorkspacePendingInvitationsActions };
@@ -1,4 +1,5 @@
1
1
  import { createJsonApiResourceRouteContract } from "@jskit-ai/http-runtime/shared/validators/jsonApiRouteTransport";
2
+ import { resolveActorId } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
2
3
  import { workspaceMembersResource } from "../../shared/resources/workspaceMembersResource.js";
3
4
  import { workspacePendingInvitationsResource } from "../../shared/resources/workspacePendingInvitationsResource.js";
4
5
  import {
@@ -7,16 +8,21 @@ import {
7
8
  WORKSPACE_PENDING_INVITATIONS_TRANSPORT
8
9
  } from "../../shared/jsonApiTransports.js";
9
10
 
10
- function resolveAuthenticatedUserRecordId(_record, context = {}) {
11
- const userId = context?.request?.user?.id;
12
- if (userId != null && String(userId).trim()) {
11
+ /**
12
+ * The JSON:API record id of a signed-in user's invitations: the user's id.
13
+ * @param {unknown} _record
14
+ * @param {{ request: object }} context The route transport's context.
15
+ */
16
+ function resolveAuthenticatedUserRecordId(_record, context) {
17
+ const userId = resolveActorId(null, context.request);
18
+ if (userId) {
13
19
  return userId;
14
20
  }
15
21
 
16
22
  throw new Error("JSON:API response requires request.user.id.");
17
23
  }
18
24
 
19
- function resolveInviteResolutionRecordId(record = {}) {
25
+ function resolveInviteResolutionRecordId(record) {
20
26
  const recordId = record?.id || record?.token;
21
27
  if (recordId != null && String(recordId).trim()) {
22
28
  return recordId;
@@ -24,11 +30,8 @@ function resolveInviteResolutionRecordId(record = {}) {
24
30
  return "workspace-invitation";
25
31
  }
26
32
 
33
+ /** @param {import("@jskit-ai/kernel/server/types").RuntimeRouter} router */
27
34
  function registerWorkspacePendingInvitationsRoutes(router) {
28
- if (!router || typeof router.register !== "function") {
29
- throw new TypeError("registerWorkspacePendingInvitationsRoutes requires router.register().");
30
- }
31
-
32
35
  router.register(
33
36
  "GET",
34
37
  "/api/workspace/invitations/resolve",