@jskit-ai/workspaces-core 0.1.81 → 0.1.82
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/package.descriptor.mjs +38 -5
- package/package.json +9 -9
- package/src/server/common/repositories/workspaceInvitesRepository.js +18 -0
- package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +42 -0
- package/src/server/workspaceMembers/registerWorkspaceMembers.js +30 -1
- package/src/server/workspaceMembers/workspaceInviteUrls.js +65 -0
- package/src/server/workspaceMembers/workspaceMembersService.js +82 -2
- package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +36 -0
- package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +11 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +22 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +92 -0
- package/src/shared/jsonApiTransports.js +7 -0
- package/src/shared/resources/workspaceMembersResource.js +24 -0
- package/src/shared/resources/workspacePendingInvitationsResource.js +44 -0
- package/templates/packages/main/src/server/email/workspaceInviteEmail.js +23 -0
- package/test/packageDescriptor.test.js +75 -0
- package/test/registerWorkspaceMembers.test.js +140 -0
- package/test/resourcesCanonical.test.js +4 -1
- package/test/usersRouteResources.test.js +4 -1
- package/test/workspaceInvitesRepository.test.js +35 -0
- package/test/workspaceMembersResource.test.js +8 -0
- package/test/workspaceMembersService.test.js +123 -0
- package/test/workspacePendingInvitationsResource.test.js +18 -0
- package/test/workspacePendingInvitationsService.test.js +126 -1
- package/test/workspacesRouteRequestInputValidator.test.js +37 -5
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/workspaces-core",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.82",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
|
|
7
7
|
dependsOn: [
|
|
@@ -86,6 +86,11 @@ export default Object.freeze({
|
|
|
86
86
|
path: "/api/workspaces",
|
|
87
87
|
summary: "List workspaces visible to authenticated user."
|
|
88
88
|
},
|
|
89
|
+
{
|
|
90
|
+
method: "GET",
|
|
91
|
+
path: "/api/workspace/invitations/resolve",
|
|
92
|
+
summary: "Resolve safe public workspace invitation metadata."
|
|
93
|
+
},
|
|
89
94
|
{
|
|
90
95
|
method: "GET",
|
|
91
96
|
path: "/api/workspace/invitations/pending",
|
|
@@ -142,10 +147,10 @@ export default Object.freeze({
|
|
|
142
147
|
mutations: {
|
|
143
148
|
dependencies: {
|
|
144
149
|
runtime: {
|
|
145
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
146
|
-
"@jskit-ai/resource-core": "0.1.
|
|
147
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
148
|
-
"@jskit-ai/users-core": "0.1.
|
|
150
|
+
"@jskit-ai/json-rest-api-core": "0.1.51",
|
|
151
|
+
"@jskit-ai/resource-core": "0.1.51",
|
|
152
|
+
"@jskit-ai/resource-crud-core": "0.1.51",
|
|
153
|
+
"@jskit-ai/users-core": "0.1.116"
|
|
149
154
|
},
|
|
150
155
|
dev: {}
|
|
151
156
|
},
|
|
@@ -191,6 +196,34 @@ export default Object.freeze({
|
|
|
191
196
|
reason: "Install app-owned role catalog in a dedicated config file.",
|
|
192
197
|
category: "workspaces-core",
|
|
193
198
|
id: "users-core-app-owned-role-catalog-config"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
from: "templates/packages/main/src/server/email/workspaceInviteEmail.js",
|
|
202
|
+
to: "packages/main/src/server/email/workspaceInviteEmail.js",
|
|
203
|
+
preserveOnRemove: true,
|
|
204
|
+
reason: "Install app-owned editable workspace invite email template.",
|
|
205
|
+
category: "workspaces-core",
|
|
206
|
+
id: "workspaces-core-main-workspace-invite-email-template"
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
source: [
|
|
210
|
+
{
|
|
211
|
+
op: "ensure-import",
|
|
212
|
+
file: "config/server.js",
|
|
213
|
+
namedImports: ["renderWorkspaceInviteEmail"],
|
|
214
|
+
from: "../packages/main/src/server/email/workspaceInviteEmail.js",
|
|
215
|
+
reason: "Load app-owned workspace invite email renderer from packages/main.",
|
|
216
|
+
category: "workspaces-core",
|
|
217
|
+
id: "workspaces-core-server-config-workspace-invite-email-import"
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
op: "ensure-assignment",
|
|
221
|
+
file: "config/server.js",
|
|
222
|
+
target: "config.workspaceInviteEmailTemplate",
|
|
223
|
+
value: "renderWorkspaceInviteEmail",
|
|
224
|
+
reason: "Bind app-owned workspace invite email renderer into server config.",
|
|
225
|
+
category: "workspaces-core",
|
|
226
|
+
id: "workspaces-core-server-config-workspace-invite-email-template"
|
|
194
227
|
}
|
|
195
228
|
],
|
|
196
229
|
text: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/workspaces-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.82",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
"./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@jskit-ai/auth-core": "0.1.
|
|
22
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
23
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
24
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
25
|
-
"@jskit-ai/kernel": "0.1.
|
|
26
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
27
|
-
"@jskit-ai/resource-core": "0.1.
|
|
28
|
-
"@jskit-ai/users-core": "0.1.
|
|
21
|
+
"@jskit-ai/auth-core": "0.1.105",
|
|
22
|
+
"@jskit-ai/database-runtime": "0.1.106",
|
|
23
|
+
"@jskit-ai/http-runtime": "0.1.105",
|
|
24
|
+
"@jskit-ai/json-rest-api-core": "0.1.51",
|
|
25
|
+
"@jskit-ai/kernel": "0.1.107",
|
|
26
|
+
"@jskit-ai/resource-crud-core": "0.1.51",
|
|
27
|
+
"@jskit-ai/resource-core": "0.1.51",
|
|
28
|
+
"@jskit-ai/users-core": "0.1.116",
|
|
29
29
|
"json-rest-schema": "1.x.x"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -132,6 +132,23 @@ function createRepository({ api, knex } = {}) {
|
|
|
132
132
|
return normalizeInviteRecord(rows[0] || null);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
async function findByTokenHashWithWorkspace(tokenHash, options = {}) {
|
|
136
|
+
const normalizedTokenHash = normalizeText(tokenHash);
|
|
137
|
+
if (!normalizedTokenHash) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rows = await queryInvites(
|
|
142
|
+
{
|
|
143
|
+
tokenHash: normalizedTokenHash
|
|
144
|
+
},
|
|
145
|
+
options,
|
|
146
|
+
{ includeWorkspace: true }
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return normalizeInviteWithWorkspace(rows[0] || null);
|
|
150
|
+
}
|
|
151
|
+
|
|
135
152
|
async function listPendingByEmail(email, options = {}) {
|
|
136
153
|
const normalizedEmail = normalizeLowerText(email);
|
|
137
154
|
if (!normalizedEmail) {
|
|
@@ -349,6 +366,7 @@ function createRepository({ api, knex } = {}) {
|
|
|
349
366
|
return Object.freeze({
|
|
350
367
|
withTransaction,
|
|
351
368
|
findPendingByTokenHash,
|
|
369
|
+
findByTokenHashWithWorkspace,
|
|
352
370
|
listPendingByEmail,
|
|
353
371
|
listPendingByWorkspaceIdWithWorkspace,
|
|
354
372
|
insert,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function escapeHtml(value = "") {
|
|
2
|
+
return String(value ?? "")
|
|
3
|
+
.replaceAll("&", "&")
|
|
4
|
+
.replaceAll("<", "<")
|
|
5
|
+
.replaceAll(">", ">")
|
|
6
|
+
.replaceAll('"', """)
|
|
7
|
+
.replaceAll("'", "'");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function renderDefaultWorkspaceInviteEmail({
|
|
11
|
+
inviteUrl = "",
|
|
12
|
+
workspace = {},
|
|
13
|
+
inviter = null,
|
|
14
|
+
roleSid = "member",
|
|
15
|
+
expiresAt = ""
|
|
16
|
+
} = {}) {
|
|
17
|
+
const workspaceName = String(workspace?.name || workspace?.slug || "the workspace").trim() || "the workspace";
|
|
18
|
+
const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
|
|
19
|
+
const roleName = String(roleSid || "member").trim() || "member";
|
|
20
|
+
const expiryText = expiresAt ? `This invitation expires at ${expiresAt}.` : "";
|
|
21
|
+
const intro = inviterName
|
|
22
|
+
? `${inviterName} invited you to join ${workspaceName} as ${roleName}.`
|
|
23
|
+
: `You have been invited to join ${workspaceName} as ${roleName}.`;
|
|
24
|
+
const subject = `You're invited to ${workspaceName}`;
|
|
25
|
+
const text = [intro, expiryText, `Open this link to accept the invitation:`, inviteUrl]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join("\n\n");
|
|
28
|
+
const html = [
|
|
29
|
+
`<p>${escapeHtml(intro)}</p>`,
|
|
30
|
+
expiryText ? `<p>${escapeHtml(expiryText)}</p>` : "",
|
|
31
|
+
`<p><a href="${escapeHtml(inviteUrl)}">Accept invitation</a></p>`,
|
|
32
|
+
`<p>${escapeHtml(inviteUrl)}</p>`
|
|
33
|
+
].filter(Boolean).join("\n");
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
subject,
|
|
37
|
+
text,
|
|
38
|
+
html
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { renderDefaultWorkspaceInviteEmail };
|
|
@@ -7,6 +7,8 @@ import { createService as createWorkspaceMembersService } from "./workspaceMembe
|
|
|
7
7
|
import { workspaceMembersActions } from "./workspaceMembersActions.js";
|
|
8
8
|
import { createWorkspaceRoleCatalog } from "../../shared/roles.js";
|
|
9
9
|
import { createWorkspaceEntityAndBootstrapEvents } from "../common/support/realtimeServiceEvents.js";
|
|
10
|
+
import { renderDefaultWorkspaceInviteEmail } from "./defaultWorkspaceInviteEmail.js";
|
|
11
|
+
import { createWorkspaceInviteUrlBuilder } from "./workspaceInviteUrls.js";
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
function resolveWorkspaceMembersInviteExpiresInMs(appConfig = {}) {
|
|
@@ -18,6 +20,23 @@ function resolveWorkspaceMembersInviteExpiresInMs(appConfig = {}) {
|
|
|
18
20
|
return inviteExpiresInMs;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function resolveWorkspaceInviteEmailTemplate(scope, appConfig = {}) {
|
|
24
|
+
if (typeof scope.has === "function" && scope.has("workspaces.invite.emailTemplate")) {
|
|
25
|
+
return scope.make("workspaces.invite.emailTemplate");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const configuredTemplate =
|
|
29
|
+
appConfig.workspaceInviteEmailTemplate ||
|
|
30
|
+
appConfig.workspaceInviteEmailRenderer ||
|
|
31
|
+
appConfig.workspaceInvitations?.emailTemplate ||
|
|
32
|
+
appConfig.workspaceInvitations?.emailRenderer;
|
|
33
|
+
if (typeof configuredTemplate === "function") {
|
|
34
|
+
return configuredTemplate;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return renderDefaultWorkspaceInviteEmail;
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
const INVITE_RECIPIENT_BOOTSTRAP_AUDIENCE = Object.freeze({
|
|
22
41
|
preset: "event_scope",
|
|
23
42
|
async userQuery({ knex, event } = {}) {
|
|
@@ -57,12 +76,22 @@ function registerWorkspaceMembers(app) {
|
|
|
57
76
|
"workspaces.members.service",
|
|
58
77
|
(scope) => {
|
|
59
78
|
const appConfig = resolveAppConfig(scope);
|
|
79
|
+
const env = typeof scope.has === "function" && scope.has("jskit.env") ? scope.make("jskit.env") : process.env;
|
|
60
80
|
return createWorkspaceMembersService({
|
|
61
81
|
workspaceMembershipsRepository: scope.make("internal.repository.workspace-memberships"),
|
|
62
82
|
workspaceInvitesRepository: scope.make("internal.repository.workspace-invites"),
|
|
63
83
|
inviteExpiresInMs: resolveWorkspaceMembersInviteExpiresInMs(appConfig),
|
|
64
84
|
roleCatalog: createWorkspaceRoleCatalog(appConfig),
|
|
65
|
-
workspaceInvitationsEnabled: scope.make("workspaces.invitations.enabled") === true
|
|
85
|
+
workspaceInvitationsEnabled: scope.make("workspaces.invitations.enabled") === true,
|
|
86
|
+
inviteUrlBuilder: createWorkspaceInviteUrlBuilder({
|
|
87
|
+
appConfig,
|
|
88
|
+
env
|
|
89
|
+
}),
|
|
90
|
+
workspaceInviteMailer:
|
|
91
|
+
typeof scope.has === "function" && scope.has("workspaces.invite.mailer")
|
|
92
|
+
? scope.make("workspaces.invite.mailer")
|
|
93
|
+
: null,
|
|
94
|
+
workspaceInviteEmailTemplate: resolveWorkspaceInviteEmailTemplate(scope, appConfig)
|
|
66
95
|
});
|
|
67
96
|
},
|
|
68
97
|
{
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function normalizeInvitePathTemplate(value = "") {
|
|
2
|
+
const raw = String(value || "").trim();
|
|
3
|
+
if (!raw) {
|
|
4
|
+
return "/invite/:token";
|
|
5
|
+
}
|
|
6
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildInvitePath(token, pathTemplate = "/invite/:token") {
|
|
10
|
+
const encodedToken = encodeURIComponent(String(token || "").trim());
|
|
11
|
+
const template = normalizeInvitePathTemplate(pathTemplate);
|
|
12
|
+
if (template.includes(":token")) {
|
|
13
|
+
return template.replaceAll(":token", encodedToken);
|
|
14
|
+
}
|
|
15
|
+
if (template.includes("[token]")) {
|
|
16
|
+
return template.replaceAll("[token]", encodedToken);
|
|
17
|
+
}
|
|
18
|
+
return `${template.replace(/\/$/, "")}/${encodedToken}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeBaseUrl(value = "") {
|
|
22
|
+
const raw = String(value || "").trim();
|
|
23
|
+
if (!raw) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(raw);
|
|
28
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
return parsed.toString().replace(/\/$/, "");
|
|
32
|
+
} catch {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createWorkspaceInviteUrlBuilder({
|
|
38
|
+
appConfig = {},
|
|
39
|
+
env = {}
|
|
40
|
+
} = {}) {
|
|
41
|
+
const inviteConfig = appConfig?.workspaceInvitations && typeof appConfig.workspaceInvitations === "object"
|
|
42
|
+
? appConfig.workspaceInvitations
|
|
43
|
+
: {};
|
|
44
|
+
const baseUrl = normalizeBaseUrl(
|
|
45
|
+
inviteConfig.inviteBaseUrl ||
|
|
46
|
+
inviteConfig.baseUrl ||
|
|
47
|
+
appConfig?.appPublicUrl ||
|
|
48
|
+
env.APP_PUBLIC_URL ||
|
|
49
|
+
""
|
|
50
|
+
);
|
|
51
|
+
const pathTemplate = normalizeInvitePathTemplate(inviteConfig.invitePath || inviteConfig.path || "/invite/:token");
|
|
52
|
+
|
|
53
|
+
return function buildWorkspaceInviteUrl({ token } = {}) {
|
|
54
|
+
const invitePath = buildInvitePath(token, pathTemplate);
|
|
55
|
+
if (!baseUrl) {
|
|
56
|
+
return invitePath;
|
|
57
|
+
}
|
|
58
|
+
return new URL(invitePath, `${baseUrl}/`).toString();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
buildInvitePath,
|
|
64
|
+
createWorkspaceInviteUrlBuilder
|
|
65
|
+
};
|
|
@@ -3,13 +3,17 @@ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actio
|
|
|
3
3
|
import { buildInviteToken, hashInviteToken } from "@jskit-ai/auth-core/server/inviteTokens";
|
|
4
4
|
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
5
5
|
import { OWNER_ROLE_ID, createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
|
|
6
|
+
import { renderDefaultWorkspaceInviteEmail } from "./defaultWorkspaceInviteEmail.js";
|
|
6
7
|
|
|
7
8
|
function createService({
|
|
8
9
|
workspaceMembershipsRepository,
|
|
9
10
|
workspaceInvitesRepository,
|
|
10
11
|
inviteExpiresInMs,
|
|
11
12
|
roleCatalog = null,
|
|
12
|
-
workspaceInvitationsEnabled = true
|
|
13
|
+
workspaceInvitationsEnabled = true,
|
|
14
|
+
inviteUrlBuilder = null,
|
|
15
|
+
workspaceInviteMailer = null,
|
|
16
|
+
workspaceInviteEmailTemplate = renderDefaultWorkspaceInviteEmail
|
|
13
17
|
} = {}) {
|
|
14
18
|
if (!workspaceMembershipsRepository || !workspaceInvitesRepository) {
|
|
15
19
|
throw new Error("workspaceMembersService requires membership and invite repositories.");
|
|
@@ -24,6 +28,15 @@ function createService({
|
|
|
24
28
|
? [...resolvedRoleCatalog.assignableRoleIds]
|
|
25
29
|
: [];
|
|
26
30
|
const resolvedWorkspaceInvitationsEnabled = workspaceInvitationsEnabled === true;
|
|
31
|
+
const resolvedInviteUrlBuilder = typeof inviteUrlBuilder === "function"
|
|
32
|
+
? inviteUrlBuilder
|
|
33
|
+
: ({ token }) => `/invite/${encodeURIComponent(String(token || "").trim())}`;
|
|
34
|
+
const resolvedWorkspaceInviteMailer =
|
|
35
|
+
workspaceInviteMailer && typeof workspaceInviteMailer === "object" ? workspaceInviteMailer : null;
|
|
36
|
+
const resolvedWorkspaceInviteEmailTemplate =
|
|
37
|
+
typeof workspaceInviteEmailTemplate === "function"
|
|
38
|
+
? workspaceInviteEmailTemplate
|
|
39
|
+
: renderDefaultWorkspaceInviteEmail;
|
|
27
40
|
|
|
28
41
|
function ensureWorkspaceInvitationsEnabled() {
|
|
29
42
|
if (resolvedWorkspaceInvitationsEnabled) {
|
|
@@ -77,6 +90,52 @@ function createService({
|
|
|
77
90
|
};
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
function normalizeInviteDeliveryResult(value = {}) {
|
|
94
|
+
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
95
|
+
return {
|
|
96
|
+
status: normalizeLowerText(source.status || "sent") || "sent",
|
|
97
|
+
message: normalizeText(source.message),
|
|
98
|
+
providerMessageId: normalizeText(source.providerMessageId)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function deliverInviteEmail({ workspace, user, invite, inviteUrl } = {}, options = {}) {
|
|
103
|
+
if (!resolvedWorkspaceInviteMailer || typeof resolvedWorkspaceInviteMailer.sendWorkspaceInvite !== "function") {
|
|
104
|
+
return {
|
|
105
|
+
status: "mailer_unconfigured",
|
|
106
|
+
message: "No workspace invite mailer is configured.",
|
|
107
|
+
providerMessageId: ""
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const message = await resolvedWorkspaceInviteEmailTemplate({
|
|
113
|
+
email: invite.email,
|
|
114
|
+
inviteUrl,
|
|
115
|
+
workspace: mapWorkspaceSummary(workspace),
|
|
116
|
+
inviter: user || null,
|
|
117
|
+
roleSid: invite.roleSid,
|
|
118
|
+
expiresAt: invite.expiresAt
|
|
119
|
+
});
|
|
120
|
+
const result = await resolvedWorkspaceInviteMailer.sendWorkspaceInvite({
|
|
121
|
+
email: invite.email,
|
|
122
|
+
inviteUrl,
|
|
123
|
+
workspace: mapWorkspaceSummary(workspace),
|
|
124
|
+
inviter: user || null,
|
|
125
|
+
roleSid: invite.roleSid,
|
|
126
|
+
expiresAt: invite.expiresAt,
|
|
127
|
+
message
|
|
128
|
+
}, options);
|
|
129
|
+
return normalizeInviteDeliveryResult(result || { status: "sent" });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
status: "failed",
|
|
133
|
+
message: normalizeText(error?.message || "Unable to send workspace invite email."),
|
|
134
|
+
providerMessageId: ""
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
80
139
|
async function listRoles(options = {}) {
|
|
81
140
|
return cloneWorkspaceRoleCatalog({
|
|
82
141
|
...resolvedRoleCatalog,
|
|
@@ -176,6 +235,7 @@ function createService({
|
|
|
176
235
|
}
|
|
177
236
|
|
|
178
237
|
async function createInvite(workspace, user, payload = {}, options = {}) {
|
|
238
|
+
ensureWorkspaceInvitationsEnabled();
|
|
179
239
|
const email = normalizeLowerText(payload.email);
|
|
180
240
|
const roleSid = normalizeLowerText(payload.roleSid || "member") || "member";
|
|
181
241
|
if (!assignableRoleIds.includes(roleSid)) {
|
|
@@ -190,6 +250,7 @@ function createService({
|
|
|
190
250
|
|
|
191
251
|
const token = buildInviteToken();
|
|
192
252
|
const tokenHash = hashInviteToken(token);
|
|
253
|
+
const expiresAt = new Date(Date.now() + resolvedInviteExpiresInMs).toISOString();
|
|
193
254
|
await workspaceInvitesRepository.expirePendingByWorkspaceIdAndEmail(workspace.id, email, options);
|
|
194
255
|
const createdInvite = await workspaceInvitesRepository.insert(
|
|
195
256
|
{
|
|
@@ -199,7 +260,7 @@ function createService({
|
|
|
199
260
|
status: "pending",
|
|
200
261
|
tokenHash,
|
|
201
262
|
invitedByUserId: normalizeRecordId(user?.id, { fallback: null }),
|
|
202
|
-
expiresAt
|
|
263
|
+
expiresAt
|
|
203
264
|
},
|
|
204
265
|
options
|
|
205
266
|
);
|
|
@@ -209,14 +270,33 @@ function createService({
|
|
|
209
270
|
}
|
|
210
271
|
|
|
211
272
|
const response = await listInvitesPayload(workspace, options);
|
|
273
|
+
const inviteUrl = resolvedInviteUrlBuilder({
|
|
274
|
+
token,
|
|
275
|
+
invite: createdInvite,
|
|
276
|
+
workspace
|
|
277
|
+
});
|
|
278
|
+
const inviteDelivery = await deliverInviteEmail({
|
|
279
|
+
workspace,
|
|
280
|
+
user,
|
|
281
|
+
invite: {
|
|
282
|
+
...createdInvite,
|
|
283
|
+
email,
|
|
284
|
+
roleSid,
|
|
285
|
+
expiresAt: createdInvite?.expiresAt || expiresAt
|
|
286
|
+
},
|
|
287
|
+
inviteUrl
|
|
288
|
+
}, options);
|
|
212
289
|
return {
|
|
213
290
|
...response,
|
|
214
291
|
inviteTokenPreview: token,
|
|
292
|
+
inviteUrl,
|
|
293
|
+
inviteDelivery,
|
|
215
294
|
createdInviteId
|
|
216
295
|
};
|
|
217
296
|
}
|
|
218
297
|
|
|
219
298
|
async function revokeInvite(workspace, inviteId, options = {}) {
|
|
299
|
+
ensureWorkspaceInvitationsEnabled();
|
|
220
300
|
const normalizedInviteId = normalizeRecordId(inviteId, { fallback: null });
|
|
221
301
|
if (!normalizedInviteId) {
|
|
222
302
|
throw new AppError(400, "Validation failed.");
|
|
@@ -2,6 +2,7 @@ import { createJsonApiResourceRouteContract } from "@jskit-ai/http-runtime/share
|
|
|
2
2
|
import { workspaceMembersResource } from "../../shared/resources/workspaceMembersResource.js";
|
|
3
3
|
import { workspacePendingInvitationsResource } from "../../shared/resources/workspacePendingInvitationsResource.js";
|
|
4
4
|
import {
|
|
5
|
+
WORKSPACE_INVITATION_RESOLVE_TRANSPORT,
|
|
5
6
|
WORKSPACE_INVITE_REDEEM_TRANSPORT,
|
|
6
7
|
WORKSPACE_PENDING_INVITATIONS_TRANSPORT
|
|
7
8
|
} from "../../shared/jsonApiTransports.js";
|
|
@@ -15,6 +16,14 @@ function resolveAuthenticatedUserRecordId(_record, context = {}) {
|
|
|
15
16
|
throw new Error("JSON:API response requires request.user.id.");
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
function resolveInviteResolutionRecordId(record = {}) {
|
|
20
|
+
const recordId = record?.id || record?.token;
|
|
21
|
+
if (recordId != null && String(recordId).trim()) {
|
|
22
|
+
return recordId;
|
|
23
|
+
}
|
|
24
|
+
return "workspace-invitation";
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
function bootWorkspacePendingInvitations(app) {
|
|
19
28
|
if (!app || typeof app.make !== "function") {
|
|
20
29
|
throw new Error("bootWorkspacePendingInvitations requires application make().");
|
|
@@ -22,6 +31,33 @@ function bootWorkspacePendingInvitations(app) {
|
|
|
22
31
|
|
|
23
32
|
const router = app.make("jskit.http.router");
|
|
24
33
|
|
|
34
|
+
router.register(
|
|
35
|
+
"GET",
|
|
36
|
+
"/api/workspace/invitations/resolve",
|
|
37
|
+
{
|
|
38
|
+
auth: "public",
|
|
39
|
+
meta: {
|
|
40
|
+
tags: ["workspace"],
|
|
41
|
+
summary: "Resolve safe public workspace invitation metadata"
|
|
42
|
+
},
|
|
43
|
+
...createJsonApiResourceRouteContract({
|
|
44
|
+
...WORKSPACE_INVITATION_RESOLVE_TRANSPORT,
|
|
45
|
+
query: workspacePendingInvitationsResource.operations.resolve.query,
|
|
46
|
+
output: workspacePendingInvitationsResource.operations.resolve.output,
|
|
47
|
+
outputKind: "record",
|
|
48
|
+
getRecordId: resolveInviteResolutionRecordId,
|
|
49
|
+
includeValidation400: true
|
|
50
|
+
})
|
|
51
|
+
},
|
|
52
|
+
async function (request, reply) {
|
|
53
|
+
const response = await request.executeAction({
|
|
54
|
+
actionId: "workspace.invitation.resolve",
|
|
55
|
+
input: request.input.query
|
|
56
|
+
});
|
|
57
|
+
reply.code(200).send(response);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
25
61
|
router.register(
|
|
26
62
|
"GET",
|
|
27
63
|
"/api/workspace/invitations/pending",
|
|
@@ -106,6 +106,17 @@ function registerWorkspacePendingInvitations(app) {
|
|
|
106
106
|
}
|
|
107
107
|
);
|
|
108
108
|
|
|
109
|
+
if (typeof app.has === "function" && !app.has("auth.invitationContextResolver")) {
|
|
110
|
+
app.singleton("auth.invitationContextResolver", (scope) => {
|
|
111
|
+
const service = scope.make("workspaces.pending-invitations.service");
|
|
112
|
+
return {
|
|
113
|
+
async resolveInvitationContext(invitation, options = {}) {
|
|
114
|
+
return service.resolveInviteContextForAuth(invitation, options);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
app.actions(
|
|
110
121
|
withActionDefaults(workspacePendingInvitationsActions, {
|
|
111
122
|
domain: "workspace",
|
|
@@ -3,9 +3,31 @@ import {
|
|
|
3
3
|
} from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
|
|
4
4
|
import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
|
|
5
5
|
import { workspaceMembersResource } from "../../shared/resources/workspaceMembersResource.js";
|
|
6
|
+
import { workspacePendingInvitationsResource } from "../../shared/resources/workspacePendingInvitationsResource.js";
|
|
6
7
|
import { resolveActionUser } from "../common/support/resolveActionUser.js";
|
|
7
8
|
|
|
8
9
|
const workspacePendingInvitationsActions = Object.freeze([
|
|
10
|
+
{
|
|
11
|
+
id: "workspace.invitation.resolve",
|
|
12
|
+
version: 1,
|
|
13
|
+
kind: "query",
|
|
14
|
+
channels: ["api", "automation", "internal"],
|
|
15
|
+
surfacesFrom: "enabled",
|
|
16
|
+
input: workspacePendingInvitationsResource.operations.resolve.query,
|
|
17
|
+
output: null,
|
|
18
|
+
idempotency: "none",
|
|
19
|
+
audit: {
|
|
20
|
+
actionName: "workspace.invitation.resolve"
|
|
21
|
+
},
|
|
22
|
+
observability: {},
|
|
23
|
+
async execute(input, context, deps) {
|
|
24
|
+
return returnJsonApiData(
|
|
25
|
+
await deps.workspacePendingInvitationsService.resolveInviteByToken(input?.token, {
|
|
26
|
+
context
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
},
|
|
9
31
|
{
|
|
10
32
|
id: "workspace.invitations.pending.list",
|
|
11
33
|
version: 1,
|
|
@@ -34,6 +34,67 @@ function createService({
|
|
|
34
34
|
return tokenHash;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function maskInviteEmail(email = "") {
|
|
38
|
+
const normalizedEmail = normalizeLowerText(email);
|
|
39
|
+
const [localPart = "", domain = ""] = normalizedEmail.split("@");
|
|
40
|
+
if (!localPart || !domain) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const visibleLocal = localPart.length <= 2 ? localPart[0] || "" : localPart.slice(0, 2);
|
|
44
|
+
return `${visibleLocal}${"*".repeat(Math.max(localPart.length - visibleLocal.length, 1))}@${domain}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolvePublicInviteStatus(invite = null) {
|
|
48
|
+
if (!invite) {
|
|
49
|
+
return "not_found";
|
|
50
|
+
}
|
|
51
|
+
const status = normalizeLowerText(invite.status || "pending") || "pending";
|
|
52
|
+
if (status === "pending" && invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
|
|
53
|
+
return "expired";
|
|
54
|
+
}
|
|
55
|
+
if (["accepted", "revoked", "expired"].includes(status)) {
|
|
56
|
+
return status;
|
|
57
|
+
}
|
|
58
|
+
return "pending";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mapResolvedInvite(invite = null, token = "") {
|
|
62
|
+
const status = resolvePublicInviteStatus(invite);
|
|
63
|
+
if (!invite) {
|
|
64
|
+
return {
|
|
65
|
+
id: "not-found",
|
|
66
|
+
token: normalizeText(token),
|
|
67
|
+
status,
|
|
68
|
+
email: "",
|
|
69
|
+
maskedEmail: "",
|
|
70
|
+
roleSid: "",
|
|
71
|
+
expiresAt: null,
|
|
72
|
+
workspace: {
|
|
73
|
+
id: "",
|
|
74
|
+
slug: "",
|
|
75
|
+
name: "",
|
|
76
|
+
avatarUrl: ""
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id: normalizeRecordId(invite.id, { fallback: "" }),
|
|
83
|
+
token: normalizeText(token),
|
|
84
|
+
status,
|
|
85
|
+
email: normalizeLowerText(invite.email),
|
|
86
|
+
maskedEmail: maskInviteEmail(invite.email),
|
|
87
|
+
roleSid: normalizeLowerText(invite.roleSid || "member") || "member",
|
|
88
|
+
expiresAt: invite.expiresAt || null,
|
|
89
|
+
workspace: {
|
|
90
|
+
id: normalizeRecordId(invite.workspaceId, { fallback: "" }),
|
|
91
|
+
slug: normalizeText(invite.workspaceSlug),
|
|
92
|
+
name: normalizeText(invite.workspaceName || invite.workspaceSlug),
|
|
93
|
+
avatarUrl: normalizeText(invite.workspaceAvatarUrl)
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
37
98
|
function mapPendingInvite(invite = {}) {
|
|
38
99
|
const id = normalizeRecordId(invite.id, { fallback: null });
|
|
39
100
|
const workspaceId = normalizeRecordId(invite.workspaceId, { fallback: null });
|
|
@@ -93,6 +154,35 @@ function createService({
|
|
|
93
154
|
return invites.map((invite) => mapPendingInvite(invite)).filter(Boolean);
|
|
94
155
|
}
|
|
95
156
|
|
|
157
|
+
async function resolveInviteByToken(token, options = {}) {
|
|
158
|
+
const tokenHash = requireInviteTokenHash(token);
|
|
159
|
+
const invite = typeof workspaceInvitesRepository.findByTokenHashWithWorkspace === "function"
|
|
160
|
+
? await workspaceInvitesRepository.findByTokenHashWithWorkspace(tokenHash, options)
|
|
161
|
+
: await workspaceInvitesRepository.findPendingByTokenHash(tokenHash, options);
|
|
162
|
+
|
|
163
|
+
return mapResolvedInvite(invite, token);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function resolveInviteContextForAuth({ token, email } = {}, options = {}) {
|
|
167
|
+
const resolved = await resolveInviteByToken(token, options);
|
|
168
|
+
if (resolved.status !== "pending") {
|
|
169
|
+
throw new AppError(409, "Invitation is not available.");
|
|
170
|
+
}
|
|
171
|
+
if (normalizeLowerText(resolved.email) !== normalizeLowerText(email)) {
|
|
172
|
+
throw new AppError(403, "Invitation email does not match account email.");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
token: resolved.token,
|
|
177
|
+
workspaceId: resolved.workspace.id,
|
|
178
|
+
workspaceSlug: resolved.workspace.slug,
|
|
179
|
+
workspaceName: resolved.workspace.name,
|
|
180
|
+
email: resolved.email,
|
|
181
|
+
roleSid: resolved.roleSid,
|
|
182
|
+
expiresAt: resolved.expiresAt
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
96
186
|
function requireWorkspaceIdFromInvite(invite, methodName = "workspacePendingInvitationsService") {
|
|
97
187
|
const workspaceId = normalizeRecordId(invite?.workspaceId, { fallback: null });
|
|
98
188
|
if (!workspaceId) {
|
|
@@ -152,6 +242,8 @@ function createService({
|
|
|
152
242
|
}
|
|
153
243
|
|
|
154
244
|
return Object.freeze({
|
|
245
|
+
resolveInviteByToken,
|
|
246
|
+
resolveInviteContextForAuth,
|
|
155
247
|
listPendingInvitesForUser,
|
|
156
248
|
acceptInviteByToken,
|
|
157
249
|
refuseInviteByToken
|
|
@@ -65,6 +65,12 @@ const WORKSPACE_PENDING_INVITATIONS_TRANSPORT = deepFreeze({
|
|
|
65
65
|
responseKind: "record"
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
const WORKSPACE_INVITATION_RESOLVE_TRANSPORT = deepFreeze({
|
|
69
|
+
kind: "jsonapi-resource",
|
|
70
|
+
responseType: "workspace-invitation-resolutions",
|
|
71
|
+
responseKind: "record"
|
|
72
|
+
});
|
|
73
|
+
|
|
68
74
|
export {
|
|
69
75
|
WORKSPACES_TRANSPORT,
|
|
70
76
|
WORKSPACES_COLLECTION_TRANSPORT,
|
|
@@ -75,5 +81,6 @@ export {
|
|
|
75
81
|
WORKSPACE_INVITES_TRANSPORT,
|
|
76
82
|
WORKSPACE_INVITE_CREATE_TRANSPORT,
|
|
77
83
|
WORKSPACE_INVITE_REDEEM_TRANSPORT,
|
|
84
|
+
WORKSPACE_INVITATION_RESOLVE_TRANSPORT,
|
|
78
85
|
WORKSPACE_PENDING_INVITATIONS_TRANSPORT
|
|
79
86
|
};
|