@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.1.199",
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.221",
22
- "@jskit-ai/json-rest-api-core": "0.1.165",
23
- "@jskit-ai/resource-core": "0.1.163",
24
- "@jskit-ai/resource-crud-core": "0.1.163",
25
- "@jskit-ai/users-core": "0.1.234",
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.219",
173
- "@jskit-ai/http-runtime": "0.1.219",
174
- "@jskit-ai/kernel": "0.1.221"
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
- Use the existing `workspaces.core` capability. During application feature setup,
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/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,
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. Failed association restores any
53
- prior pending invitation. A draft of intended access should not call preparation.
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 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.
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`. Move transactional creation to preparation/post-commit
67
- sending and acceptance-related writes into the participant. Revocation may join
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 attempts to overwrite terminal state. Matching-recipient
71
- expiry rejection commits revocation before returning 409. Preserve transaction
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 still
77
- own authorization, identity/training linking, reviewed scope eligibility and
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
- // 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" };
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]) throw new TypeError("Unsupported locking invitation filter.");
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 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.
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", 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")
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) throw new TypeError("Invitation locking requires workspaceId.");
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) throw new AppError(404, "Invitation workspace no longer exists.");
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
- // The current locking query uses only IDs, keeping bearer-equivalent token
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) throw new TypeError("Invitation transition requires inviteId.");
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]) throw new AppError(404, "Invitation not found or already handled.");
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) throw new AppError(404, "Invitation not found or already handled.");
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) await lockWorkspaceForInvitations(normalizedWorkspaceId, options);
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) throw new TypeError("prepareInvite requires a managed transaction.");
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) throw new TypeError("sendInvite must run after the invitation transaction commits.");
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, workspace.id, { ...options, includeWorkspace: true, lock: false }
287
+ prepared.createdInviteId,
288
+ workspace.id,
289
+ { ...options, includeWorkspace: true, lock: false }
286
290
  );
287
- if (!invite) throw new AppError(404, "Invite not found or already handled.");
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, inviteUrl, inviteDelivery,
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, workspace.id, transactionOptions
343
+ normalizedInviteId,
344
+ workspace.id,
345
+ transactionOptions
336
346
  );
337
- if (!invite) throw new AppError(404, "Invite not found.");
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") 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.");
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 inTransaction(user, token, options, work) {
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 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.
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, trx, lock: true, workspaceId: initial.invite.workspaceId, inviteId: initial.invite.id
40
+ ...options,
41
+ trx,
42
+ lock: true,
43
+ workspaceId: initial.invite.workspaceId,
44
+ inviteId: initial.invite.id
36
45
  }));
37
- // Expiry revocation intentionally commits before reporting rejection, as it
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 inTransaction(user, token, options, async (transactionOptions) => {
239
+ return withInviteTransaction(user, token, options, async (transactionOptions) => {
232
240
  const resolved = await resolveInviteActionInput(
233
- user, token, transactionOptions, "workspacePendingInvitationsService.acceptInviteByToken"
241
+ user,
242
+ token,
243
+ transactionOptions,
244
+ "workspacePendingInvitationsService.acceptInviteByToken"
234
245
  );
235
- if (resolved.expired) return resolved;
246
+ if (resolved.expired) {
247
+ return resolved;
248
+ }
236
249
  const { resolvedInvite, workspaceId } = resolved;
237
250
  await workspaceMembershipsRepository.upsertMembership(
238
- workspaceId, resolvedInvite.user.id,
251
+ workspaceId,
252
+ resolvedInvite.user.id,
239
253
  { roleSid: resolvedInvite.invite.roleSid, status: "active" },
240
254
  transactionOptions
241
255
  );
242
256
  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.
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 inTransaction(user, token, options, async (transactionOptions) => {
269
+ return withInviteTransaction(user, token, options, async (transactionOptions) => {
257
270
  const resolved = await resolveInviteActionInput(
258
- user, token, transactionOptions, "workspacePendingInvitationsService.refuseInviteByToken"
271
+ user,
272
+ token,
273
+ transactionOptions,
274
+ "workspacePendingInvitationsService.refuseInviteByToken"
259
275
  );
260
- if (resolved.expired) return resolved;
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
- // Lock workspace before application receipt rows, matching acceptance's
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
- // An already-used service intentionally seals registration; use a fresh
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
- // Observe errors immediately so an assertion failure cannot create an
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) { return 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) { return 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) { return work({}); },
262
- async lockWorkspaceForInvitations() {},
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) { return 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() { return { workspaceId: "1", email: "a@example.test" }; },
298
- async withTransaction() { throw failure; }
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
  });