@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.
Files changed (25) hide show
  1. package/package.descriptor.mjs +38 -5
  2. package/package.json +9 -9
  3. package/src/server/common/repositories/workspaceInvitesRepository.js +18 -0
  4. package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +42 -0
  5. package/src/server/workspaceMembers/registerWorkspaceMembers.js +30 -1
  6. package/src/server/workspaceMembers/workspaceInviteUrls.js +65 -0
  7. package/src/server/workspaceMembers/workspaceMembersService.js +82 -2
  8. package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +36 -0
  9. package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +11 -0
  10. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +22 -0
  11. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +92 -0
  12. package/src/shared/jsonApiTransports.js +7 -0
  13. package/src/shared/resources/workspaceMembersResource.js +24 -0
  14. package/src/shared/resources/workspacePendingInvitationsResource.js +44 -0
  15. package/templates/packages/main/src/server/email/workspaceInviteEmail.js +23 -0
  16. package/test/packageDescriptor.test.js +75 -0
  17. package/test/registerWorkspaceMembers.test.js +140 -0
  18. package/test/resourcesCanonical.test.js +4 -1
  19. package/test/usersRouteResources.test.js +4 -1
  20. package/test/workspaceInvitesRepository.test.js +35 -0
  21. package/test/workspaceMembersResource.test.js +8 -0
  22. package/test/workspaceMembersService.test.js +123 -0
  23. package/test/workspacePendingInvitationsResource.test.js +18 -0
  24. package/test/workspacePendingInvitationsService.test.js +126 -1
  25. package/test/workspacesRouteRequestInputValidator.test.js +37 -5
@@ -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.81",
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.50",
146
- "@jskit-ai/resource-core": "0.1.50",
147
- "@jskit-ai/resource-crud-core": "0.1.50",
148
- "@jskit-ai/users-core": "0.1.115"
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.81",
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.104",
22
- "@jskit-ai/database-runtime": "0.1.105",
23
- "@jskit-ai/http-runtime": "0.1.104",
24
- "@jskit-ai/json-rest-api-core": "0.1.50",
25
- "@jskit-ai/kernel": "0.1.106",
26
- "@jskit-ai/resource-crud-core": "0.1.50",
27
- "@jskit-ai/resource-core": "0.1.50",
28
- "@jskit-ai/users-core": "0.1.115",
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("<", "&lt;")
5
+ .replaceAll(">", "&gt;")
6
+ .replaceAll('"', "&quot;")
7
+ .replaceAll("'", "&#39;");
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: new Date(Date.now() + resolvedInviteExpiresInMs).toISOString()
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
  };