@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.
- package/package.descriptor.mjs +38 -5
- package/package.json +9 -9
- package/src/server/common/repositories/workspaceInvitesRepository.js +18 -0
- package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +42 -0
- package/src/server/workspaceMembers/registerWorkspaceMembers.js +30 -1
- package/src/server/workspaceMembers/workspaceInviteUrls.js +65 -0
- package/src/server/workspaceMembers/workspaceMembersService.js +82 -2
- package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +36 -0
- package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +11 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +22 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +92 -0
- package/src/shared/jsonApiTransports.js +7 -0
- package/src/shared/resources/workspaceMembersResource.js +24 -0
- package/src/shared/resources/workspacePendingInvitationsResource.js +44 -0
- package/templates/packages/main/src/server/email/workspaceInviteEmail.js +23 -0
- package/test/packageDescriptor.test.js +75 -0
- package/test/registerWorkspaceMembers.test.js +140 -0
- package/test/resourcesCanonical.test.js +4 -1
- package/test/usersRouteResources.test.js +4 -1
- package/test/workspaceInvitesRepository.test.js +35 -0
- package/test/workspaceMembersResource.test.js +8 -0
- package/test/workspaceMembersService.test.js +123 -0
- package/test/workspacePendingInvitationsResource.test.js +18 -0
- package/test/workspacePendingInvitationsService.test.js +126 -1
- package/test/workspacesRouteRequestInputValidator.test.js +37 -5
|
@@ -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]
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
assert.deepEqual(calls[
|
|
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 () => {
|