@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
@@ -5,9 +5,11 @@ import { createService } from "../src/server/workspacePendingInvitations/workspa
5
5
 
6
6
  function createFixture({
7
7
  pendingInvitesByEmail = [],
8
- inviteByTokenHash = null
8
+ inviteByTokenHash = null,
9
+ resolvedInviteByTokenHash = null
9
10
  } = {}) {
10
11
  const tokenHashCalls = [];
12
+ const resolveTokenHashCalls = [];
11
13
  const upsertCalls = [];
12
14
  const revokeCalls = [];
13
15
  const acceptCalls = [];
@@ -25,6 +27,14 @@ function createFixture({
25
27
 
26
28
  return inviteByTokenHash[String(tokenHash || "")] || null;
27
29
  },
30
+ async findByTokenHashWithWorkspace(tokenHash) {
31
+ resolveTokenHashCalls.push(String(tokenHash || ""));
32
+ if (!resolvedInviteByTokenHash || typeof resolvedInviteByTokenHash !== "object") {
33
+ return null;
34
+ }
35
+
36
+ return resolvedInviteByTokenHash[String(tokenHash || "")] || null;
37
+ },
28
38
  async revokeById(inviteId) {
29
39
  revokeCalls.push(Number(inviteId));
30
40
  },
@@ -47,6 +57,7 @@ function createFixture({
47
57
  service,
48
58
  calls: {
49
59
  tokenHashCalls,
60
+ resolveTokenHashCalls,
50
61
  upsertCalls,
51
62
  revokeCalls,
52
63
  acceptCalls
@@ -83,6 +94,120 @@ test("listPendingInvitesForUser returns raw pending invite rows for the action l
83
94
  assert.equal(pendingInvites[0].status, "pending");
84
95
  });
85
96
 
97
+ test("resolveInviteByToken returns safe public invite metadata without mutating the invite", async () => {
98
+ const tokenHash = "d".repeat(64);
99
+ const encodedToken = encodeInviteTokenHash(tokenHash);
100
+ const { service, calls } = createFixture({
101
+ resolvedInviteByTokenHash: {
102
+ [tokenHash]: {
103
+ id: "46",
104
+ workspaceId: "8",
105
+ workspaceSlug: "acme",
106
+ workspaceName: "Acme",
107
+ workspaceAvatarUrl: "https://example.com/acme.png",
108
+ email: "Invitee@Example.com",
109
+ roleSid: "admin",
110
+ status: "pending",
111
+ tokenHash,
112
+ expiresAt: "2030-01-01T00:00:00.000Z"
113
+ }
114
+ }
115
+ });
116
+
117
+ const resolved = await service.resolveInviteByToken(encodedToken);
118
+
119
+ assert.deepEqual(calls.resolveTokenHashCalls, [tokenHash]);
120
+ assert.deepEqual(calls.revokeCalls, []);
121
+ assert.equal(resolved.id, "46");
122
+ assert.equal(resolved.token, encodedToken);
123
+ assert.equal(resolved.status, "pending");
124
+ assert.equal(resolved.email, "invitee@example.com");
125
+ assert.equal(resolved.maskedEmail, "in*****@example.com");
126
+ assert.equal(resolved.roleSid, "admin");
127
+ assert.deepEqual(resolved.workspace, {
128
+ id: "8",
129
+ slug: "acme",
130
+ name: "Acme",
131
+ avatarUrl: "https://example.com/acme.png"
132
+ });
133
+ });
134
+
135
+ test("resolveInviteByToken reports expired and missing invitations as terminal states", async () => {
136
+ const expiredTokenHash = "e".repeat(64);
137
+ const missingTokenHash = "f".repeat(64);
138
+ const expiredToken = encodeInviteTokenHash(expiredTokenHash);
139
+ const missingToken = encodeInviteTokenHash(missingTokenHash);
140
+ const { service, calls } = createFixture({
141
+ resolvedInviteByTokenHash: {
142
+ [expiredTokenHash]: {
143
+ id: "47",
144
+ workspaceId: "8",
145
+ workspaceSlug: "acme",
146
+ workspaceName: "Acme",
147
+ workspaceAvatarUrl: "",
148
+ email: "invitee@example.com",
149
+ roleSid: "member",
150
+ status: "pending",
151
+ tokenHash: expiredTokenHash,
152
+ expiresAt: "2000-01-01T00:00:00.000Z"
153
+ }
154
+ }
155
+ });
156
+
157
+ const expired = await service.resolveInviteByToken(expiredToken);
158
+ const missing = await service.resolveInviteByToken(missingToken);
159
+
160
+ assert.equal(expired.status, "expired");
161
+ assert.equal(missing.status, "not_found");
162
+ assert.equal(missing.email, "");
163
+ assert.deepEqual(calls.revokeCalls, []);
164
+ assert.deepEqual(calls.acceptCalls, []);
165
+ });
166
+
167
+ test("resolveInviteContextForAuth returns invite context for matching registration emails", async () => {
168
+ const tokenHash = "1".repeat(64);
169
+ const encodedToken = encodeInviteTokenHash(tokenHash);
170
+ const { service } = createFixture({
171
+ resolvedInviteByTokenHash: {
172
+ [tokenHash]: {
173
+ id: "48",
174
+ workspaceId: "8",
175
+ workspaceSlug: "acme",
176
+ workspaceName: "Acme",
177
+ workspaceAvatarUrl: "",
178
+ email: "Invitee@Example.com",
179
+ roleSid: "member",
180
+ status: "pending",
181
+ tokenHash,
182
+ expiresAt: "2030-01-01T00:00:00.000Z"
183
+ }
184
+ }
185
+ });
186
+
187
+ const context = await service.resolveInviteContextForAuth({
188
+ token: encodedToken,
189
+ email: "invitee@example.com"
190
+ });
191
+
192
+ assert.deepEqual(context, {
193
+ token: encodedToken,
194
+ workspaceId: "8",
195
+ workspaceSlug: "acme",
196
+ workspaceName: "Acme",
197
+ email: "invitee@example.com",
198
+ roleSid: "member",
199
+ expiresAt: "2030-01-01T00:00:00.000Z"
200
+ });
201
+ await assert.rejects(
202
+ () =>
203
+ service.resolveInviteContextForAuth({
204
+ token: encodedToken,
205
+ email: "other@example.com"
206
+ }),
207
+ /Invitation email does not match account email/
208
+ );
209
+ });
210
+
86
211
  test("acceptInviteByToken accepts opaque invite token and resolves invite by decoded hash", async () => {
87
212
  const tokenHash = "b".repeat(64);
88
213
  const encodedToken = encodeInviteTokenHash(tokenHash);
@@ -161,6 +161,10 @@ test("workspace and settings routes attach only shared schema definitions on raw
161
161
  method: "DELETE",
162
162
  path: "/api/w/:workspaceSlug/invites/:inviteId"
163
163
  });
164
+ const workspaceInviteResolve = findRoute(routes, {
165
+ method: "GET",
166
+ path: "/api/workspace/invitations/resolve"
167
+ });
164
168
 
165
169
  assert.equal(typeof workspaceSettings?.params?.schema, "object");
166
170
  assert.equal(typeof workspacePatch?.body?.schema, "object");
@@ -169,6 +173,7 @@ test("workspace and settings routes attach only shared schema definitions on raw
169
173
  assert.equal(typeof workspaceMemberRole?.body?.schema, "object");
170
174
  assert.equal(typeof workspaceMemberDelete?.params?.schema, "object");
171
175
  assert.equal(typeof workspaceInviteDelete?.params?.schema, "object");
176
+ assert.equal(typeof workspaceInviteResolve?.query?.schema, "object");
172
177
  });
173
178
 
174
179
  test("workspace core/settings routes mount one canonical workspace endpoint", async () => {
@@ -264,6 +269,7 @@ test("workspaces-core route registration follows tenancy mode matrix", async ()
264
269
  assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/w/:workspaceSlug" }), null);
265
270
  assert.equal(findRoute(noneRoutes, { method: "PATCH", path: "/api/w/:workspaceSlug" }), null);
266
271
  assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/w/:workspaceSlug/settings" }), null);
272
+ assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/workspace/invitations/resolve" }), null);
267
273
  assert.equal(findRoute(noneRoutes, { method: "GET", path: "/api/workspace/invitations/pending" }), null);
268
274
 
269
275
  assert.equal(findRoute(personalRoutes, { method: "GET", path: "/api/workspaces" })?.path, "/api/workspaces");
@@ -280,6 +286,10 @@ test("workspaces-core route registration follows tenancy mode matrix", async ()
280
286
  findRoute(personalRoutes, { method: "GET", path: "/api/w/:workspaceSlug/settings" })?.path,
281
287
  "/api/w/:workspaceSlug/settings"
282
288
  );
289
+ assert.equal(
290
+ findRoute(personalRoutes, { method: "GET", path: "/api/workspace/invitations/resolve" })?.path,
291
+ "/api/workspace/invitations/resolve"
292
+ );
283
293
  assert.equal(
284
294
  findRoute(personalRoutes, { method: "GET", path: "/api/workspace/invitations/pending" })?.path,
285
295
  "/api/workspace/invitations/pending"
@@ -299,6 +309,10 @@ test("workspaces-core route registration follows tenancy mode matrix", async ()
299
309
  findRoute(workspaceRoutes, { method: "GET", path: "/api/w/:workspaceSlug/settings" })?.path,
300
310
  "/api/w/:workspaceSlug/settings"
301
311
  );
312
+ assert.equal(
313
+ findRoute(workspaceRoutes, { method: "GET", path: "/api/workspace/invitations/resolve" })?.path,
314
+ "/api/workspace/invitations/resolve"
315
+ );
302
316
  assert.equal(
303
317
  findRoute(workspaceRoutes, { method: "GET", path: "/api/workspace/invitations/pending" })?.path,
304
318
  "/api/workspace/invitations/pending"
@@ -318,6 +332,7 @@ test("workspaces-core boot skips invitation redeem/list routes when workspace in
318
332
  workspaceSelfCreateEnabled: false
319
333
  });
320
334
 
335
+ assert.equal(findRoute(routes, { method: "GET", path: "/api/workspace/invitations/resolve" }), null);
321
336
  assert.equal(findRoute(routes, { method: "GET", path: "/api/workspace/invitations/pending" }), null);
322
337
  assert.equal(findRoute(routes, { method: "POST", path: "/api/workspace/invitations/redeem" }), null);
323
338
  assert.equal(findRoute(routes, { method: "GET", path: "/api/w/:workspaceSlug/invites" }), null);
@@ -335,6 +350,10 @@ test("workspace invite and member handlers build action input from request.input
335
350
  method: "POST",
336
351
  path: "/api/workspace/invitations/redeem"
337
352
  });
353
+ const workspaceInviteResolve = findRoute(routes, {
354
+ method: "GET",
355
+ path: "/api/workspace/invitations/resolve"
356
+ });
338
357
  const workspaceMemberRolePatch = findRoute(routes, {
339
358
  method: "PATCH",
340
359
  path: "/api/w/:workspaceSlug/members/:memberUserId/role"
@@ -366,6 +385,15 @@ test("workspace invite and member handlers build action input from request.input
366
385
  }),
367
386
  createReplyDouble()
368
387
  );
388
+ await workspaceInviteResolve.handler(
389
+ createActionRequest({
390
+ input: {
391
+ query: { token: "token-0" }
392
+ },
393
+ executeAction
394
+ }),
395
+ createReplyDouble()
396
+ );
369
397
  await workspaceInviteRedeem.handler(
370
398
  createActionRequest({
371
399
  input: {
@@ -418,11 +446,15 @@ test("workspace invite and member handlers build action input from request.input
418
446
  actionId: "workspace.workspaces.create",
419
447
  input: { name: "Operations", slug: "operations" }
420
448
  });
421
- assert.deepEqual(calls[1].input, { token: "token-1", decision: "accept" });
422
- assert.deepEqual(calls[2].input, { workspaceSlug: "acme", memberUserId: "12", roleSid: "admin" });
423
- assert.deepEqual(calls[3].input, { workspaceSlug: "acme", email: "user@example.com", roleSid: "member" });
424
- assert.deepEqual(calls[4].input, { workspaceSlug: "acme", memberUserId: "44" });
425
- assert.deepEqual(calls[5].input, { workspaceSlug: "acme", inviteId: "55" });
449
+ assert.deepEqual(calls[1], {
450
+ actionId: "workspace.invitation.resolve",
451
+ input: { token: "token-0" }
452
+ });
453
+ assert.deepEqual(calls[2].input, { token: "token-1", decision: "accept" });
454
+ assert.deepEqual(calls[3].input, { workspaceSlug: "acme", memberUserId: "12", roleSid: "admin" });
455
+ assert.deepEqual(calls[4].input, { workspaceSlug: "acme", email: "user@example.com", roleSid: "member" });
456
+ assert.deepEqual(calls[5].input, { workspaceSlug: "acme", memberUserId: "44" });
457
+ assert.deepEqual(calls[6].input, { workspaceSlug: "acme", inviteId: "55" });
426
458
  });
427
459
 
428
460
  test("workspace settings route handlers build action input from request.input", async () => {