@jskit-ai/workspaces-core 0.2.4 → 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.
- package/locales/en.json +2 -2
- package/package.json +38 -11
- package/patterns/workspace-server/PATTERN.md +42 -17
- package/patterns/workspace-server/example/packages/main/src/server/WorkspaceInvitationsFeature.js +27 -0
- package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteEmail.js +11 -10
- package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteMailer.js +27 -0
- package/src/server/WorkspacesFeature.js +74 -183
- package/src/server/WorkspacesIntegrationsProvider.js +26 -10
- package/src/server/common/contributors/workspaceActionContextContributor.js +60 -70
- package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +12 -27
- package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +48 -63
- package/src/server/common/formatters/workspaceFormatter.js +12 -10
- package/src/server/common/repositories/workspaceInvitesRepository.js +74 -75
- package/src/server/common/repositories/workspaceMembershipsRepository.js +23 -45
- package/src/server/common/repositories/workspacesRepository.js +17 -25
- package/src/server/common/services/{workspaceContextService.js → workspaceDirectoryService.js} +52 -72
- package/src/server/common/support/realtimeServiceEvents.js +20 -12
- package/src/server/common/support/workspaceRecordId.js +21 -0
- package/src/server/common/validators/routeParamsValidator.js +3 -2
- package/src/server/i18n.js +5 -2
- package/src/server/support/resolveWorkspace.js +10 -7
- package/src/server/support/workspaceActionSurfaces.js +5 -41
- package/src/server/support/workspaceCrud.js +25 -0
- package/src/server/support/workspaceInvitationsPolicy.js +16 -27
- package/src/server/support/workspaceRouteInput.js +19 -2
- package/src/server/{common/support → support}/workspaceRoutePaths.js +8 -2
- package/src/server/types.js +47 -0
- package/src/server/workspaceBootstrapContributor.js +105 -142
- package/src/server/workspaceDirectory/workspaceDirectoryActions.js +105 -137
- package/src/server/workspaceDirectory/{bootWorkspaceDirectoryRoutes.js → workspaceDirectoryRoutes.js} +7 -19
- package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +15 -29
- package/src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js +25 -0
- package/src/server/workspaceMembers/workspaceInviteUrls.js +24 -23
- package/src/server/workspaceMembers/workspaceMembersActions.js +189 -219
- package/src/server/workspaceMembers/{bootWorkspaceMembers.js → workspaceMembersRoutes.js} +14 -26
- package/src/server/workspaceMembers/workspaceMembersService.js +110 -97
- package/src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js +8 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +79 -112
- package/src/server/workspacePendingInvitations/{bootWorkspacePendingInvitations.js → workspacePendingInvitationsRoutes.js} +11 -8
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +54 -57
- package/src/server/workspaceSettings/workspaceSettingsActions.js +53 -73
- package/src/server/workspaceSettings/workspaceSettingsRepository.js +20 -20
- package/src/server/workspaceSettings/{bootWorkspaceSettings.js → workspaceSettingsRoutes.js} +9 -21
- package/src/server/workspaceSettings/workspaceSettingsService.js +24 -26
- package/src/server/workspacesRuntime.js +185 -0
- package/src/shared/operationMessages.js +6 -15
- package/src/shared/resources/workspaceMembersResource.js +2 -2
- package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
- package/src/shared/resources/workspaceResource.js +4 -1
- package/src/shared/roles.js +15 -12
- package/src/shared/tenancyProfile.js +2 -2
- package/test/exportsContract.test.js +5 -0
- package/test/featureRuntime.test.js +5 -6
- package/test/packageMetadata.test.js +12 -0
- package/test/repositories.integration.test.js +1 -1
- package/test/requestedWorkspaceScope.test.js +24 -0
- package/test/roleLabels.test.js +29 -7
- package/test/support/invitationTransactions.js +35 -19
- package/test/workspaceActionSurfaces.test.js +0 -55
- package/test/workspaceAuthPolicyContextResolver.test.js +1 -1
- package/test/workspaceBootstrapContributor.test.js +7 -6
- package/test/workspaceCrud.test.js +23 -0
- package/test/workspaceInvitesRepository.test.js +9 -8
- package/test/workspaceMembersService.test.js +131 -28
- package/test/workspacePendingInvitationsResource.test.js +2 -2
- package/test/workspacePendingInvitationsService.test.js +69 -22
- package/test/workspaceService.test.js +58 -47
- package/test/workspaceSettingsActions.test.js +14 -7
- package/test/workspaceSettingsRepository.test.js +11 -0
- package/test/workspaceSettingsService.test.js +11 -9
- package/test/workspacesRepository.test.js +16 -16
- package/test/workspacesRouteRequestInputValidator.test.js +4 -4
- package/src/server/common/repositories/repositoryUtils.js +0 -43
- package/src/server/common/support/deepFreeze.js +0 -1
- package/src/server/common/support/resolveActionUser.js +0 -9
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
7
|
-
import {
|
|
8
|
-
|
|
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
|
|
23
|
-
workspaceInvitationsEnabled
|
|
24
|
-
inviteUrlBuilder
|
|
25
|
-
workspaceInviteMailer
|
|
26
|
-
workspaceInviteEmailTemplate
|
|
27
|
-
i18n
|
|
28
|
-
userProfilesRepository
|
|
29
|
-
userSettingsRepository
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 }
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 =
|
|
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
|
|
152
|
+
const message = await workspaceInviteEmailTemplate({
|
|
146
153
|
email: invite.email,
|
|
147
154
|
inviteUrl,
|
|
148
|
-
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
|
|
163
|
+
const result = await workspaceInviteMailer.sendWorkspaceInvite({
|
|
157
164
|
email: invite.email,
|
|
158
165
|
inviteUrl,
|
|
159
|
-
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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:
|
|
200
|
+
workspace: mapMembersWorkspace(workspace),
|
|
191
201
|
members: members.map((member) => mapMemberSummary(member, workspace))
|
|
192
202
|
});
|
|
193
203
|
}
|
|
194
204
|
|
|
195
|
-
async function
|
|
196
|
-
return
|
|
205
|
+
async function listMembersResponse(workspace, options = {}) {
|
|
206
|
+
return returnJsonApiData(await listMembers(workspace, options));
|
|
197
207
|
}
|
|
198
208
|
|
|
199
|
-
async function updateMemberRole(workspace, payload
|
|
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
|
|
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
|
|
237
|
+
return returnJsonApiData(await listMembers(workspace, options));
|
|
228
238
|
}
|
|
229
239
|
|
|
230
|
-
async function removeMember(workspace, payload
|
|
240
|
+
async function removeMember(workspace, payload, options = {}) {
|
|
231
241
|
const memberUserId = normalizeRecordId(payload.memberUserId, { fallback: null });
|
|
232
242
|
if (!memberUserId) {
|
|
233
|
-
throw
|
|
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
|
|
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:
|
|
272
|
+
workspace: mapMembersWorkspace(workspace),
|
|
263
273
|
invites: invites.map((invite) => mapInviteSummary(invite))
|
|
264
274
|
});
|
|
265
275
|
}
|
|
266
276
|
|
|
267
|
-
async function
|
|
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
|
|
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() +
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 "
|
|
8
|
+
import { resolveActionUser } from "@jskit-ai/users-core/server/resolveActionUser";
|
|
10
9
|
|
|
11
10
|
const WORKSPACE_INVITE_DECISION_EVENTS = createInviteDecisionEvents();
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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",
|