@jskit-ai/workspaces-core 0.1.81 → 0.1.83

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
@@ -87,6 +87,30 @@ const workspaceInviteCreateOutputSchema = createSchema({
87
87
  required: true,
88
88
  minLength: 1
89
89
  },
90
+ inviteUrl: {
91
+ type: "string",
92
+ required: true,
93
+ minLength: 1
94
+ },
95
+ inviteDelivery: {
96
+ type: "object",
97
+ required: true,
98
+ schema: createSchema({
99
+ status: {
100
+ type: "string",
101
+ required: true,
102
+ enum: ["sent", "failed", "mailer_unconfigured"]
103
+ },
104
+ message: {
105
+ type: "string",
106
+ required: true
107
+ },
108
+ providerMessageId: {
109
+ type: "string",
110
+ required: true
111
+ }
112
+ })
113
+ },
90
114
  createdInviteId: {
91
115
  type: "string",
92
116
  required: true,
@@ -3,6 +3,45 @@ import { createSchema } from "json-rest-schema";
3
3
  import { defineResource } from "@jskit-ai/resource-core/shared/resource";
4
4
  import { createOperationMessages } from "../operationMessages.js";
5
5
 
6
+ const invitationTokenQuerySchema = createSchema({
7
+ token: {
8
+ type: "string",
9
+ required: true,
10
+ minLength: 1,
11
+ messages: {
12
+ required: "Invite token is required.",
13
+ minLength: "Invite token is required.",
14
+ default: "Invite token is invalid."
15
+ }
16
+ }
17
+ });
18
+
19
+ const invitationWorkspaceSummarySchema = createSchema({
20
+ id: { type: "string", required: true },
21
+ slug: { type: "string", required: true },
22
+ name: { type: "string", required: true },
23
+ avatarUrl: { type: "string", required: true }
24
+ });
25
+
26
+ const invitationResolveOutputSchema = createSchema({
27
+ id: { type: "string", required: true },
28
+ token: { type: "string", required: true, minLength: 1 },
29
+ status: {
30
+ type: "string",
31
+ required: true,
32
+ enum: ["pending", "expired", "accepted", "revoked", "not_found"]
33
+ },
34
+ email: { type: "string", required: true },
35
+ maskedEmail: { type: "string", required: true },
36
+ roleSid: { type: "string", required: true },
37
+ expiresAt: { type: "string", required: false, nullable: true, minLength: 1 },
38
+ workspace: {
39
+ type: "object",
40
+ required: true,
41
+ schema: invitationWorkspaceSummarySchema
42
+ }
43
+ });
44
+
6
45
  const pendingInvitationsListOutputSchema = createSchema({
7
46
  pendingInvites: {
8
47
  type: "array",
@@ -25,6 +64,11 @@ const workspacePendingInvitationsResource = defineResource({
25
64
  namespace: "workspacePendingInvitations",
26
65
  messages: createOperationMessages(),
27
66
  operations: {
67
+ resolve: {
68
+ method: "GET",
69
+ query: invitationTokenQuerySchema,
70
+ output: invitationResolveOutputSchema
71
+ },
28
72
  list: {
29
73
  method: "GET",
30
74
  output: pendingInvitationsListOutputSchema
@@ -0,0 +1,23 @@
1
+ function renderWorkspaceInviteEmail({
2
+ inviteUrl = "",
3
+ workspace = {},
4
+ inviter = null,
5
+ roleSid = "member",
6
+ expiresAt = ""
7
+ } = {}) {
8
+ const workspaceName = String(workspace?.name || workspace?.slug || "the workspace").trim() || "the workspace";
9
+ const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
10
+ const roleName = String(roleSid || "member").trim() || "member";
11
+ const intro = inviterName
12
+ ? `${inviterName} invited you to join ${workspaceName} as ${roleName}.`
13
+ : `You have been invited to join ${workspaceName} as ${roleName}.`;
14
+ const expiryText = expiresAt ? `This invitation expires at ${expiresAt}.` : "";
15
+
16
+ return {
17
+ subject: `You're invited to ${workspaceName}`,
18
+ text: [intro, expiryText, "Open this link to accept the invitation:", inviteUrl].filter(Boolean).join("\n\n"),
19
+ html: null
20
+ };
21
+ }
22
+
23
+ export { renderWorkspaceInviteEmail };
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+ import descriptor from "../package.descriptor.mjs";
7
+
8
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
9
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
10
+
11
+ function findRoute(method, routePath) {
12
+ const routes = descriptor?.metadata?.server?.routes;
13
+ return Array.isArray(routes)
14
+ ? routes.find((entry) => entry?.method === method && entry?.path === routePath) || null
15
+ : null;
16
+ }
17
+
18
+ function findFileMutation(id) {
19
+ const fileMutations = descriptor?.mutations?.files;
20
+ return Array.isArray(fileMutations)
21
+ ? fileMutations.find((entry) => String(entry?.id || "").trim() === id) || null
22
+ : null;
23
+ }
24
+
25
+ function findSourceMutation(id) {
26
+ const sourceMutations = descriptor?.mutations?.source;
27
+ return Array.isArray(sourceMutations)
28
+ ? sourceMutations.find((entry) => String(entry?.id || "").trim() === id) || null
29
+ : null;
30
+ }
31
+
32
+ test("workspaces-core descriptor advertises public invite resolution route metadata", () => {
33
+ assert.deepEqual(findRoute("GET", "/api/workspace/invitations/resolve"), {
34
+ method: "GET",
35
+ path: "/api/workspace/invitations/resolve",
36
+ summary: "Resolve safe public workspace invitation metadata."
37
+ });
38
+ });
39
+
40
+ test("workspaces-core installs an app-owned editable workspace invite email template", async () => {
41
+ const source = await readFile(
42
+ path.join(PACKAGE_DIR, "templates", "packages", "main", "src", "server", "email", "workspaceInviteEmail.js"),
43
+ "utf8"
44
+ );
45
+
46
+ assert.match(source, /function renderWorkspaceInviteEmail/);
47
+ assert.match(source, /export \{ renderWorkspaceInviteEmail \}/);
48
+ assert.match(source, /inviteUrl/);
49
+ assert.deepEqual(findFileMutation("workspaces-core-main-workspace-invite-email-template"), {
50
+ from: "templates/packages/main/src/server/email/workspaceInviteEmail.js",
51
+ to: "packages/main/src/server/email/workspaceInviteEmail.js",
52
+ preserveOnRemove: true,
53
+ reason: "Install app-owned editable workspace invite email template.",
54
+ category: "workspaces-core",
55
+ id: "workspaces-core-main-workspace-invite-email-template"
56
+ });
57
+ assert.deepEqual(findSourceMutation("workspaces-core-server-config-workspace-invite-email-import"), {
58
+ op: "ensure-import",
59
+ file: "config/server.js",
60
+ namedImports: ["renderWorkspaceInviteEmail"],
61
+ from: "../packages/main/src/server/email/workspaceInviteEmail.js",
62
+ reason: "Load app-owned workspace invite email renderer from packages/main.",
63
+ category: "workspaces-core",
64
+ id: "workspaces-core-server-config-workspace-invite-email-import"
65
+ });
66
+ assert.deepEqual(findSourceMutation("workspaces-core-server-config-workspace-invite-email-template"), {
67
+ op: "ensure-assignment",
68
+ file: "config/server.js",
69
+ target: "config.workspaceInviteEmailTemplate",
70
+ value: "renderWorkspaceInviteEmail",
71
+ reason: "Bind app-owned workspace invite email renderer into server config.",
72
+ category: "workspaces-core",
73
+ id: "workspaces-core-server-config-workspace-invite-email-template"
74
+ });
75
+ });
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { registerWorkspaceMembers } from "../src/server/workspaceMembers/registerWorkspaceMembers.js";
4
+
5
+ function createAppDouble() {
6
+ const serviceCalls = [];
7
+
8
+ return {
9
+ serviceCalls,
10
+ app: {
11
+ singleton() {
12
+ return this;
13
+ },
14
+ service(token, factory, metadata) {
15
+ serviceCalls.push({
16
+ token,
17
+ factory,
18
+ metadata
19
+ });
20
+ return this;
21
+ },
22
+ actions() {
23
+ return this;
24
+ }
25
+ }
26
+ };
27
+ }
28
+
29
+ function createScopeDouble(bindings = {}) {
30
+ return {
31
+ has(token) {
32
+ return Object.hasOwn(bindings, token);
33
+ },
34
+ make(token) {
35
+ if (!Object.hasOwn(bindings, token)) {
36
+ throw new Error(`Missing test binding: ${token}`);
37
+ }
38
+ const binding = bindings[token];
39
+ return typeof binding === "function" && token !== "appConfig" ? binding(this) : binding;
40
+ }
41
+ };
42
+ }
43
+
44
+ test("registerWorkspaceMembers uses app-owned workspace invite email template from appConfig", async () => {
45
+ const templateCalls = [];
46
+ const mailerCalls = [];
47
+ const fixture = createAppDouble();
48
+ registerWorkspaceMembers(fixture.app);
49
+ const serviceCall = fixture.serviceCalls.find((entry) => entry.token === "workspaces.members.service");
50
+
51
+ const service = serviceCall.factory(createScopeDouble({
52
+ appConfig: {
53
+ workspaceMembers: {
54
+ defaults: {
55
+ inviteExpiresInMs: 60_000
56
+ }
57
+ },
58
+ roleCatalog: {
59
+ workspace: {
60
+ defaultInviteRole: "member"
61
+ },
62
+ roles: {
63
+ owner: {
64
+ assignable: false,
65
+ permissions: ["*"]
66
+ },
67
+ member: {
68
+ assignable: true,
69
+ permissions: ["workspace.members.view"]
70
+ }
71
+ }
72
+ },
73
+ workspaceInviteEmailTemplate(payload) {
74
+ templateCalls.push(payload);
75
+ return {
76
+ subject: "Custom invite",
77
+ text: payload.inviteUrl,
78
+ html: null
79
+ };
80
+ }
81
+ },
82
+ "jskit.env": {
83
+ APP_PUBLIC_URL: "https://app.example.test"
84
+ },
85
+ "workspaces.invitations.enabled": true,
86
+ "workspaces.invite.mailer": {
87
+ async sendWorkspaceInvite(payload) {
88
+ mailerCalls.push(payload);
89
+ return {
90
+ status: "sent",
91
+ message: "sent",
92
+ providerMessageId: "msg-custom"
93
+ };
94
+ }
95
+ },
96
+ "internal.repository.workspace-memberships": {
97
+ async listActiveByWorkspaceId() {
98
+ return [];
99
+ }
100
+ },
101
+ "internal.repository.workspace-invites": {
102
+ async listPendingByWorkspaceIdWithWorkspace() {
103
+ return [];
104
+ },
105
+ async expirePendingByWorkspaceIdAndEmail() {},
106
+ async insert(payload) {
107
+ return {
108
+ id: "91",
109
+ expiresAt: payload.expiresAt
110
+ };
111
+ },
112
+ async findPendingByIdForWorkspace() {
113
+ return null;
114
+ },
115
+ async revokeById() {}
116
+ }
117
+ }));
118
+
119
+ const result = await service.createInvite(
120
+ {
121
+ id: "7",
122
+ slug: "acme",
123
+ name: "Acme",
124
+ ownerUserId: "9",
125
+ avatarUrl: ""
126
+ },
127
+ { id: "9", email: "owner@example.com" },
128
+ {
129
+ email: "invitee@example.com",
130
+ roleSid: "member"
131
+ }
132
+ );
133
+
134
+ assert.equal(result.inviteDelivery.providerMessageId, "msg-custom");
135
+ assert.equal(templateCalls.length, 1);
136
+ assert.equal(templateCalls[0].workspace.name, "Acme");
137
+ assert.equal(mailerCalls.length, 1);
138
+ assert.equal(mailerCalls[0].message.subject, "Custom invite");
139
+ assert.match(mailerCalls[0].inviteUrl, /^https:\/\/app\.example\.test\/invite\//);
140
+ });
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
7
7
  import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
8
+ import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
8
9
  import { workspaceInvitesResource } from "../src/shared/resources/workspaceInvitesResource.js";
9
10
  import { workspaceMembershipsResource } from "../src/shared/resources/workspaceMembershipsResource.js";
10
11
  import { workspaceResource } from "../src/shared/resources/workspaceResource.js";
@@ -78,7 +79,9 @@ test("workspaces-core specialized resource operations expose messages and valida
78
79
  { label: "workspaceMembers.invitesList", operation: workspaceMembersResource.operations.invitesList },
79
80
  { label: "workspaceMembers.createInvite", operation: workspaceMembersResource.operations.createInvite },
80
81
  { label: "workspaceMembers.revokeInvite", operation: workspaceMembersResource.operations.revokeInvite },
81
- { label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite }
82
+ { label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite },
83
+ { label: "workspacePendingInvitations.resolve", operation: workspacePendingInvitationsResource.operations.resolve },
84
+ { label: "workspacePendingInvitations.list", operation: workspacePendingInvitationsResource.operations.list }
82
85
  ];
83
86
 
84
87
  for (const { label, operation } of operationSpecs) {
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { deriveResourceRequiredMetadata } from "@jskit-ai/kernel/_testable";
7
7
  import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
8
8
  import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
9
+ import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
9
10
  import { workspaceResource } from "../src/shared/resources/workspaceResource.js";
10
11
  import { workspaceSettingsResource } from "../src/shared/resources/workspaceSettingsResource.js";
11
12
 
@@ -67,7 +68,9 @@ test("workspace settings and invite operations expose canonical validators", ()
67
68
  { label: "workspaceMembers.invitesList", operation: workspaceMembersResource.operations.invitesList },
68
69
  { label: "workspaceMembers.createInvite", operation: workspaceMembersResource.operations.createInvite },
69
70
  { label: "workspaceMembers.revokeInvite", operation: workspaceMembersResource.operations.revokeInvite },
70
- { label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite }
71
+ { label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite },
72
+ { label: "workspacePendingInvitations.resolve", operation: workspacePendingInvitationsResource.operations.resolve },
73
+ { label: "workspacePendingInvitations.list", operation: workspacePendingInvitationsResource.operations.list }
71
74
  ];
72
75
 
73
76
  for (const { label, operation } of operationSpecs) {
@@ -173,6 +173,41 @@ test("workspaceInvitesRepository.findPendingByTokenHash reads from the canonical
173
173
  assert.equal(invite?.workspaceSlug, undefined);
174
174
  });
175
175
 
176
+ test("workspaceInvitesRepository.findByTokenHashWithWorkspace preserves workspace summary fields for public invite resolution", async () => {
177
+ const { api } = createWorkspaceInvitesApiStub({
178
+ rows: [
179
+ {
180
+ id: "45",
181
+ workspace: {
182
+ id: "9",
183
+ slug: "tonymobily3",
184
+ name: "TonyMobily3",
185
+ avatarUrl: "https://example.com/avatar.png"
186
+ },
187
+ email: "invitee@example.com",
188
+ roleSid: "member",
189
+ status: "accepted",
190
+ tokenHash: "hash-token",
191
+ invitedByUser: { id: "1" },
192
+ expiresAt: "2030-01-01 00:00:00.000",
193
+ acceptedAt: "2026-03-10 00:00:00.000",
194
+ revokedAt: null,
195
+ createdAt: "2026-03-09 00:26:35.710",
196
+ updatedAt: "2026-03-10 00:00:00.000"
197
+ }
198
+ ]
199
+ });
200
+ const repository = createRepository({ api, knex: createKnexStub() });
201
+
202
+ const invite = await repository.findByTokenHashWithWorkspace("hash-token");
203
+
204
+ assert.equal(invite?.id, "45");
205
+ assert.equal(invite?.status, "accepted");
206
+ assert.equal(invite?.workspaceSlug, "tonymobily3");
207
+ assert.equal(invite?.workspaceName, "TonyMobily3");
208
+ assert.equal(invite?.workspaceAvatarUrl, "https://example.com/avatar.png");
209
+ });
210
+
176
211
  test("workspaceInvitesRepository.markAcceptedById uses the internal invite resource for normalized patch writes", async () => {
177
212
  const { api, state } = createWorkspaceInvitesApiStub({
178
213
  rowById: new Map([
@@ -29,6 +29,14 @@ test("workspace members invite mutation outputs expose their tracking ids explic
29
29
 
30
30
  assert.equal(createOutputSchema.properties.createdInviteId.type, "string");
31
31
  assert.equal(createOutputSchema.properties.inviteTokenPreview.type, "string");
32
+ assert.equal(createOutputSchema.properties.inviteUrl.type, "string");
33
+ const inviteDeliverySchemaName = createOutputSchema.properties.inviteDelivery.allOf[0].$ref.replace(/^#\/definitions\//, "");
34
+ const inviteDeliverySchema = createOutputSchema.definitions[inviteDeliverySchemaName];
35
+ assert.deepEqual(inviteDeliverySchema.required, ["status", "message", "providerMessageId"]);
36
+ assert.deepEqual(
37
+ inviteDeliverySchema.properties.status.enum,
38
+ ["sent", "failed", "mailer_unconfigured"]
39
+ );
32
40
  assert.equal(revokeOutputSchema.properties.revokedInviteId.type, "string");
33
41
  assert.equal(revokeOutputSchema.properties.roleCatalog["x-json-rest-schema"]?.castType, "object");
34
42
  assert.equal(Array.isArray(revokeOutputSchema.properties.roleCatalog.allOf), true);
@@ -147,6 +147,101 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
147
147
  assert.ok(expiresAt >= before + 30 * 60 * 1000);
148
148
  assert.ok(expiresAt <= after + 30 * 60 * 1000);
149
149
  assert.equal(response.createdInviteId, "31");
150
+ assert.equal(response.inviteUrl, `/invite/${encodeURIComponent(response.inviteTokenPreview)}`);
151
+ assert.deepEqual(response.inviteDelivery, {
152
+ status: "mailer_unconfigured",
153
+ message: "No workspace invite mailer is configured.",
154
+ providerMessageId: ""
155
+ });
156
+ });
157
+
158
+ test("workspaceMembersService.createInvite sends generated invite URLs through the configured mailer", async () => {
159
+ const templateCalls = [];
160
+ const mailerCalls = [];
161
+ const service = createService({
162
+ workspaceMembershipsRepository: {
163
+ async listActiveByWorkspaceId() {
164
+ return [];
165
+ }
166
+ },
167
+ workspaceInvitesRepository: {
168
+ async expirePendingByWorkspaceIdAndEmail(workspaceId, email) {
169
+ assert.equal(workspaceId, "7");
170
+ assert.equal(email, "invitee@example.com");
171
+ },
172
+ async insert(payload) {
173
+ assert.equal(payload.workspaceId, "7");
174
+ assert.equal(payload.email, "invitee@example.com");
175
+ assert.equal(payload.roleSid, "admin");
176
+ return {
177
+ id: "88",
178
+ expiresAt: payload.expiresAt
179
+ };
180
+ },
181
+ async listPendingByWorkspaceIdWithWorkspace() {
182
+ return [];
183
+ },
184
+ async findPendingByIdForWorkspace() {
185
+ return null;
186
+ },
187
+ async revokeById() {}
188
+ },
189
+ inviteExpiresInMs: 30 * 60 * 1000,
190
+ roleCatalog: createRoleCatalog(),
191
+ inviteUrlBuilder({ token, workspace }) {
192
+ return `https://app.example.test/invite/${encodeURIComponent(token)}?workspace=${workspace.slug}`;
193
+ },
194
+ workspaceInviteEmailTemplate(payload) {
195
+ templateCalls.push(payload);
196
+ return {
197
+ subject: `Join ${payload.workspace.name}`,
198
+ text: payload.inviteUrl,
199
+ html: null
200
+ };
201
+ },
202
+ workspaceInviteMailer: {
203
+ async sendWorkspaceInvite(payload, options) {
204
+ mailerCalls.push({ payload, options });
205
+ return {
206
+ status: "sent",
207
+ message: "queued",
208
+ providerMessageId: "msg-1"
209
+ };
210
+ }
211
+ }
212
+ });
213
+
214
+ const response = await service.createInvite(
215
+ {
216
+ id: "7",
217
+ slug: "acme",
218
+ name: "Acme",
219
+ ownerUserId: "9",
220
+ avatarUrl: ""
221
+ },
222
+ { id: "11", email: "owner@example.com" },
223
+ {
224
+ email: "Invitee@Example.com",
225
+ roleSid: "admin"
226
+ },
227
+ authorizedOptions(["workspace.members.invite"])
228
+ );
229
+
230
+ assert.equal(response.createdInviteId, "88");
231
+ assert.match(response.inviteUrl, /^https:\/\/app\.example\.test\/invite\//);
232
+ assert.deepEqual(response.inviteDelivery, {
233
+ status: "sent",
234
+ message: "queued",
235
+ providerMessageId: "msg-1"
236
+ });
237
+ assert.equal(templateCalls.length, 1);
238
+ assert.equal(templateCalls[0].email, "invitee@example.com");
239
+ assert.equal(templateCalls[0].workspace.name, "Acme");
240
+ assert.equal(templateCalls[0].inviteUrl, response.inviteUrl);
241
+ assert.equal(mailerCalls.length, 1);
242
+ assert.equal(mailerCalls[0].payload.email, "invitee@example.com");
243
+ assert.equal(mailerCalls[0].payload.inviteUrl, response.inviteUrl);
244
+ assert.equal(mailerCalls[0].payload.message.subject, "Join Acme");
150
245
  });
151
246
 
152
247
  test("workspaceMembersService.revokeInvite returns the revoked invite id", async () => {
@@ -237,6 +332,34 @@ test("workspaceMembersService rejects invite operations when invitations are dis
237
332
  ),
238
333
  /Workspace invitations are disabled/
239
334
  );
335
+ await assert.rejects(
336
+ () =>
337
+ service.createInvite(
338
+ {
339
+ id: "7",
340
+ ownerUserId: "9"
341
+ },
342
+ { id: "11" },
343
+ {
344
+ email: "alice@example.com",
345
+ roleSid: "member"
346
+ },
347
+ authorizedOptions(["workspace.members.invite"])
348
+ ),
349
+ /Workspace invitations are disabled/
350
+ );
351
+ await assert.rejects(
352
+ () =>
353
+ service.revokeInvite(
354
+ {
355
+ id: "7",
356
+ ownerUserId: "9"
357
+ },
358
+ "47",
359
+ authorizedOptions(["workspace.invites.revoke"])
360
+ ),
361
+ /Workspace invitations are disabled/
362
+ );
240
363
  });
241
364
 
242
365
  test("workspaceMembersService.listMembers uses the resolved workspace directly", async () => {
@@ -31,3 +31,21 @@ test("workspacePendingInvitationsResource output schema accepts already-shaped i
31
31
  assert.equal(Array.isArray(outputSchema.properties.pendingInvites.items.allOf), true);
32
32
  assert.equal(result.pendingInvites[0].roleSid, "member");
33
33
  });
34
+
35
+ test("workspacePendingInvitationsResource exposes public resolve query and terminal statuses", () => {
36
+ const querySchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.resolve.query, {
37
+ context: "workspacePendingInvitations.resolve.query",
38
+ defaultMode: "replace"
39
+ });
40
+ const outputSchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.resolve.output, {
41
+ context: "workspacePendingInvitations.resolve.output",
42
+ defaultMode: "replace"
43
+ });
44
+
45
+ assert.equal(querySchema.properties.token.type, "string");
46
+ assert.deepEqual(
47
+ outputSchema.properties.status.enum,
48
+ ["pending", "expired", "accepted", "revoked", "not_found"]
49
+ );
50
+ assert.equal(outputSchema.properties.workspace["x-json-rest-schema"]?.castType, "object");
51
+ });