@jskit-ai/workspaces-core 0.1.199 → 0.1.201
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.json +9 -9
- package/patterns/workspace-server/PATTERN.md +23 -28
- package/src/server/common/repositories/workspaceInvitesRepository.js +43 -21
- package/src/server/workspaceMembers/workspaceMembersService.js +21 -9
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +37 -19
- package/test/repositories.integration.test.js +0 -1
- package/test/support/invitationTransactions.js +3 -6
- package/test/workspaceMembersService.test.js +9 -12
- package/test/workspacePendingInvitationsService.test.js +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/workspaces-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.201",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
"./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
22
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
23
|
-
"@jskit-ai/resource-core": "0.1.
|
|
24
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
25
|
-
"@jskit-ai/users-core": "0.1.
|
|
21
|
+
"@jskit-ai/database-runtime": "0.1.223",
|
|
22
|
+
"@jskit-ai/json-rest-api-core": "0.1.167",
|
|
23
|
+
"@jskit-ai/resource-core": "0.1.165",
|
|
24
|
+
"@jskit-ai/resource-crud-core": "0.1.165",
|
|
25
|
+
"@jskit-ai/users-core": "0.1.236",
|
|
26
26
|
"json-rest-schema": "^1.0.17"
|
|
27
27
|
},
|
|
28
28
|
"description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
|
|
@@ -169,8 +169,8 @@
|
|
|
169
169
|
}
|
|
170
170
|
},
|
|
171
171
|
"peerDependencies": {
|
|
172
|
-
"@jskit-ai/auth-core": "0.1.
|
|
173
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
174
|
-
"@jskit-ai/kernel": "0.1.
|
|
172
|
+
"@jskit-ai/auth-core": "0.1.221",
|
|
173
|
+
"@jskit-ai/http-runtime": "0.1.221",
|
|
174
|
+
"@jskit-ai/kernel": "0.1.223"
|
|
175
175
|
}
|
|
176
176
|
}
|
|
@@ -30,52 +30,47 @@ contracts exported by `@jskit-ai/workspaces-core`.
|
|
|
30
30
|
|
|
31
31
|
## Transactional invitation participation
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
register one callback with
|
|
33
|
+
Through `workspaces.core`, register one callback during feature setup with
|
|
35
34
|
`workspaces.services.pendingInvitations.registerAcceptanceParticipant(fn)`.
|
|
36
35
|
The built-in `workspace.invite.redeem` action invokes
|
|
37
|
-
`fn({ invite, user }, { trx, context })` after recipient
|
|
38
|
-
and membership
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
For deliberate invitation creation, use `members.prepareInvite(workspace, actor,
|
|
36
|
+
`fn({ invite, user }, { trx, context })` after validating the recipient, state and
|
|
37
|
+
expiry and activating membership in the uncommitted transaction. Forward `trx`
|
|
38
|
+
to every application owner and propagate failures. JSKIT marks accepted and
|
|
39
|
+
commits after the callback succeeds. Keep external effects out of the callback.
|
|
40
|
+
Registration rejects non-functions, duplicates and calls after acceptance/refusal
|
|
41
|
+
begins. Allow ordinary invitations; reject stale, revoked, conflicting or
|
|
42
|
+
unexpectedly missing application associations.
|
|
43
|
+
|
|
44
|
+
Use `members.prepareInvite(workspace, actor,
|
|
47
45
|
payload, { trx, context })` in `repositories.workspaceInvites.withTransaction`
|
|
48
46
|
or the same JSON REST API's managed `transaction`. It returns
|
|
49
47
|
`{ createdInviteId, inviteTokenPreview }` without delivery. Lock the workspace
|
|
50
48
|
with `repositories.workspaceInvites.lockWorkspaceForInvitations(workspace.id,
|
|
51
49
|
{ trx })` before application draft/receipt rows, and save the exact invitation
|
|
52
|
-
association and retry receipt before commit.
|
|
53
|
-
|
|
50
|
+
association and retry receipt before commit. Failure restores the previous
|
|
51
|
+
pending invitation. Keep access drafts separate: preparation creates a real token.
|
|
54
52
|
|
|
55
53
|
After confirmed commit, call `members.sendInvite(workspace, actor, prepared,
|
|
56
54
|
{ context })`. A later attempt can pass only `{ createdInviteId }`. Delivery
|
|
57
|
-
reloads the committed pending invitation and uses
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
idempotency and delivery retries are application-owned; email is not exactly once.
|
|
63
|
-
Do not log the returned token/URL. No SMTP setup is required for testing.
|
|
55
|
+
reloads the committed pending invitation and uses its saved recipient, role and
|
|
56
|
+
expiry. `inviteDelivery` reports `mailer_unconfigured`, `failed` on a renderer/mailer
|
|
57
|
+
exception, or the normalized mailer result. Persistence is unchanged by delivery
|
|
58
|
+
failure. A provider may accept a message before failing; the application owns
|
|
59
|
+
receipts and retries. Keep tokens/URLs out of logs and use controlled mailers in tests.
|
|
64
60
|
|
|
65
61
|
`createInvite`, acceptance and refusal now own managed transactions and reject
|
|
66
|
-
borrowed `options.trx`.
|
|
67
|
-
|
|
62
|
+
borrowed `options.trx`. Use preparation followed by delivery after commit for
|
|
63
|
+
transactional creation; put related acceptance writes in the participant. Revocation may join
|
|
68
64
|
a managed transaction. Raw Knex/savepoint transactions cannot own resource
|
|
69
65
|
writes. Invitation mutations lock workspace then invitation rows, revalidate
|
|
70
|
-
current state and reject
|
|
71
|
-
|
|
66
|
+
current state and reject changes to accepted/revoked invitations. Expiry rejection
|
|
67
|
+
for the matching recipient commits revocation before returning 409. Preserve transaction
|
|
72
68
|
outcomes and causes; a completion-hook error after commit cannot undo writes.
|
|
73
69
|
|
|
74
70
|
See the human guide's **Workspace tenancy → Transactional invitation
|
|
75
71
|
participation** for the full consumer example, errors, controlled mailer,
|
|
76
|
-
compatibility notes and native verification commands. Application services
|
|
77
|
-
|
|
78
|
-
required audit. This seam adds no application domain tables or policy.
|
|
72
|
+
compatibility notes and native verification commands. Application services own
|
|
73
|
+
authorization, identity/training linking, scope eligibility and required audit.
|
|
79
74
|
|
|
80
75
|
## Invariants
|
|
81
76
|
|
|
@@ -88,24 +88,37 @@ function createRepository({ api } = {}) {
|
|
|
88
88
|
if (typeof options.trx !== "function" || includeWorkspace) {
|
|
89
89
|
throw new TypeError("Locking invites requires a transaction and no relationship includes.");
|
|
90
90
|
}
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
// Locking reads must see changes committed while waiting for the lock.
|
|
92
|
+
const fields = {
|
|
93
|
+
id: "id",
|
|
94
|
+
workspace: "workspace_id",
|
|
95
|
+
email: "email",
|
|
96
|
+
status: "status",
|
|
97
|
+
tokenHash: "token_hash"
|
|
98
|
+
};
|
|
94
99
|
const where = Object.fromEntries(Object.entries(filters).map(([key, value]) => {
|
|
95
|
-
if (!fields[key])
|
|
100
|
+
if (!fields[key]) {
|
|
101
|
+
throw new TypeError("Unsupported locking invitation filter.");
|
|
102
|
+
}
|
|
96
103
|
return [fields[key], value];
|
|
97
104
|
}));
|
|
98
|
-
// PostgreSQL's driver
|
|
99
|
-
// time. These owner columns store UTC; preserve their database text just
|
|
100
|
-
// as the resource read path does before toIsoString normalizes it.
|
|
105
|
+
// Read PostgreSQL's UTC timestamps as text to avoid the driver's local-time conversion.
|
|
101
106
|
const dateColumn = (name) => options.trx.client?.config?.client === "pg"
|
|
102
107
|
? options.trx.raw("CAST(?? AS TEXT)", [name])
|
|
103
108
|
: name;
|
|
104
109
|
return options.trx("workspace_invites").where(where).select({
|
|
105
|
-
id: "id",
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
id: "id",
|
|
111
|
+
workspaceId: "workspace_id",
|
|
112
|
+
email: "email",
|
|
113
|
+
roleSid: "role_sid",
|
|
114
|
+
status: "status",
|
|
115
|
+
tokenHash: "token_hash",
|
|
116
|
+
invitedByUserId: "invited_by_user_id",
|
|
117
|
+
expiresAt: dateColumn("expires_at"),
|
|
118
|
+
acceptedAt: dateColumn("accepted_at"),
|
|
119
|
+
revokedAt: dateColumn("revoked_at"),
|
|
120
|
+
createdAt: dateColumn("created_at"),
|
|
121
|
+
updatedAt: dateColumn("updated_at")
|
|
109
122
|
}).forUpdate();
|
|
110
123
|
}
|
|
111
124
|
return extractJsonRestCollectionRows(
|
|
@@ -128,15 +141,17 @@ function createRepository({ api } = {}) {
|
|
|
128
141
|
throw new TypeError("Invitation locking requires an active transaction.");
|
|
129
142
|
}
|
|
130
143
|
const id = normalizeRecordId(workspaceId, { fallback: null });
|
|
131
|
-
if (!id)
|
|
144
|
+
if (!id) {
|
|
145
|
+
throw new TypeError("Invitation locking requires workspaceId.");
|
|
146
|
+
}
|
|
132
147
|
const row = await options.trx("workspaces").where({ id }).forUpdate().first("id");
|
|
133
|
-
if (!row)
|
|
148
|
+
if (!row) {
|
|
149
|
+
throw new AppError(404, "Invitation workspace no longer exists.");
|
|
150
|
+
}
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
async function findPendingByTokenHash(tokenHash, options = {}) {
|
|
137
154
|
if (options.lock === true) {
|
|
138
|
-
// The service locates the workspace before opening its transaction, then
|
|
139
|
-
// revalidates this token against current state under workspace/invite locks.
|
|
140
155
|
await lockWorkspaceForInvitations(options.workspaceId, options);
|
|
141
156
|
}
|
|
142
157
|
const filters = options.lock === true
|
|
@@ -146,8 +161,7 @@ function createRepository({ api } = {}) {
|
|
|
146
161
|
status: "pending"
|
|
147
162
|
}
|
|
148
163
|
: { tokenHash: normalizeText(tokenHash), status: "pending" };
|
|
149
|
-
//
|
|
150
|
-
// hashes out of native lock-error SQL. Still recheck token correspondence.
|
|
164
|
+
// Lock by ID so database errors cannot expose token hashes; compare the token here.
|
|
151
165
|
const rows = await queryInvites(filters, options);
|
|
152
166
|
const invite = normalizeInviteRecord(rows[0] || null);
|
|
153
167
|
return invite?.tokenHash === normalizeText(tokenHash) ? invite : null;
|
|
@@ -314,18 +328,24 @@ function createRepository({ api } = {}) {
|
|
|
314
328
|
|
|
315
329
|
async function transitionPendingById(inviteId, status, options = {}) {
|
|
316
330
|
const id = normalizeRecordId(inviteId, { fallback: null });
|
|
317
|
-
if (!id)
|
|
331
|
+
if (!id) {
|
|
332
|
+
throw new TypeError("Invitation transition requires inviteId.");
|
|
333
|
+
}
|
|
318
334
|
let workspaceId = options.workspaceId;
|
|
319
335
|
if (!workspaceId) {
|
|
320
336
|
const candidate = await queryInvites({ id }, { ...options, lock: false });
|
|
321
|
-
if (!candidate[0])
|
|
337
|
+
if (!candidate[0]) {
|
|
338
|
+
throw new AppError(404, "Invitation not found or already handled.");
|
|
339
|
+
}
|
|
322
340
|
workspaceId = normalizeInviteRecord(candidate[0]).workspaceId;
|
|
323
341
|
}
|
|
324
342
|
if (!options.trx) {
|
|
325
343
|
return withTransaction((trx) => transitionPendingById(id, status, { ...options, trx, workspaceId }));
|
|
326
344
|
}
|
|
327
345
|
const invite = await findPendingByIdForWorkspace(id, workspaceId, { ...options, lock: true });
|
|
328
|
-
if (!invite)
|
|
346
|
+
if (!invite) {
|
|
347
|
+
throw new AppError(404, "Invitation not found or already handled.");
|
|
348
|
+
}
|
|
329
349
|
const updatedAt = new Date().toISOString();
|
|
330
350
|
await api.resources.workspaceInvites.patch(
|
|
331
351
|
{
|
|
@@ -358,7 +378,9 @@ function createRepository({ api } = {}) {
|
|
|
358
378
|
return null;
|
|
359
379
|
}
|
|
360
380
|
|
|
361
|
-
if (options.lock === true)
|
|
381
|
+
if (options.lock === true) {
|
|
382
|
+
await lockWorkspaceForInvitations(normalizedWorkspaceId, options);
|
|
383
|
+
}
|
|
362
384
|
const rows = await queryInvites(
|
|
363
385
|
{
|
|
364
386
|
id: normalizedInviteId,
|
|
@@ -236,11 +236,11 @@ function createService({
|
|
|
236
236
|
return listInvitesPayload(workspace, options);
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
// Trusted server composition: authorisation, association and retry receipts
|
|
240
|
-
// belong to the application. JSON REST writes enforce managed ownership.
|
|
241
239
|
async function prepareInvite(workspace, user, payload = {}, options = {}) {
|
|
242
240
|
ensureWorkspaceInvitationsEnabled();
|
|
243
|
-
if (!options.trx)
|
|
241
|
+
if (!options.trx) {
|
|
242
|
+
throw new TypeError("prepareInvite requires a managed transaction.");
|
|
243
|
+
}
|
|
244
244
|
const email = normalizeLowerText(payload.email);
|
|
245
245
|
const roleSid = normalizeLowerText(payload.roleSid || "member") || "member";
|
|
246
246
|
if (!assignableRoleIds.includes(roleSid)) {
|
|
@@ -280,11 +280,17 @@ function createService({
|
|
|
280
280
|
|
|
281
281
|
async function sendInvite(workspace, user, prepared = {}, options = {}) {
|
|
282
282
|
ensureWorkspaceInvitationsEnabled();
|
|
283
|
-
if (options.trx)
|
|
283
|
+
if (options.trx) {
|
|
284
|
+
throw new TypeError("sendInvite must run after the invitation transaction commits.");
|
|
285
|
+
}
|
|
284
286
|
const invite = await workspaceInvitesRepository.findPendingByIdForWorkspace(
|
|
285
|
-
prepared.createdInviteId,
|
|
287
|
+
prepared.createdInviteId,
|
|
288
|
+
workspace.id,
|
|
289
|
+
{ ...options, includeWorkspace: true, lock: false }
|
|
286
290
|
);
|
|
287
|
-
if (!invite)
|
|
291
|
+
if (!invite) {
|
|
292
|
+
throw new AppError(404, "Invite not found or already handled.");
|
|
293
|
+
}
|
|
288
294
|
if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
|
|
289
295
|
throw new AppError(409, "Invitation has expired.");
|
|
290
296
|
}
|
|
@@ -306,7 +312,9 @@ function createService({
|
|
|
306
312
|
const inviteDelivery = await deliverInviteEmail({ workspace: savedWorkspace, user, invite, inviteUrl }, options);
|
|
307
313
|
return {
|
|
308
314
|
...response,
|
|
309
|
-
inviteTokenPreview: token,
|
|
315
|
+
inviteTokenPreview: token,
|
|
316
|
+
inviteUrl,
|
|
317
|
+
inviteDelivery,
|
|
310
318
|
createdInviteId: normalizeRecordId(invite.id, { fallback: null })
|
|
311
319
|
};
|
|
312
320
|
}
|
|
@@ -332,9 +340,13 @@ function createService({
|
|
|
332
340
|
const revoke = async (trx) => {
|
|
333
341
|
const transactionOptions = { ...options, trx, lock: true, workspaceId: workspace.id };
|
|
334
342
|
const invite = await workspaceInvitesRepository.findPendingByIdForWorkspace(
|
|
335
|
-
normalizedInviteId,
|
|
343
|
+
normalizedInviteId,
|
|
344
|
+
workspace.id,
|
|
345
|
+
transactionOptions
|
|
336
346
|
);
|
|
337
|
-
if (!invite)
|
|
347
|
+
if (!invite) {
|
|
348
|
+
throw new AppError(404, "Invite not found.");
|
|
349
|
+
}
|
|
338
350
|
await workspaceInvitesRepository.revokeById(normalizedInviteId, transactionOptions);
|
|
339
351
|
return normalizeRecordId(invite.id, { fallback: null });
|
|
340
352
|
};
|
|
@@ -16,26 +16,34 @@ function createService({
|
|
|
16
16
|
let registrationClosed = false;
|
|
17
17
|
|
|
18
18
|
function registerAcceptanceParticipant(participant) {
|
|
19
|
-
if (typeof participant !== "function")
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
if (typeof participant !== "function") {
|
|
20
|
+
throw new TypeError("Acceptance participant must be a function.");
|
|
21
|
+
}
|
|
22
|
+
if (acceptanceParticipant) {
|
|
23
|
+
throw new Error("An invitation acceptance participant is already registered.");
|
|
24
|
+
}
|
|
25
|
+
if (registrationClosed) {
|
|
26
|
+
throw new Error("Invitation acceptance participant registration is closed.");
|
|
27
|
+
}
|
|
22
28
|
acceptanceParticipant = participant;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
async function
|
|
31
|
+
async function withInviteTransaction(user, token, options, work) {
|
|
26
32
|
if (options.trx) {
|
|
27
33
|
throw new TypeError("Invitation acceptance/refusal owns its transaction; use the acceptance participant for related writes.");
|
|
28
34
|
}
|
|
29
35
|
registrationClosed = true;
|
|
30
|
-
// Locate the
|
|
31
|
-
//
|
|
32
|
-
// that lock; this preliminary read never authorises a write.
|
|
36
|
+
// Locate outside the transaction to avoid a stale MySQL repeatable-read
|
|
37
|
+
// snapshot. The callback validates again under the workspace/invite locks.
|
|
33
38
|
const initial = await requirePendingInviteForUserByToken(user, token, options);
|
|
34
39
|
const result = await workspaceInvitesRepository.withTransaction((trx) => work({
|
|
35
|
-
...options,
|
|
40
|
+
...options,
|
|
41
|
+
trx,
|
|
42
|
+
lock: true,
|
|
43
|
+
workspaceId: initial.invite.workspaceId,
|
|
44
|
+
inviteId: initial.invite.id
|
|
36
45
|
}));
|
|
37
|
-
//
|
|
38
|
-
// did before acceptance became atomic. Failed commit keeps its real outcome.
|
|
46
|
+
// Commit expiry revocation before reporting the rejection.
|
|
39
47
|
if (result.expired) {
|
|
40
48
|
throw Object.assign(new AppError(409, "Invitation has expired."), { transactionOutcome: "committed" });
|
|
41
49
|
}
|
|
@@ -228,20 +236,25 @@ function createService({
|
|
|
228
236
|
}
|
|
229
237
|
|
|
230
238
|
async function acceptInviteByToken({ user, token } = {}, options = {}) {
|
|
231
|
-
return
|
|
239
|
+
return withInviteTransaction(user, token, options, async (transactionOptions) => {
|
|
232
240
|
const resolved = await resolveInviteActionInput(
|
|
233
|
-
user,
|
|
241
|
+
user,
|
|
242
|
+
token,
|
|
243
|
+
transactionOptions,
|
|
244
|
+
"workspacePendingInvitationsService.acceptInviteByToken"
|
|
234
245
|
);
|
|
235
|
-
if (resolved.expired)
|
|
246
|
+
if (resolved.expired) {
|
|
247
|
+
return resolved;
|
|
248
|
+
}
|
|
236
249
|
const { resolvedInvite, workspaceId } = resolved;
|
|
237
250
|
await workspaceMembershipsRepository.upsertMembership(
|
|
238
|
-
workspaceId,
|
|
251
|
+
workspaceId,
|
|
252
|
+
resolvedInvite.user.id,
|
|
239
253
|
{ roleSid: resolvedInvite.invite.roleSid, status: "active" },
|
|
240
254
|
transactionOptions
|
|
241
255
|
);
|
|
242
256
|
if (acceptanceParticipant) {
|
|
243
|
-
//
|
|
244
|
-
// option. Freeze the envelope so participants cannot replace trx.
|
|
257
|
+
// Repository locking options stay internal to this service.
|
|
245
258
|
await acceptanceParticipant({
|
|
246
259
|
invite: Object.freeze({ ...resolvedInvite.invite }),
|
|
247
260
|
user: Object.freeze({ ...resolvedInvite.user })
|
|
@@ -253,11 +266,16 @@ function createService({
|
|
|
253
266
|
}
|
|
254
267
|
|
|
255
268
|
async function refuseInviteByToken({ user, token } = {}, options = {}) {
|
|
256
|
-
return
|
|
269
|
+
return withInviteTransaction(user, token, options, async (transactionOptions) => {
|
|
257
270
|
const resolved = await resolveInviteActionInput(
|
|
258
|
-
user,
|
|
271
|
+
user,
|
|
272
|
+
token,
|
|
273
|
+
transactionOptions,
|
|
274
|
+
"workspacePendingInvitationsService.refuseInviteByToken"
|
|
259
275
|
);
|
|
260
|
-
if (resolved.expired)
|
|
276
|
+
if (resolved.expired) {
|
|
277
|
+
return resolved;
|
|
278
|
+
}
|
|
261
279
|
const { resolvedInvite, workspaceId } = resolved;
|
|
262
280
|
await workspaceInvitesRepository.revokeById(resolvedInvite.invite.id, transactionOptions);
|
|
263
281
|
return { decision: "refused", workspaceId };
|
|
@@ -17,7 +17,6 @@ import { createRepository as createWorkspacesRepository } from "../src/server/co
|
|
|
17
17
|
import { createRepository as createMembershipsRepository } from "../src/server/common/repositories/workspaceMembershipsRepository.js";
|
|
18
18
|
import { createRepository as createInvitesRepository } from "../src/server/common/repositories/workspaceInvitesRepository.js";
|
|
19
19
|
import { createRepository as createSettingsRepository } from "../src/server/workspaceSettings/workspaceSettingsRepository.js";
|
|
20
|
-
|
|
21
20
|
import { verifyInvitationTransactions } from "./support/invitationTransactions.js";
|
|
22
21
|
|
|
23
22
|
const TABLES = [
|
|
@@ -101,8 +101,7 @@ export async function verifyInvitationTransactions(t, db) {
|
|
|
101
101
|
input: { token: prepared.inviteTokenPreview, decision, ...extraInput },
|
|
102
102
|
context: { ...options.context, actor }
|
|
103
103
|
});
|
|
104
|
-
//
|
|
105
|
-
// lock order. A lost response/retry returns the exact persisted invitation.
|
|
104
|
+
// Match acceptance's lock order: workspace, then application receipt.
|
|
106
105
|
async function prepareOnce({ fail = false } = {}) {
|
|
107
106
|
return invites.withTransaction(async (trx) => {
|
|
108
107
|
await invites.lockWorkspaceForInvitations(workspace.id, { trx });
|
|
@@ -305,8 +304,7 @@ export async function verifyInvitationTransactions(t, db) {
|
|
|
305
304
|
if (state === "revoked") await f.members.revokeInvite(f.workspace, prepared.createdInviteId);
|
|
306
305
|
if (state === "accepted") await f.redeem(prepared);
|
|
307
306
|
let called = false;
|
|
308
|
-
//
|
|
309
|
-
// instance to verify terminal-state validation independently of sealing.
|
|
307
|
+
// The earlier acceptance closed registration on the original service.
|
|
310
308
|
const pending = pendingService({ workspaceInvitesRepository: invites, workspaceMembershipsRepository: memberships });
|
|
311
309
|
pending.registerAcceptanceParticipant(async () => { called = true; });
|
|
312
310
|
const membershipBefore = await memberships.findByWorkspaceIdAndUserId(f.workspace.id, f.user.id);
|
|
@@ -437,8 +435,7 @@ export async function verifyInvitationTransactions(t, db) {
|
|
|
437
435
|
holdTransaction = true;
|
|
438
436
|
connections.length = 0;
|
|
439
437
|
const first = acceptanceWins ? f.redeem(prepared) : compete();
|
|
440
|
-
//
|
|
441
|
-
// unhandled rejection while a competing database operation is pending.
|
|
438
|
+
// Handle rejections while the competing transaction is still waiting.
|
|
442
439
|
const firstSettled = Promise.allSettled([first]);
|
|
443
440
|
await entered.promise;
|
|
444
441
|
const waiting = Promise.withResolvers();
|
|
@@ -81,8 +81,6 @@ function createFixture() {
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
workspaceInvitesRepository: {
|
|
84
|
-
async withTransaction(work) { return work({}); },
|
|
85
|
-
async lockWorkspaceForInvitations() {},
|
|
86
84
|
async listPendingByWorkspaceIdWithWorkspace(workspaceId) {
|
|
87
85
|
assert.equal(Number(workspaceId), 7);
|
|
88
86
|
return [];
|
|
@@ -111,7 +109,9 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
|
|
|
111
109
|
}
|
|
112
110
|
},
|
|
113
111
|
workspaceInvitesRepository: {
|
|
114
|
-
async withTransaction(work) {
|
|
112
|
+
async withTransaction(work) {
|
|
113
|
+
return work({});
|
|
114
|
+
},
|
|
115
115
|
async lockWorkspaceForInvitations() {},
|
|
116
116
|
async expirePendingByWorkspaceIdAndEmail() {},
|
|
117
117
|
async insert(payload) {
|
|
@@ -170,7 +170,9 @@ test("workspaceMembersService.createInvite sends generated invite URLs through t
|
|
|
170
170
|
}
|
|
171
171
|
},
|
|
172
172
|
workspaceInvitesRepository: {
|
|
173
|
-
async withTransaction(work) {
|
|
173
|
+
async withTransaction(work) {
|
|
174
|
+
return work({});
|
|
175
|
+
},
|
|
174
176
|
async lockWorkspaceForInvitations() {},
|
|
175
177
|
async expirePendingByWorkspaceIdAndEmail(workspaceId, email) {
|
|
176
178
|
assert.equal(workspaceId, "7");
|
|
@@ -258,8 +260,9 @@ test("workspaceMembersService.revokeInvite returns the revoked invite id", async
|
|
|
258
260
|
}
|
|
259
261
|
},
|
|
260
262
|
workspaceInvitesRepository: {
|
|
261
|
-
async withTransaction(work) {
|
|
262
|
-
|
|
263
|
+
async withTransaction(work) {
|
|
264
|
+
return work({});
|
|
265
|
+
},
|
|
263
266
|
async listPendingByWorkspaceIdWithWorkspace() {
|
|
264
267
|
return [];
|
|
265
268
|
},
|
|
@@ -307,8 +310,6 @@ test("workspaceMembersService rejects invite operations when invitations are dis
|
|
|
307
310
|
}
|
|
308
311
|
},
|
|
309
312
|
workspaceInvitesRepository: {
|
|
310
|
-
async withTransaction(work) { return work({}); },
|
|
311
|
-
async lockWorkspaceForInvitations() {},
|
|
312
313
|
async listPendingByWorkspaceIdWithWorkspace() {
|
|
313
314
|
throw new Error("invite repository should not be called when invitations are disabled");
|
|
314
315
|
},
|
|
@@ -449,8 +450,6 @@ test("workspaceMembersService.removeMember marks membership revoked and returns
|
|
|
449
450
|
}
|
|
450
451
|
},
|
|
451
452
|
workspaceInvitesRepository: {
|
|
452
|
-
async withTransaction(work) { return work({}); },
|
|
453
|
-
async lockWorkspaceForInvitations() {},
|
|
454
453
|
async listPendingByWorkspaceIdWithWorkspace() {
|
|
455
454
|
return [];
|
|
456
455
|
},
|
|
@@ -504,8 +503,6 @@ test("workspaceMembersService.removeMember rejects removing the owner", async ()
|
|
|
504
503
|
}
|
|
505
504
|
},
|
|
506
505
|
workspaceInvitesRepository: {
|
|
507
|
-
async withTransaction(work) { return work({}); },
|
|
508
|
-
async lockWorkspaceForInvitations() {},
|
|
509
506
|
async listPendingByWorkspaceIdWithWorkspace() {
|
|
510
507
|
return [];
|
|
511
508
|
},
|
|
@@ -16,7 +16,9 @@ function createFixture({
|
|
|
16
16
|
|
|
17
17
|
const service = createService({
|
|
18
18
|
workspaceInvitesRepository: {
|
|
19
|
-
async withTransaction(work) {
|
|
19
|
+
async withTransaction(work) {
|
|
20
|
+
return work({});
|
|
21
|
+
},
|
|
20
22
|
async listPendingByEmail() {
|
|
21
23
|
return Array.isArray(pendingInvitesByEmail) ? [...pendingInvitesByEmail] : [];
|
|
22
24
|
},
|
|
@@ -277,7 +279,6 @@ test("refuseInviteByToken revokes the invite and returns refused", async () => {
|
|
|
277
279
|
assert.equal(response.workspaceId, "1");
|
|
278
280
|
});
|
|
279
281
|
|
|
280
|
-
|
|
281
282
|
test("participant registration rejects invalid, duplicate and late registration", async () => {
|
|
282
283
|
const { service } = createFixture();
|
|
283
284
|
assert.throws(() => service.registerAcceptanceParticipant(null), /must be a function/);
|
|
@@ -294,8 +295,12 @@ for (const transactionOutcome of ["committed", "unknown", "rolledBack"]) {
|
|
|
294
295
|
const failure = Object.assign(new Error("Managed completion failed"), { transactionOutcome, cause });
|
|
295
296
|
const service = createService({
|
|
296
297
|
workspaceInvitesRepository: {
|
|
297
|
-
async findPendingByTokenHash() {
|
|
298
|
-
|
|
298
|
+
async findPendingByTokenHash() {
|
|
299
|
+
return { workspaceId: "1", email: "a@example.test" };
|
|
300
|
+
},
|
|
301
|
+
async withTransaction() {
|
|
302
|
+
throw failure;
|
|
303
|
+
}
|
|
299
304
|
},
|
|
300
305
|
workspaceMembershipsRepository: {}
|
|
301
306
|
});
|