@jskit-ai/workspaces-core 0.2.3 → 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.
Files changed (75) hide show
  1. package/locales/en.json +2 -2
  2. package/package.json +38 -11
  3. package/patterns/workspace-server/PATTERN.md +42 -17
  4. package/patterns/workspace-server/example/packages/main/src/server/WorkspaceInvitationsFeature.js +27 -0
  5. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteEmail.js +11 -10
  6. package/patterns/workspace-server/example/packages/main/src/server/email/workspaceInviteMailer.js +27 -0
  7. package/src/server/WorkspacesFeature.js +74 -183
  8. package/src/server/WorkspacesIntegrationsProvider.js +26 -10
  9. package/src/server/common/contributors/workspaceActionContextContributor.js +60 -70
  10. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +12 -27
  11. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +48 -63
  12. package/src/server/common/formatters/workspaceFormatter.js +12 -10
  13. package/src/server/common/repositories/workspaceInvitesRepository.js +74 -75
  14. package/src/server/common/repositories/workspaceMembershipsRepository.js +23 -45
  15. package/src/server/common/repositories/workspacesRepository.js +17 -25
  16. package/src/server/common/services/{workspaceContextService.js → workspaceDirectoryService.js} +52 -72
  17. package/src/server/common/support/realtimeServiceEvents.js +20 -12
  18. package/src/server/common/support/workspaceRecordId.js +21 -0
  19. package/src/server/common/validators/routeParamsValidator.js +3 -2
  20. package/src/server/i18n.js +5 -2
  21. package/src/server/support/resolveWorkspace.js +10 -7
  22. package/src/server/support/workspaceActionSurfaces.js +5 -41
  23. package/src/server/support/workspaceCrud.js +25 -0
  24. package/src/server/support/workspaceInvitationsPolicy.js +16 -27
  25. package/src/server/support/workspaceRouteInput.js +19 -2
  26. package/src/server/{common/support → support}/workspaceRoutePaths.js +8 -2
  27. package/src/server/types.js +47 -0
  28. package/src/server/workspaceBootstrapContributor.js +105 -142
  29. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +105 -137
  30. package/src/server/workspaceDirectory/{bootWorkspaceDirectoryRoutes.js → workspaceDirectoryRoutes.js} +7 -19
  31. package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +15 -29
  32. package/src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js +25 -0
  33. package/src/server/workspaceMembers/workspaceInviteUrls.js +24 -23
  34. package/src/server/workspaceMembers/workspaceMembersActions.js +189 -219
  35. package/src/server/workspaceMembers/{bootWorkspaceMembers.js → workspaceMembersRoutes.js} +14 -26
  36. package/src/server/workspaceMembers/workspaceMembersService.js +110 -97
  37. package/src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js +8 -0
  38. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +79 -112
  39. package/src/server/workspacePendingInvitations/{bootWorkspacePendingInvitations.js → workspacePendingInvitationsRoutes.js} +11 -8
  40. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +54 -57
  41. package/src/server/workspaceSettings/workspaceSettingsActions.js +53 -73
  42. package/src/server/workspaceSettings/workspaceSettingsRepository.js +20 -20
  43. package/src/server/workspaceSettings/{bootWorkspaceSettings.js → workspaceSettingsRoutes.js} +9 -21
  44. package/src/server/workspaceSettings/workspaceSettingsService.js +24 -26
  45. package/src/server/workspacesRuntime.js +185 -0
  46. package/src/shared/operationMessages.js +6 -15
  47. package/src/shared/resources/workspaceMembersResource.js +2 -2
  48. package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
  49. package/src/shared/resources/workspaceResource.js +4 -1
  50. package/src/shared/roles.js +15 -12
  51. package/src/shared/tenancyProfile.js +2 -2
  52. package/test/exportsContract.test.js +5 -0
  53. package/test/featureRuntime.test.js +5 -6
  54. package/test/packageMetadata.test.js +12 -0
  55. package/test/repositories.integration.test.js +1 -1
  56. package/test/requestedWorkspaceScope.test.js +24 -0
  57. package/test/roleLabels.test.js +29 -7
  58. package/test/support/invitationTransactions.js +35 -19
  59. package/test/workspaceActionSurfaces.test.js +0 -55
  60. package/test/workspaceAuthPolicyContextResolver.test.js +1 -1
  61. package/test/workspaceBootstrapContributor.test.js +7 -6
  62. package/test/workspaceCrud.test.js +23 -0
  63. package/test/workspaceInvitesRepository.test.js +9 -8
  64. package/test/workspaceMembersService.test.js +131 -28
  65. package/test/workspacePendingInvitationsResource.test.js +2 -2
  66. package/test/workspacePendingInvitationsService.test.js +69 -22
  67. package/test/workspaceService.test.js +58 -47
  68. package/test/workspaceSettingsActions.test.js +14 -7
  69. package/test/workspaceSettingsRepository.test.js +11 -0
  70. package/test/workspaceSettingsService.test.js +11 -9
  71. package/test/workspacesRepository.test.js +16 -16
  72. package/test/workspacesRouteRequestInputValidator.test.js +4 -4
  73. package/src/server/common/repositories/repositoryUtils.js +0 -43
  74. package/src/server/common/support/deepFreeze.js +0 -1
  75. package/src/server/common/support/resolveActionUser.js +0 -9
@@ -3,9 +3,13 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
+ import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
6
7
  import { createWorkspacesServerI18n } from "../src/server/i18n.js";
7
8
  import { createService } from "../src/server/workspaceMembers/workspaceMembersService.js";
8
9
  import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
10
+ import { createWorkspaceInviteUrlBuilder } from "../src/server/workspaceMembers/workspaceInviteUrls.js";
11
+ import { renderDefaultWorkspaceInviteEmail } from "../src/server/workspaceMembers/defaultWorkspaceInviteEmail.js";
12
+ import { unconfiguredWorkspaceInviteMailer } from "../src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js";
9
13
 
10
14
  function authorizedOptions(permissions = []) {
11
15
  return {
@@ -45,13 +49,15 @@ function createRoleCatalog() {
45
49
  function createFixture() {
46
50
  const workspace = {
47
51
  id: "7",
48
- slug: "tonymobily3",
49
- name: "TonyMobily3",
52
+ slug: "tony3",
53
+ name: "Tony3",
50
54
  ownerUserId: "9",
51
55
  avatarUrl: ""
52
56
  };
53
57
 
54
58
  const service = createService({
59
+ workspaceInvitationsEnabled: true,
60
+ i18n: createWorkspacesServerI18n(),
55
61
  workspaceMembershipsRepository: {
56
62
  async listActiveByWorkspaceId(workspaceId) {
57
63
  assert.equal(Number(workspaceId), 7);
@@ -97,7 +103,10 @@ function createFixture() {
97
103
  async revokeById() {}
98
104
  },
99
105
  inviteExpiresInMs: 7 * 24 * 60 * 60 * 1000,
100
- roleCatalog: createRoleCatalog()
106
+ roleCatalog: createRoleCatalog(),
107
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
108
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
109
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
101
110
  });
102
111
 
103
112
  return { service, workspace };
@@ -107,6 +116,8 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
107
116
  const expiresAtValues = [];
108
117
  let savedInvite;
109
118
  const service = createService({
119
+ workspaceInvitationsEnabled: true,
120
+ i18n: createWorkspacesServerI18n(),
110
121
  workspaceMembershipsRepository: {
111
122
  async listActiveByWorkspaceId() {
112
123
  return [];
@@ -132,11 +143,14 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
132
143
  async revokeById() {}
133
144
  },
134
145
  inviteExpiresInMs: 30 * 60 * 1000,
135
- roleCatalog: createRoleCatalog()
146
+ roleCatalog: createRoleCatalog(),
147
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
148
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
149
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
136
150
  });
137
151
 
138
152
  const before = Date.now();
139
- const response = await service.createInvite(
153
+ const response = (await service.createInvite(
140
154
  {
141
155
  id: "7",
142
156
  ownerUserId: "9"
@@ -147,7 +161,7 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
147
161
  roleSid: "member"
148
162
  },
149
163
  authorizedOptions(["workspace.members.invite"])
150
- );
164
+ )).value;
151
165
  const after = Date.now();
152
166
 
153
167
  assert.equal(expiresAtValues.length, 1);
@@ -165,11 +179,68 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
165
179
  });
166
180
  });
167
181
 
182
+ test("workspaceMembersService.createInvite without a provided mailer neither looks up the recipient nor renders", async () => {
183
+ const lookups = [];
184
+ let savedInvite;
185
+ const service = createService({
186
+ workspaceInvitationsEnabled: true,
187
+ i18n: createWorkspacesServerI18n(),
188
+ workspaceMembershipsRepository: {
189
+ async listActiveByWorkspaceId() {
190
+ return [];
191
+ }
192
+ },
193
+ workspaceInvitesRepository: {
194
+ async withTransaction(work) {
195
+ return work({});
196
+ },
197
+ async lockWorkspaceForInvitations() {},
198
+ async expirePendingByWorkspaceIdAndEmail() {},
199
+ async insert(payload) {
200
+ savedInvite = { ...payload, id: "32" };
201
+ return savedInvite;
202
+ },
203
+ async listPendingByWorkspaceIdWithWorkspace() {
204
+ return [];
205
+ },
206
+ async findPendingByIdForWorkspace() {
207
+ return savedInvite;
208
+ }
209
+ },
210
+ userProfilesRepository: {
211
+ async findByEmail(email) {
212
+ lookups.push(email);
213
+ return null;
214
+ }
215
+ },
216
+ inviteExpiresInMs: 30 * 60 * 1000,
217
+ roleCatalog: createRoleCatalog(),
218
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
219
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
220
+ workspaceInviteEmailTemplate() {
221
+ throw new Error("No email is rendered without a mailer.");
222
+ }
223
+ });
224
+
225
+ const response = (await service.createInvite(
226
+ { id: "7", ownerUserId: "9" },
227
+ { id: "11" },
228
+ { email: "alice@example.com", roleSid: "member" },
229
+ authorizedOptions(["workspace.members.invite"])
230
+ )).value;
231
+
232
+ assert.deepEqual(lookups, []);
233
+ assert.equal(response.inviteDelivery.status, "mailer_unconfigured");
234
+ assert.equal(response.inviteDelivery.messageCode, "workspaces.invite.mailerUnconfigured");
235
+ });
236
+
168
237
  test("workspaceMembersService.createInvite sends generated invite URLs through the configured mailer", async () => {
169
238
  const templateCalls = [];
170
239
  const mailerCalls = [];
171
240
  let savedInvite;
172
241
  const service = createService({
242
+ workspaceInvitationsEnabled: true,
243
+ i18n: createWorkspacesServerI18n(),
173
244
  workspaceMembershipsRepository: {
174
245
  async listActiveByWorkspaceId() {
175
246
  return [];
@@ -224,7 +295,7 @@ test("workspaceMembersService.createInvite sends generated invite URLs through t
224
295
  }
225
296
  });
226
297
 
227
- const response = await service.createInvite(
298
+ const response = (await service.createInvite(
228
299
  {
229
300
  id: "7",
230
301
  slug: "acme",
@@ -238,7 +309,7 @@ test("workspaceMembersService.createInvite sends generated invite URLs through t
238
309
  roleSid: "admin"
239
310
  },
240
311
  authorizedOptions(["workspace.members.invite"])
241
- );
312
+ )).value;
242
313
 
243
314
  assert.equal(response.createdInviteId, "88");
244
315
  assert.match(response.inviteUrl, /^https:\/\/app\.example\.test\/invite\//);
@@ -262,6 +333,8 @@ test("workspaceMembersService.createInvite sends generated invite URLs through t
262
333
  test("workspaceMembersService.revokeInvite returns the revoked invite id", async () => {
263
334
  let revokedInviteId = 0;
264
335
  const service = createService({
336
+ workspaceInvitationsEnabled: true,
337
+ i18n: createWorkspacesServerI18n(),
265
338
  workspaceMembershipsRepository: {
266
339
  async listActiveByWorkspaceId() {
267
340
  return [];
@@ -294,17 +367,20 @@ test("workspaceMembersService.revokeInvite returns the revoked invite id", async
294
367
  }
295
368
  },
296
369
  inviteExpiresInMs: 30 * 60 * 1000,
297
- roleCatalog: createRoleCatalog()
370
+ roleCatalog: createRoleCatalog(),
371
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
372
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
373
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
298
374
  });
299
375
 
300
- const response = await service.revokeInvite(
376
+ const response = (await service.revokeInvite(
301
377
  {
302
378
  id: "7",
303
379
  ownerUserId: "9"
304
380
  },
305
381
  "47",
306
382
  authorizedOptions(["workspace.invites.revoke"])
307
- );
383
+ )).value;
308
384
 
309
385
  assert.equal(revokedInviteId, 47);
310
386
  assert.equal(response.revokedInviteId, "47");
@@ -312,6 +388,7 @@ test("workspaceMembersService.revokeInvite returns the revoked invite id", async
312
388
 
313
389
  test("workspaceMembersService rejects invite operations when invitations are disabled", async () => {
314
390
  const service = createService({
391
+ i18n: createWorkspacesServerI18n(),
315
392
  workspaceMembershipsRepository: {
316
393
  async listActiveByWorkspaceId() {
317
394
  return [];
@@ -336,12 +413,15 @@ test("workspaceMembersService rejects invite operations when invitations are dis
336
413
  },
337
414
  inviteExpiresInMs: 30 * 60 * 1000,
338
415
  roleCatalog: createRoleCatalog(),
339
- workspaceInvitationsEnabled: false
416
+ workspaceInvitationsEnabled: false,
417
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
418
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
419
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
340
420
  });
341
421
 
342
422
  await assert.rejects(
343
423
  () =>
344
- service.listInvites(
424
+ service.listInvitesResponse(
345
425
  {
346
426
  id: "7",
347
427
  ownerUserId: "9"
@@ -380,6 +460,17 @@ test("workspaceMembersService rejects invite operations when invitations are dis
380
460
  );
381
461
  });
382
462
 
463
+ test("workspaceMembersService answers the role and member list actions with JSON:API data", async () => {
464
+ const { service, workspace } = createFixture();
465
+ const options = authorizedOptions(["workspace.members.view"]);
466
+
467
+ assert.deepEqual(await service.listRolesResponse(options), returnJsonApiData(await service.listRoles(options)));
468
+ assert.deepEqual(
469
+ await service.listMembersResponse(workspace, options),
470
+ returnJsonApiData(await service.listMembers(workspace, options))
471
+ );
472
+ });
473
+
383
474
  test("workspaceMembersService.listMembers uses the resolved workspace directly", async () => {
384
475
  const { service, workspace } = createFixture();
385
476
 
@@ -387,8 +478,8 @@ test("workspaceMembersService.listMembers uses the resolved workspace directly",
387
478
 
388
479
  assert.deepEqual(response.workspace, {
389
480
  id: "7",
390
- slug: "tonymobily3",
391
- name: "TonyMobily3",
481
+ slug: "tony3",
482
+ name: "Tony3",
392
483
  ownerUserId: "9",
393
484
  avatarUrl: ""
394
485
  });
@@ -399,14 +490,14 @@ test("workspaceMembersService.listMembers uses the resolved workspace directly",
399
490
  test("workspaceMembersService.updateMemberRole returns the refreshed member list without re-fetching the workspace", async () => {
400
491
  const { service, workspace } = createFixture();
401
492
 
402
- const response = await service.updateMemberRole(
493
+ const response = (await service.updateMemberRole(
403
494
  workspace,
404
495
  {
405
496
  memberUserId: "11",
406
497
  roleSid: "admin"
407
498
  },
408
499
  authorizedOptions(["workspace.members.manage"])
409
- );
500
+ )).value;
410
501
 
411
502
  assert.equal(response.members.length, 1);
412
503
  assert.equal(response.members[0].roleSid, "member");
@@ -416,12 +507,14 @@ test("workspaceMembersService.removeMember marks membership revoked and returns
416
507
  let removed = false;
417
508
  const workspace = {
418
509
  id: "7",
419
- slug: "tonymobily3",
420
- name: "TonyMobily3",
510
+ slug: "tony3",
511
+ name: "Tony3",
421
512
  ownerUserId: "9",
422
513
  avatarUrl: ""
423
514
  };
424
515
  const service = createService({
516
+ workspaceInvitationsEnabled: true,
517
+ i18n: createWorkspacesServerI18n(),
425
518
  workspaceMembershipsRepository: {
426
519
  async listActiveByWorkspaceId(workspaceId) {
427
520
  assert.equal(Number(workspaceId), 7);
@@ -469,16 +562,19 @@ test("workspaceMembersService.removeMember marks membership revoked and returns
469
562
  async revokeById() {}
470
563
  },
471
564
  inviteExpiresInMs: 7 * 24 * 60 * 60 * 1000,
472
- roleCatalog: createRoleCatalog()
565
+ roleCatalog: createRoleCatalog(),
566
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
567
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
568
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
473
569
  });
474
570
 
475
- const response = await service.removeMember(
571
+ const response = (await service.removeMember(
476
572
  workspace,
477
573
  {
478
574
  memberUserId: "11"
479
575
  },
480
576
  authorizedOptions(["workspace.members.manage"])
481
- );
577
+ )).value;
482
578
 
483
579
  assert.equal(response.members.length, 0);
484
580
  });
@@ -486,12 +582,14 @@ test("workspaceMembersService.removeMember marks membership revoked and returns
486
582
  test("workspaceMembersService.removeMember rejects removing the owner", async () => {
487
583
  const workspace = {
488
584
  id: "7",
489
- slug: "tonymobily3",
490
- name: "TonyMobily3",
585
+ slug: "tony3",
586
+ name: "Tony3",
491
587
  ownerUserId: "9",
492
588
  avatarUrl: ""
493
589
  };
494
590
  const service = createService({
591
+ workspaceInvitationsEnabled: true,
592
+ i18n: createWorkspacesServerI18n(),
495
593
  workspaceMembershipsRepository: {
496
594
  async listActiveByWorkspaceId() {
497
595
  return [];
@@ -522,7 +620,10 @@ test("workspaceMembersService.removeMember rejects removing the owner", async ()
522
620
  async revokeById() {}
523
621
  },
524
622
  inviteExpiresInMs: 7 * 24 * 60 * 60 * 1000,
525
- roleCatalog: createRoleCatalog()
623
+ roleCatalog: createRoleCatalog(),
624
+ inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: {}, env: {} }),
625
+ workspaceInviteMailer: unconfiguredWorkspaceInviteMailer,
626
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
526
627
  });
527
628
 
528
629
  await assert.rejects(
@@ -541,6 +642,7 @@ test("workspaceMembersService.removeMember rejects removing the owner", async ()
541
642
  function createMailingService({ accounts = {}, appRoot = "", mailer } = {}) {
542
643
  let savedInvite;
543
644
  return createService({
645
+ workspaceInvitationsEnabled: true,
544
646
  workspaceMembershipsRepository: {
545
647
  async listActiveByWorkspaceId() {
546
648
  return [];
@@ -581,7 +683,8 @@ function createMailingService({ accounts = {}, appRoot = "", mailer } = {}) {
581
683
  }
582
684
  return account ? { locale: account.locale } : null;
583
685
  }
584
- }
686
+ },
687
+ workspaceInviteEmailTemplate: renderDefaultWorkspaceInviteEmail
585
688
  });
586
689
  }
587
690
 
@@ -644,12 +747,12 @@ test("workspaceMembersService reports a failed delivery with a message code", as
644
747
  }
645
748
  }
646
749
  });
647
- const response = await service.createInvite(
750
+ const response = (await service.createInvite(
648
751
  { id: "7", slug: "acme", name: "Acme", ownerUserId: "9", avatarUrl: "" },
649
752
  { id: "9" },
650
753
  { email: "invitee@example.com", roleSid: "member" },
651
754
  authorizedOptions()
652
- );
755
+ )).value;
653
756
  assert.deepEqual(response.inviteDelivery, {
654
757
  status: "failed",
655
758
  message: "Unable to send workspace invite email.",
@@ -13,8 +13,8 @@ test("workspacePendingInvitationsResource output schema accepts already-shaped i
13
13
  {
14
14
  id: "10",
15
15
  workspaceId: "3",
16
- workspaceSlug: "tonymobily3",
17
- workspaceName: "TonyMobily3",
16
+ workspaceSlug: "tony3",
17
+ workspaceName: "Tony3",
18
18
  workspaceAvatarUrl: "",
19
19
  roleSid: "member",
20
20
  status: "pending",
@@ -1,7 +1,10 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
4
+ import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
4
5
  import { createService } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsService.js";
6
+ import { noInviteAcceptanceParticipant } from "../src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js";
7
+ import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
5
8
 
6
9
  function createFixture({
7
10
  pendingInvitesByEmail = [],
@@ -53,7 +56,9 @@ function createFixture({
53
56
  payload: payload && typeof payload === "object" ? { ...payload } : payload
54
57
  });
55
58
  }
56
- }
59
+ },
60
+ roleCatalog: createWorkspaceRoleCatalog(),
61
+ acceptanceParticipant: noInviteAcceptanceParticipant
57
62
  });
58
63
 
59
64
  return {
@@ -68,15 +73,15 @@ function createFixture({
68
73
  };
69
74
  }
70
75
 
71
- test("listPendingInvitesForUser returns raw pending invite rows for the action layer to shape", async () => {
76
+ test("listPendingInvitesForUser returns raw pending invite rows", async () => {
72
77
  const tokenHash = "a".repeat(64);
73
78
  const { service } = createFixture({
74
79
  pendingInvitesByEmail: [
75
80
  {
76
81
  id: "10",
77
82
  workspaceId: "1",
78
- workspaceSlug: "tonymobily3",
79
- workspaceName: "TonyMobily3",
83
+ workspaceSlug: "tony3",
84
+ workspaceName: "Tony3",
80
85
  workspaceAvatarUrl: "",
81
86
  roleSid: "member",
82
87
  status: "pending",
@@ -88,15 +93,39 @@ test("listPendingInvitesForUser returns raw pending invite rows for the action l
88
93
 
89
94
  const pendingInvites = await service.listPendingInvitesForUser({
90
95
  id: "7",
91
- email: "chiaramobily@gmail.com"
96
+ email: "chiara@example.com"
92
97
  });
93
98
 
94
99
  assert.equal(pendingInvites.length, 1);
95
100
  assert.equal(pendingInvites[0].token, encodeInviteTokenHash(tokenHash));
96
- assert.equal(pendingInvites[0].workspaceName, "TonyMobily3");
101
+ assert.equal(pendingInvites[0].workspaceName, "Tony3");
97
102
  assert.equal(pendingInvites[0].status, "pending");
98
103
  });
99
104
 
105
+ test("listPendingInvitesResponse answers the pending list action with the rows as JSON:API data", async () => {
106
+ const { service } = createFixture({
107
+ pendingInvitesByEmail: [
108
+ {
109
+ id: "10",
110
+ workspaceId: "1",
111
+ workspaceSlug: "tony3",
112
+ workspaceName: "Tony3",
113
+ workspaceAvatarUrl: "",
114
+ roleSid: "member",
115
+ status: "pending",
116
+ expiresAt: "2030-01-01T00:00:00.000Z",
117
+ tokenHash: "b".repeat(64)
118
+ }
119
+ ]
120
+ });
121
+ const user = { id: "7", email: "chiara@example.com" };
122
+
123
+ assert.deepEqual(
124
+ await service.listPendingInvitesResponse(user),
125
+ returnJsonApiData({ pendingInvites: await service.listPendingInvitesForUser(user) })
126
+ );
127
+ });
128
+
100
129
  test("resolveInviteByToken returns safe public invite metadata without mutating the invite", async () => {
101
130
  const tokenHash = "d".repeat(64);
102
131
  const encodedToken = encodeInviteTokenHash(tokenHash);
@@ -117,7 +146,7 @@ test("resolveInviteByToken returns safe public invite metadata without mutating
117
146
  }
118
147
  });
119
148
 
120
- const resolved = await service.resolveInviteByToken(encodedToken);
149
+ const resolved = (await service.resolveInviteByToken(encodedToken)).value;
121
150
 
122
151
  assert.deepEqual(calls.resolveTokenHashCalls, [tokenHash]);
123
152
  assert.deepEqual(calls.revokeCalls, []);
@@ -157,8 +186,8 @@ test("resolveInviteByToken reports expired and missing invitations as terminal s
157
186
  }
158
187
  });
159
188
 
160
- const expired = await service.resolveInviteByToken(expiredToken);
161
- const missing = await service.resolveInviteByToken(missingToken);
189
+ const expired = (await service.resolveInviteByToken(expiredToken)).value;
190
+ const missing = (await service.resolveInviteByToken(missingToken)).value;
162
191
 
163
192
  assert.equal(expired.status, "expired");
164
193
  assert.equal(missing.status, "not_found");
@@ -219,7 +248,7 @@ test("acceptInviteByToken accepts opaque invite token and resolves invite by dec
219
248
  [tokenHash]: {
220
249
  id: "44",
221
250
  workspaceId: "1",
222
- email: "chiaramobily@gmail.com",
251
+ email: "chiara@example.com",
223
252
  roleSid: "member",
224
253
  status: "pending",
225
254
  tokenHash,
@@ -231,7 +260,7 @@ test("acceptInviteByToken accepts opaque invite token and resolves invite by dec
231
260
  const response = await service.acceptInviteByToken({
232
261
  user: {
233
262
  id: "7",
234
- email: "chiaramobily@gmail.com",
263
+ email: "chiara@example.com",
235
264
  displayName: "Chiara"
236
265
  },
237
266
  token: encodedToken
@@ -253,7 +282,7 @@ test("refuseInviteByToken revokes the invite and returns refused", async () => {
253
282
  [tokenHash]: {
254
283
  id: "45",
255
284
  workspaceId: "1",
256
- email: "chiaramobily@gmail.com",
285
+ email: "chiara@example.com",
257
286
  roleSid: "member",
258
287
  status: "pending",
259
288
  tokenHash,
@@ -265,7 +294,7 @@ test("refuseInviteByToken revokes the invite and returns refused", async () => {
265
294
  const response = await service.refuseInviteByToken({
266
295
  user: {
267
296
  id: "7",
268
- email: "chiaramobily@gmail.com",
297
+ email: "chiara@example.com",
269
298
  displayName: "Chiara"
270
299
  },
271
300
  token: encodedToken
@@ -279,14 +308,30 @@ test("refuseInviteByToken revokes the invite and returns refused", async () => {
279
308
  assert.equal(response.workspaceId, "1");
280
309
  });
281
310
 
282
- test("participant registration rejects invalid, duplicate and late registration", async () => {
283
- const { service } = createFixture();
284
- assert.throws(() => service.registerAcceptanceParticipant(null), /must be a function/);
285
- service.registerAcceptanceParticipant(async () => {});
286
- assert.throws(() => service.registerAcceptanceParticipant(async () => {}), /already registered/);
287
- const { service: late } = createFixture();
288
- await assert.rejects(late.acceptInviteByToken(), /Authentication required/);
289
- assert.throws(() => late.registerAcceptanceParticipant(async () => {}), /registration is closed/);
311
+ test("redeemInviteByToken accepts on an accept decision and refuses on any other", async () => {
312
+ const user = { id: "7", email: "chiara@example.com", displayName: "Chiara" };
313
+ for (const [decision, expected] of [["accept", "accepted"], ["refuse", "refused"]]) {
314
+ const tokenHash = "d".repeat(64);
315
+ const { service, calls } = createFixture({
316
+ inviteByTokenHash: {
317
+ [tokenHash]: {
318
+ id: "46",
319
+ workspaceId: "1",
320
+ email: user.email,
321
+ roleSid: "member",
322
+ status: "pending",
323
+ tokenHash,
324
+ expiresAt: "2030-01-01T00:00:00.000Z"
325
+ }
326
+ }
327
+ });
328
+
329
+ const response = (await service.redeemInviteByToken(user, { token: encodeInviteTokenHash(tokenHash), decision })).value;
330
+
331
+ assert.equal(response.decision, expected);
332
+ assert.deepEqual(calls.acceptCalls, decision === "accept" ? [46] : []);
333
+ assert.deepEqual(calls.revokeCalls, decision === "accept" ? [] : [46]);
334
+ }
290
335
  });
291
336
 
292
337
  for (const transactionOutcome of ["committed", "unknown", "rolledBack"]) {
@@ -302,7 +347,9 @@ for (const transactionOutcome of ["committed", "unknown", "rolledBack"]) {
302
347
  throw failure;
303
348
  }
304
349
  },
305
- workspaceMembershipsRepository: {}
350
+ workspaceMembershipsRepository: {},
351
+ roleCatalog: createWorkspaceRoleCatalog(),
352
+ acceptanceParticipant: noInviteAcceptanceParticipant
306
353
  });
307
354
  await assert.rejects(service.acceptInviteByToken({ user: { id: "1", email: "a@example.test" }, token: "valid-token" }),
308
355
  (error) => error === failure && error.cause === cause);