@jskit-ai/workspaces-core 0.1.196 → 0.1.198
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 +49 -0
- package/src/server/common/repositories/workspaceInvitesRepository.js +89 -44
- package/src/server/workspaceMembers/workspaceMembersService.js +67 -40
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +1 -1
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +66 -42
- package/test/featureRuntime.test.js +5 -0
- package/test/repositories.integration.test.js +18 -2
- package/test/support/invitationTransactions.js +476 -0
- package/test/workspaceInvitesRepository.test.js +10 -2
- package/test/workspaceMembersService.test.js +22 -9
- package/test/workspacePendingInvitationsService.test.js +30 -2
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.198",
|
|
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.220",
|
|
22
|
+
"@jskit-ai/json-rest-api-core": "0.1.164",
|
|
23
|
+
"@jskit-ai/resource-core": "0.1.162",
|
|
24
|
+
"@jskit-ai/resource-crud-core": "0.1.162",
|
|
25
|
+
"@jskit-ai/users-core": "0.1.233",
|
|
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.218",
|
|
173
|
+
"@jskit-ai/http-runtime": "0.1.218",
|
|
174
|
+
"@jskit-ai/kernel": "0.1.220"
|
|
175
175
|
}
|
|
176
176
|
}
|
|
@@ -28,6 +28,55 @@ member permissions, personal-workspace behavior, and surface access rules.
|
|
|
28
28
|
Use workspace routes, resources, scope helpers, tenancy profiles, and settings
|
|
29
29
|
contracts exported by `@jskit-ai/workspaces-core`.
|
|
30
30
|
|
|
31
|
+
## Transactional invitation participation
|
|
32
|
+
|
|
33
|
+
Use the existing `workspaces.core` capability. During application feature setup,
|
|
34
|
+
register one callback with
|
|
35
|
+
`workspaces.services.pendingInvitations.registerAcceptanceParticipant(fn)`.
|
|
36
|
+
The built-in `workspace.invite.redeem` action invokes
|
|
37
|
+
`fn({ invite, user }, { trx, context })` after recipient/state/expiry validation
|
|
38
|
+
and membership activation inside the same uncommitted managed transaction.
|
|
39
|
+
Forward `trx` to every application owner; throw on invalid reviewed associations
|
|
40
|
+
or failed writes. The framework marks accepted and commits only after success.
|
|
41
|
+
Do not perform external effects or complete the transaction in the participant.
|
|
42
|
+
Non-functions, duplicate registrations and registrations after acceptance/refusal
|
|
43
|
+
begins reject. Ordinary invitations can pass through without an association;
|
|
44
|
+
expected-but-missing, stale, revoked or conflicting associations must fail closed.
|
|
45
|
+
|
|
46
|
+
For deliberate invitation creation, use `members.prepareInvite(workspace, actor,
|
|
47
|
+
payload, { trx, context })` in `repositories.workspaceInvites.withTransaction`
|
|
48
|
+
or the same JSON REST API's managed `transaction`. It returns
|
|
49
|
+
`{ createdInviteId, inviteTokenPreview }` without delivery. Lock the workspace
|
|
50
|
+
with `repositories.workspaceInvites.lockWorkspaceForInvitations(workspace.id,
|
|
51
|
+
{ trx })` before application draft/receipt rows, and save the exact invitation
|
|
52
|
+
association and retry receipt before commit. Failed association restores any
|
|
53
|
+
prior pending invitation. A draft of intended access should not call preparation.
|
|
54
|
+
|
|
55
|
+
After confirmed commit, call `members.sendInvite(workspace, actor, prepared,
|
|
56
|
+
{ context })`. A later attempt can pass only `{ createdInviteId }`. Delivery
|
|
57
|
+
reloads the committed pending invitation and uses stored recipient/role/expiry;
|
|
58
|
+
it does not replace the invitation. Its `inviteDelivery` is separate from
|
|
59
|
+
persistence: `mailer_unconfigured` when no mailer is supplied, `failed` on a
|
|
60
|
+
renderer/mailer exception, or the normalized controlled/provider result.
|
|
61
|
+
Interruption or failure may still mean a provider accepted the message. Receipt
|
|
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.
|
|
64
|
+
|
|
65
|
+
`createInvite`, acceptance and refusal now own managed transactions and reject
|
|
66
|
+
borrowed `options.trx`. Move transactional creation to preparation/post-commit
|
|
67
|
+
sending and acceptance-related writes into the participant. Revocation may join
|
|
68
|
+
a managed transaction. Raw Knex/savepoint transactions cannot own resource
|
|
69
|
+
writes. Invitation mutations lock workspace then invitation rows, revalidate
|
|
70
|
+
current state and reject attempts to overwrite terminal state. Matching-recipient
|
|
71
|
+
expiry rejection commits revocation before returning 409. Preserve transaction
|
|
72
|
+
outcomes and causes; a completion-hook error after commit cannot undo writes.
|
|
73
|
+
|
|
74
|
+
See the human guide's **Workspace tenancy → Transactional invitation
|
|
75
|
+
participation** for the full consumer example, errors, controlled mailer,
|
|
76
|
+
compatibility notes and native verification commands. Application services still
|
|
77
|
+
own authorization, identity/training linking, reviewed scope eligibility and
|
|
78
|
+
required audit. This seam adds no application domain tables or policy.
|
|
79
|
+
|
|
31
80
|
## Invariants
|
|
32
81
|
|
|
33
82
|
- Workspace membership and role are checked before scoped application actions.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
1
2
|
import {
|
|
2
3
|
normalizeLowerText,
|
|
3
4
|
normalizeRecordId,
|
|
@@ -83,6 +84,30 @@ function createRepository({ api } = {}) {
|
|
|
83
84
|
const withTransaction = (work) => api.transaction(work);
|
|
84
85
|
|
|
85
86
|
async function queryInvites(filters = {}, options = {}, { includeWorkspace = false } = {}) {
|
|
87
|
+
if (options.lock === true) {
|
|
88
|
+
if (typeof options.trx !== "function" || includeWorkspace) {
|
|
89
|
+
throw new TypeError("Locking invites requires a transaction and no relationship includes.");
|
|
90
|
+
}
|
|
91
|
+
// Read current rows under lock rather than authorising a state transition
|
|
92
|
+
// from the ordinary resource query's earlier snapshot.
|
|
93
|
+
const fields = { id: "id", workspace: "workspace_id", email: "email", status: "status", tokenHash: "token_hash" };
|
|
94
|
+
const where = Object.fromEntries(Object.entries(filters).map(([key, value]) => {
|
|
95
|
+
if (!fields[key]) throw new TypeError("Unsupported locking invitation filter.");
|
|
96
|
+
return [fields[key], value];
|
|
97
|
+
}));
|
|
98
|
+
// PostgreSQL's driver interprets timestamp-without-time-zone as local
|
|
99
|
+
// time. These owner columns store UTC; preserve their database text just
|
|
100
|
+
// as the resource read path does before toIsoString normalizes it.
|
|
101
|
+
const dateColumn = (name) => options.trx.client?.config?.client === "pg"
|
|
102
|
+
? options.trx.raw("CAST(?? AS TEXT)", [name])
|
|
103
|
+
: name;
|
|
104
|
+
return options.trx("workspace_invites").where(where).select({
|
|
105
|
+
id: "id", workspaceId: "workspace_id", email: "email", roleSid: "role_sid",
|
|
106
|
+
status: "status", tokenHash: "token_hash", invitedByUserId: "invited_by_user_id",
|
|
107
|
+
expiresAt: dateColumn("expires_at"), acceptedAt: dateColumn("accepted_at"), revokedAt: dateColumn("revoked_at"),
|
|
108
|
+
createdAt: dateColumn("created_at"), updatedAt: dateColumn("updated_at")
|
|
109
|
+
}).forUpdate();
|
|
110
|
+
}
|
|
86
111
|
return extractJsonRestCollectionRows(
|
|
87
112
|
await api.resources.workspaceInvites.query(
|
|
88
113
|
{
|
|
@@ -98,16 +123,34 @@ function createRepository({ api } = {}) {
|
|
|
98
123
|
);
|
|
99
124
|
}
|
|
100
125
|
|
|
101
|
-
async function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
126
|
+
async function lockWorkspaceForInvitations(workspaceId, options = {}) {
|
|
127
|
+
if (typeof options.trx !== "function" || options.trx.isCompleted?.()) {
|
|
128
|
+
throw new TypeError("Invitation locking requires an active transaction.");
|
|
129
|
+
}
|
|
130
|
+
const id = normalizeRecordId(workspaceId, { fallback: null });
|
|
131
|
+
if (!id) throw new TypeError("Invitation locking requires workspaceId.");
|
|
132
|
+
const row = await options.trx("workspaces").where({ id }).forUpdate().first("id");
|
|
133
|
+
if (!row) throw new AppError(404, "Invitation workspace no longer exists.");
|
|
134
|
+
}
|
|
109
135
|
|
|
110
|
-
|
|
136
|
+
async function findPendingByTokenHash(tokenHash, options = {}) {
|
|
137
|
+
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
|
+
await lockWorkspaceForInvitations(options.workspaceId, options);
|
|
141
|
+
}
|
|
142
|
+
const filters = options.lock === true
|
|
143
|
+
? {
|
|
144
|
+
id: normalizeRecordId(options.inviteId, { fallback: null }),
|
|
145
|
+
workspace: normalizeRecordId(options.workspaceId, { fallback: null }),
|
|
146
|
+
status: "pending"
|
|
147
|
+
}
|
|
148
|
+
: { tokenHash: normalizeText(tokenHash), status: "pending" };
|
|
149
|
+
// The current locking query uses only IDs, keeping bearer-equivalent token
|
|
150
|
+
// hashes out of native lock-error SQL. Still recheck token correspondence.
|
|
151
|
+
const rows = await queryInvites(filters, options);
|
|
152
|
+
const invite = normalizeInviteRecord(rows[0] || null);
|
|
153
|
+
return invite?.tokenHash === normalizeText(tokenHash) ? invite : null;
|
|
111
154
|
}
|
|
112
155
|
|
|
113
156
|
async function findByTokenHashWithWorkspace(tokenHash, options = {}) {
|
|
@@ -235,6 +278,10 @@ function createRepository({ api } = {}) {
|
|
|
235
278
|
return;
|
|
236
279
|
}
|
|
237
280
|
|
|
281
|
+
if (!options.trx) {
|
|
282
|
+
return withTransaction((trx) => expirePendingByWorkspaceIdAndEmail(workspaceId, email, { ...options, trx }));
|
|
283
|
+
}
|
|
284
|
+
await lockWorkspaceForInvitations(normalizedWorkspaceId, options);
|
|
238
285
|
const patch = normalizeInvitePatchPayload({ status: "expired" });
|
|
239
286
|
const rows = await queryInvites(
|
|
240
287
|
{
|
|
@@ -242,7 +289,7 @@ function createRepository({ api } = {}) {
|
|
|
242
289
|
email: normalizeLowerText(email),
|
|
243
290
|
status: "pending"
|
|
244
291
|
},
|
|
245
|
-
options
|
|
292
|
+
{ ...options, lock: true }
|
|
246
293
|
);
|
|
247
294
|
|
|
248
295
|
for (const row of rows) {
|
|
@@ -265,50 +312,43 @@ function createRepository({ api } = {}) {
|
|
|
265
312
|
}
|
|
266
313
|
}
|
|
267
314
|
|
|
268
|
-
async function
|
|
269
|
-
const
|
|
270
|
-
if (!
|
|
271
|
-
|
|
315
|
+
async function transitionPendingById(inviteId, status, options = {}) {
|
|
316
|
+
const id = normalizeRecordId(inviteId, { fallback: null });
|
|
317
|
+
if (!id) throw new TypeError("Invitation transition requires inviteId.");
|
|
318
|
+
let workspaceId = options.workspaceId;
|
|
319
|
+
if (!workspaceId) {
|
|
320
|
+
const candidate = await queryInvites({ id }, { ...options, lock: false });
|
|
321
|
+
if (!candidate[0]) throw new AppError(404, "Invitation not found or already handled.");
|
|
322
|
+
workspaceId = normalizeInviteRecord(candidate[0]).workspaceId;
|
|
272
323
|
}
|
|
273
|
-
|
|
274
|
-
|
|
324
|
+
if (!options.trx) {
|
|
325
|
+
return withTransaction((trx) => transitionPendingById(id, status, { ...options, trx, workspaceId }));
|
|
326
|
+
}
|
|
327
|
+
const invite = await findPendingByIdForWorkspace(id, workspaceId, { ...options, lock: true });
|
|
328
|
+
if (!invite) throw new AppError(404, "Invitation not found or already handled.");
|
|
329
|
+
const updatedAt = new Date().toISOString();
|
|
275
330
|
await api.resources.workspaceInvites.patch(
|
|
276
331
|
{
|
|
277
|
-
id
|
|
332
|
+
id,
|
|
278
333
|
data: {
|
|
279
|
-
status
|
|
280
|
-
acceptedAt,
|
|
281
|
-
updatedAt
|
|
334
|
+
status,
|
|
335
|
+
[status === "accepted" ? "acceptedAt" : "revokedAt"]: updatedAt,
|
|
336
|
+
updatedAt
|
|
282
337
|
},
|
|
283
338
|
format: "plain",
|
|
284
339
|
returning: "full",
|
|
285
|
-
transaction: options
|
|
340
|
+
transaction: options.trx
|
|
286
341
|
},
|
|
287
|
-
createJsonRestContext(options
|
|
342
|
+
createJsonRestContext(options.context || null)
|
|
288
343
|
);
|
|
289
344
|
}
|
|
290
345
|
|
|
291
|
-
async function
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
346
|
+
async function markAcceptedById(inviteId, options = {}) {
|
|
347
|
+
return transitionPendingById(inviteId, "accepted", options);
|
|
348
|
+
}
|
|
296
349
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
{
|
|
300
|
-
id: normalizedInviteId,
|
|
301
|
-
data: {
|
|
302
|
-
status: "revoked",
|
|
303
|
-
revokedAt,
|
|
304
|
-
updatedAt: revokedAt
|
|
305
|
-
},
|
|
306
|
-
format: "plain",
|
|
307
|
-
returning: "full",
|
|
308
|
-
transaction: options?.trx || null
|
|
309
|
-
},
|
|
310
|
-
createJsonRestContext(options?.context || null)
|
|
311
|
-
);
|
|
350
|
+
async function revokeById(inviteId, options = {}) {
|
|
351
|
+
return transitionPendingById(inviteId, "revoked", options);
|
|
312
352
|
}
|
|
313
353
|
|
|
314
354
|
async function findPendingByIdForWorkspace(inviteId, workspaceId, options = {}) {
|
|
@@ -318,20 +358,25 @@ function createRepository({ api } = {}) {
|
|
|
318
358
|
return null;
|
|
319
359
|
}
|
|
320
360
|
|
|
361
|
+
if (options.lock === true) await lockWorkspaceForInvitations(normalizedWorkspaceId, options);
|
|
321
362
|
const rows = await queryInvites(
|
|
322
363
|
{
|
|
323
364
|
id: normalizedInviteId,
|
|
324
365
|
workspace: normalizedWorkspaceId,
|
|
325
366
|
status: "pending"
|
|
326
367
|
},
|
|
327
|
-
options
|
|
368
|
+
options,
|
|
369
|
+
{ includeWorkspace: options.includeWorkspace === true }
|
|
328
370
|
);
|
|
329
371
|
|
|
330
|
-
return
|
|
372
|
+
return options.includeWorkspace === true
|
|
373
|
+
? (rows[0] ? normalizeInviteWithWorkspace(rows[0]) : null)
|
|
374
|
+
: normalizeInviteRecord(rows[0] || null);
|
|
331
375
|
}
|
|
332
376
|
|
|
333
377
|
return Object.freeze({
|
|
334
378
|
withTransaction,
|
|
379
|
+
lockWorkspaceForInvitations,
|
|
335
380
|
findPendingByTokenHash,
|
|
336
381
|
findByTokenHashWithWorkspace,
|
|
337
382
|
listPendingByEmail,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
3
|
-
import {
|
|
3
|
+
import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
|
|
4
|
+
import { resolveInviteTokenHash, buildInviteToken, hashInviteToken } from "@jskit-ai/auth-core/server/inviteTokens";
|
|
4
5
|
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
5
6
|
import { OWNER_ROLE_ID, createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
|
|
6
7
|
import { renderDefaultWorkspaceInviteEmail } from "./defaultWorkspaceInviteEmail.js";
|
|
@@ -127,10 +128,11 @@ function createService({
|
|
|
127
128
|
message
|
|
128
129
|
}, options);
|
|
129
130
|
return normalizeInviteDeliveryResult(result || { status: "sent" });
|
|
130
|
-
} catch
|
|
131
|
+
} catch {
|
|
131
132
|
return {
|
|
132
133
|
status: "failed",
|
|
133
|
-
|
|
134
|
+
// Provider errors can contain the secret invitation URL.
|
|
135
|
+
message: "Unable to send workspace invite email.",
|
|
134
136
|
providerMessageId: ""
|
|
135
137
|
};
|
|
136
138
|
}
|
|
@@ -234,8 +236,11 @@ function createService({
|
|
|
234
236
|
return listInvitesPayload(workspace, options);
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
|
|
239
|
+
// Trusted server composition: authorisation, association and retry receipts
|
|
240
|
+
// belong to the application. JSON REST writes enforce managed ownership.
|
|
241
|
+
async function prepareInvite(workspace, user, payload = {}, options = {}) {
|
|
238
242
|
ensureWorkspaceInvitationsEnabled();
|
|
243
|
+
if (!options.trx) throw new TypeError("prepareInvite requires a managed transaction.");
|
|
239
244
|
const email = normalizeLowerText(payload.email);
|
|
240
245
|
const roleSid = normalizeLowerText(payload.roleSid || "member") || "member";
|
|
241
246
|
if (!assignableRoleIds.includes(roleSid)) {
|
|
@@ -251,6 +256,7 @@ function createService({
|
|
|
251
256
|
const token = buildInviteToken();
|
|
252
257
|
const tokenHash = hashInviteToken(token);
|
|
253
258
|
const expiresAt = new Date(Date.now() + resolvedInviteExpiresInMs).toISOString();
|
|
259
|
+
await workspaceInvitesRepository.lockWorkspaceForInvitations(workspace.id, options);
|
|
254
260
|
await workspaceInvitesRepository.expirePendingByWorkspaceIdAndEmail(workspace.id, email, options);
|
|
255
261
|
const createdInvite = await workspaceInvitesRepository.insert(
|
|
256
262
|
{
|
|
@@ -266,35 +272,56 @@ function createService({
|
|
|
266
272
|
);
|
|
267
273
|
const createdInviteId = normalizeRecordId(createdInvite?.id, { fallback: null });
|
|
268
274
|
if (!createdInviteId) {
|
|
269
|
-
throw new Error("workspaceMembersService.
|
|
275
|
+
throw new Error("workspaceMembersService.prepareInvite expected repository to return created invite id.");
|
|
270
276
|
}
|
|
271
277
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
workspace,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
278
|
+
return { createdInviteId, inviteTokenPreview: token };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function sendInvite(workspace, user, prepared = {}, options = {}) {
|
|
282
|
+
ensureWorkspaceInvitationsEnabled();
|
|
283
|
+
if (options.trx) throw new TypeError("sendInvite must run after the invitation transaction commits.");
|
|
284
|
+
const invite = await workspaceInvitesRepository.findPendingByIdForWorkspace(
|
|
285
|
+
prepared.createdInviteId, workspace.id, { ...options, includeWorkspace: true, lock: false }
|
|
286
|
+
);
|
|
287
|
+
if (!invite) throw new AppError(404, "Invite not found or already handled.");
|
|
288
|
+
if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
|
|
289
|
+
throw new AppError(409, "Invitation has expired.");
|
|
290
|
+
}
|
|
291
|
+
const token = Object.hasOwn(prepared, "inviteTokenPreview")
|
|
292
|
+
? normalizeText(prepared.inviteTokenPreview)
|
|
293
|
+
: encodeInviteTokenHash(invite.tokenHash);
|
|
294
|
+
if (!token || resolveInviteTokenHash(token) !== invite.tokenHash) {
|
|
295
|
+
throw new AppError(400, "Invitation token does not match the saved invitation.");
|
|
296
|
+
}
|
|
297
|
+
const savedWorkspace = {
|
|
298
|
+
...workspace,
|
|
299
|
+
id: invite.workspaceId,
|
|
300
|
+
slug: invite.workspaceSlug,
|
|
301
|
+
name: invite.workspaceName,
|
|
302
|
+
avatarUrl: invite.workspaceAvatarUrl
|
|
303
|
+
};
|
|
304
|
+
const inviteUrl = resolvedInviteUrlBuilder({ token, invite, workspace: savedWorkspace });
|
|
305
|
+
const response = await listInvitesPayload(savedWorkspace, options);
|
|
306
|
+
const inviteDelivery = await deliverInviteEmail({ workspace: savedWorkspace, user, invite, inviteUrl }, options);
|
|
289
307
|
return {
|
|
290
308
|
...response,
|
|
291
|
-
inviteTokenPreview: token,
|
|
292
|
-
|
|
293
|
-
inviteDelivery,
|
|
294
|
-
createdInviteId
|
|
309
|
+
inviteTokenPreview: token, inviteUrl, inviteDelivery,
|
|
310
|
+
createdInviteId: normalizeRecordId(invite.id, { fallback: null })
|
|
295
311
|
};
|
|
296
312
|
}
|
|
297
313
|
|
|
314
|
+
async function createInvite(workspace, user, payload = {}, options = {}) {
|
|
315
|
+
ensureWorkspaceInvitationsEnabled();
|
|
316
|
+
if (options.trx) {
|
|
317
|
+
throw new TypeError("Use prepareInvite inside a transaction, then sendInvite after commit.");
|
|
318
|
+
}
|
|
319
|
+
const prepared = await workspaceInvitesRepository.withTransaction((trx) =>
|
|
320
|
+
prepareInvite(workspace, user, payload, { ...options, trx })
|
|
321
|
+
);
|
|
322
|
+
return sendInvite(workspace, user, prepared, options);
|
|
323
|
+
}
|
|
324
|
+
|
|
298
325
|
async function revokeInvite(workspace, inviteId, options = {}) {
|
|
299
326
|
ensureWorkspaceInvitationsEnabled();
|
|
300
327
|
const normalizedInviteId = normalizeRecordId(inviteId, { fallback: null });
|
|
@@ -302,20 +329,18 @@ function createService({
|
|
|
302
329
|
throw new AppError(400, "Validation failed.");
|
|
303
330
|
}
|
|
304
331
|
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
throw new Error("workspaceMembersService.revokeInvite expected repository to return pending invite id.");
|
|
318
|
-
}
|
|
332
|
+
const revoke = async (trx) => {
|
|
333
|
+
const transactionOptions = { ...options, trx, lock: true, workspaceId: workspace.id };
|
|
334
|
+
const invite = await workspaceInvitesRepository.findPendingByIdForWorkspace(
|
|
335
|
+
normalizedInviteId, workspace.id, transactionOptions
|
|
336
|
+
);
|
|
337
|
+
if (!invite) throw new AppError(404, "Invite not found.");
|
|
338
|
+
await workspaceInvitesRepository.revokeById(normalizedInviteId, transactionOptions);
|
|
339
|
+
return normalizeRecordId(invite.id, { fallback: null });
|
|
340
|
+
};
|
|
341
|
+
const revokedInviteId = options.trx
|
|
342
|
+
? await revoke(options.trx)
|
|
343
|
+
: await workspaceInvitesRepository.withTransaction(revoke);
|
|
319
344
|
|
|
320
345
|
const response = await listInvitesPayload(workspace, options);
|
|
321
346
|
return {
|
|
@@ -331,6 +356,8 @@ function createService({
|
|
|
331
356
|
removeMember,
|
|
332
357
|
listInvites,
|
|
333
358
|
createInvite,
|
|
359
|
+
prepareInvite,
|
|
360
|
+
sendInvite,
|
|
334
361
|
revokeInvite
|
|
335
362
|
});
|
|
336
363
|
}
|
|
@@ -100,7 +100,7 @@ const workspacePendingInvitationsActionSpecifications = Object.freeze([
|
|
|
100
100
|
events: WORKSPACE_INVITE_DECISION_EVENTS,
|
|
101
101
|
async run(workspacePendingInvitationsService, input, context) {
|
|
102
102
|
const payload = input || {};
|
|
103
|
-
const user = resolveActionUser(context
|
|
103
|
+
const user = resolveActionUser(context);
|
|
104
104
|
|
|
105
105
|
if (payload.decision === "accept") {
|
|
106
106
|
return returnJsonApiData(await workspacePendingInvitationsService.acceptInviteByToken({
|
|
@@ -12,6 +12,36 @@ function createService({
|
|
|
12
12
|
throw new Error("workspacePendingInvitationsService requires invite and membership repositories.");
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
let acceptanceParticipant = null;
|
|
16
|
+
let registrationClosed = false;
|
|
17
|
+
|
|
18
|
+
function registerAcceptanceParticipant(participant) {
|
|
19
|
+
if (typeof participant !== "function") throw new TypeError("Acceptance participant must be a function.");
|
|
20
|
+
if (acceptanceParticipant) throw new Error("An invitation acceptance participant is already registered.");
|
|
21
|
+
if (registrationClosed) throw new Error("Invitation acceptance participant registration is closed.");
|
|
22
|
+
acceptanceParticipant = participant;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function inTransaction(user, token, options, work) {
|
|
26
|
+
if (options.trx) {
|
|
27
|
+
throw new TypeError("Invitation acceptance/refusal owns its transaction; use the acceptance participant for related writes.");
|
|
28
|
+
}
|
|
29
|
+
registrationClosed = true;
|
|
30
|
+
// Locate the workspace before opening the unit, avoiding a stale MySQL
|
|
31
|
+
// repeatable-read snapshot while waiting for its lock. Validate again under
|
|
32
|
+
// that lock; this preliminary read never authorises a write.
|
|
33
|
+
const initial = await requirePendingInviteForUserByToken(user, token, options);
|
|
34
|
+
const result = await workspaceInvitesRepository.withTransaction((trx) => work({
|
|
35
|
+
...options, trx, lock: true, workspaceId: initial.invite.workspaceId, inviteId: initial.invite.id
|
|
36
|
+
}));
|
|
37
|
+
// Expiry revocation intentionally commits before reporting rejection, as it
|
|
38
|
+
// did before acceptance became atomic. Failed commit keeps its real outcome.
|
|
39
|
+
if (result.expired) {
|
|
40
|
+
throw Object.assign(new AppError(409, "Invitation has expired."), { transactionOutcome: "committed" });
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
function requireAuthenticatedInviteUser(user) {
|
|
16
46
|
if (!normalizeRecordId(user?.id, { fallback: null })) {
|
|
17
47
|
throw new AppError(401, "Authentication required.");
|
|
@@ -136,13 +166,6 @@ function createService({
|
|
|
136
166
|
};
|
|
137
167
|
}
|
|
138
168
|
|
|
139
|
-
async function revokeExpiredInviteAndThrow(invite, options = {}) {
|
|
140
|
-
if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
|
|
141
|
-
await workspaceInvitesRepository.revokeById(invite.id, options);
|
|
142
|
-
throw new AppError(409, "Invitation has expired.");
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
169
|
async function listPendingInvitesForUser(user, options = {}) {
|
|
147
170
|
const actor = requireAuthenticatedInviteUser(user);
|
|
148
171
|
const actorEmail = normalizeLowerText(actor?.email);
|
|
@@ -193,7 +216,10 @@ function createService({
|
|
|
193
216
|
|
|
194
217
|
async function resolveInviteActionInput(user, token, options = {}, methodName = "workspacePendingInvitationsService") {
|
|
195
218
|
const resolvedInvite = await requirePendingInviteForUserByToken(user, token, options);
|
|
196
|
-
|
|
219
|
+
if (resolvedInvite.invite.expiresAt && new Date(resolvedInvite.invite.expiresAt).getTime() < Date.now()) {
|
|
220
|
+
await workspaceInvitesRepository.revokeById(resolvedInvite.invite.id, options);
|
|
221
|
+
return { expired: true };
|
|
222
|
+
}
|
|
197
223
|
|
|
198
224
|
return {
|
|
199
225
|
resolvedInvite,
|
|
@@ -202,46 +228,44 @@ function createService({
|
|
|
202
228
|
}
|
|
203
229
|
|
|
204
230
|
async function acceptInviteByToken({ user, token } = {}, options = {}) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
workspaceId
|
|
226
|
-
};
|
|
231
|
+
return inTransaction(user, token, options, async (transactionOptions) => {
|
|
232
|
+
const resolved = await resolveInviteActionInput(
|
|
233
|
+
user, token, transactionOptions, "workspacePendingInvitationsService.acceptInviteByToken"
|
|
234
|
+
);
|
|
235
|
+
if (resolved.expired) return resolved;
|
|
236
|
+
const { resolvedInvite, workspaceId } = resolved;
|
|
237
|
+
await workspaceMembershipsRepository.upsertMembership(
|
|
238
|
+
workspaceId, resolvedInvite.user.id,
|
|
239
|
+
{ roleSid: resolvedInvite.invite.roleSid, status: "active" },
|
|
240
|
+
transactionOptions
|
|
241
|
+
);
|
|
242
|
+
if (acceptanceParticipant) {
|
|
243
|
+
// Do not expose the repository's lock flag as a general application
|
|
244
|
+
// option. Freeze the envelope so participants cannot replace trx.
|
|
245
|
+
await acceptanceParticipant({
|
|
246
|
+
invite: Object.freeze({ ...resolvedInvite.invite }),
|
|
247
|
+
user: Object.freeze({ ...resolvedInvite.user })
|
|
248
|
+
}, Object.freeze({ ...options, trx: transactionOptions.trx }));
|
|
249
|
+
}
|
|
250
|
+
await workspaceInvitesRepository.markAcceptedById(resolvedInvite.invite.id, transactionOptions);
|
|
251
|
+
return { decision: "accepted", workspaceId };
|
|
252
|
+
});
|
|
227
253
|
}
|
|
228
254
|
|
|
229
255
|
async function refuseInviteByToken({ user, token } = {}, options = {}) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
decision: "refused",
|
|
240
|
-
workspaceId
|
|
241
|
-
};
|
|
256
|
+
return inTransaction(user, token, options, async (transactionOptions) => {
|
|
257
|
+
const resolved = await resolveInviteActionInput(
|
|
258
|
+
user, token, transactionOptions, "workspacePendingInvitationsService.refuseInviteByToken"
|
|
259
|
+
);
|
|
260
|
+
if (resolved.expired) return resolved;
|
|
261
|
+
const { resolvedInvite, workspaceId } = resolved;
|
|
262
|
+
await workspaceInvitesRepository.revokeById(resolvedInvite.invite.id, transactionOptions);
|
|
263
|
+
return { decision: "refused", workspaceId };
|
|
264
|
+
});
|
|
242
265
|
}
|
|
243
266
|
|
|
244
267
|
return Object.freeze({
|
|
268
|
+
registerAcceptanceParticipant,
|
|
245
269
|
resolveInviteByToken,
|
|
246
270
|
resolveInviteContextForAuth,
|
|
247
271
|
listPendingInvitesForUser,
|
|
@@ -65,6 +65,7 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
|
|
|
65
65
|
setup({ actions, workspaceCapability }) {
|
|
66
66
|
actionCatalogue = actions;
|
|
67
67
|
workspaces = workspaceCapability;
|
|
68
|
+
workspaceCapability.services.pendingInvitations.registerAcceptanceParticipant(async () => {});
|
|
68
69
|
return {};
|
|
69
70
|
}
|
|
70
71
|
});
|
|
@@ -104,6 +105,10 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
|
|
|
104
105
|
"workspaces"
|
|
105
106
|
]);
|
|
106
107
|
assert.equal(typeof workspaces.services.directory.resolveWorkspaceContextForUserBySlug, "function");
|
|
108
|
+
assert.equal(typeof workspaces.services.members.prepareInvite, "function");
|
|
109
|
+
assert.equal(typeof workspaces.services.members.sendInvite, "function");
|
|
110
|
+
assert.throws(() => workspaces.services.pendingInvitations.registerAcceptanceParticipant(null), /must be a function/);
|
|
111
|
+
assert.throws(() => workspaces.services.pendingInvitations.registerAcceptanceParticipant(async () => {}), /already registered/);
|
|
107
112
|
assert.equal(routes.includes("POST /api/workspaces"), true);
|
|
108
113
|
assert.equal(routes.includes("GET /api/workspace/invitations/resolve"), true);
|
|
109
114
|
assert.equal(actionCatalogue.listDefinitions().length, 16);
|