@jskit-ai/workspaces-core 0.1.197 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.1.197",
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.219",
22
- "@jskit-ai/json-rest-api-core": "0.1.163",
23
- "@jskit-ai/resource-core": "0.1.161",
24
- "@jskit-ai/resource-crud-core": "0.1.161",
25
- "@jskit-ai/users-core": "0.1.232",
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.217",
173
- "@jskit-ai/http-runtime": "0.1.217",
174
- "@jskit-ai/kernel": "0.1.219"
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 findPendingByTokenHash(tokenHash, options = {}) {
102
- const rows = await queryInvites(
103
- {
104
- tokenHash: normalizeText(tokenHash),
105
- status: "pending"
106
- },
107
- options
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
- return normalizeInviteRecord(rows[0] || null);
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 markAcceptedById(inviteId, options = {}) {
269
- const normalizedInviteId = normalizeRecordId(inviteId, { fallback: null });
270
- if (!normalizedInviteId) {
271
- return;
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
- const acceptedAt = new Date().toISOString();
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: normalizedInviteId,
332
+ id,
278
333
  data: {
279
- status: "accepted",
280
- acceptedAt,
281
- updatedAt: acceptedAt
334
+ status,
335
+ [status === "accepted" ? "acceptedAt" : "revokedAt"]: updatedAt,
336
+ updatedAt
282
337
  },
283
338
  format: "plain",
284
339
  returning: "full",
285
- transaction: options?.trx || null
340
+ transaction: options.trx
286
341
  },
287
- createJsonRestContext(options?.context || null)
342
+ createJsonRestContext(options.context || null)
288
343
  );
289
344
  }
290
345
 
291
- async function revokeById(inviteId, options = {}) {
292
- const normalizedInviteId = normalizeRecordId(inviteId, { fallback: null });
293
- if (!normalizedInviteId) {
294
- return;
295
- }
346
+ async function markAcceptedById(inviteId, options = {}) {
347
+ return transitionPendingById(inviteId, "accepted", options);
348
+ }
296
349
 
297
- const revokedAt = new Date().toISOString();
298
- await api.resources.workspaceInvites.patch(
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 normalizeInviteRecord(rows[0] || null);
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 { buildInviteToken, hashInviteToken } from "@jskit-ai/auth-core/server/inviteTokens";
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 (error) {
131
+ } catch {
131
132
  return {
132
133
  status: "failed",
133
- message: normalizeText(error?.message || "Unable to send workspace invite email."),
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
- async function createInvite(workspace, user, payload = {}, options = {}) {
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.createInvite expected repository to return created invite id.");
275
+ throw new Error("workspaceMembersService.prepareInvite expected repository to return created invite id.");
270
276
  }
271
277
 
272
- const response = await listInvitesPayload(workspace, options);
273
- const inviteUrl = resolvedInviteUrlBuilder({
274
- token,
275
- invite: createdInvite,
276
- workspace
277
- });
278
- const inviteDelivery = await deliverInviteEmail({
279
- workspace,
280
- user,
281
- invite: {
282
- ...createdInvite,
283
- email,
284
- roleSid,
285
- expiresAt: createdInvite?.expiresAt || expiresAt
286
- },
287
- inviteUrl
288
- }, options);
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
- inviteUrl,
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 invite = await workspaceInvitesRepository.findPendingByIdForWorkspace(
306
- normalizedInviteId,
307
- workspace.id,
308
- options
309
- );
310
- if (!invite) {
311
- throw new AppError(404, "Invite not found.");
312
- }
313
-
314
- await workspaceInvitesRepository.revokeById(normalizedInviteId, options);
315
- const revokedInviteId = normalizeRecordId(invite?.id, { fallback: null });
316
- if (!revokedInviteId) {
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, input);
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
- await revokeExpiredInviteAndThrow(resolvedInvite.invite, options);
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
- const { resolvedInvite, workspaceId } = await resolveInviteActionInput(
206
- user,
207
- token,
208
- options,
209
- "workspacePendingInvitationsService.acceptInviteByToken"
210
- );
211
-
212
- await workspaceMembershipsRepository.upsertMembership(
213
- workspaceId,
214
- resolvedInvite.user.id,
215
- {
216
- roleSid: resolvedInvite.invite.roleSid,
217
- status: "active"
218
- },
219
- options
220
- );
221
- await workspaceInvitesRepository.markAcceptedById(resolvedInvite.invite.id, options);
222
-
223
- return {
224
- decision: "accepted",
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
- const { resolvedInvite, workspaceId } = await resolveInviteActionInput(
231
- user,
232
- token,
233
- options,
234
- "workspacePendingInvitationsService.refuseInviteByToken"
235
- );
236
- await workspaceInvitesRepository.revokeById(resolvedInvite.invite.id, options);
237
-
238
- return {
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);