@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.
@@ -18,6 +18,8 @@ import { createRepository as createMembershipsRepository } from "../src/server/c
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
20
 
21
+ import { verifyInvitationTransactions } from "./support/invitationTransactions.js";
22
+
21
23
  const TABLES = [
22
24
  "workspace_invites", "workspace_settings", "workspace_memberships", "workspaces", "user_settings", "users"
23
25
  ];
@@ -45,7 +47,7 @@ function databaseConfig() {
45
47
  password: decodeURIComponent(url.password),
46
48
  database: url.pathname.slice(1)
47
49
  },
48
- pool: { min: 1, max: 2 }
50
+ pool: { min: 1, max: 6 }
49
51
  };
50
52
  }
51
53
  return {
@@ -61,7 +63,7 @@ function databaseConfig() {
61
63
  supportBigNumbers: true,
62
64
  bigNumberStrings: true
63
65
  },
64
- pool: { min: 1, max: 2 }
66
+ pool: { min: 1, max: 6 }
65
67
  };
66
68
  }
67
69
 
@@ -82,6 +84,12 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
82
84
  before(async () => {
83
85
  knex = knexLib(configuration);
84
86
  if (databaseUrl) {
87
+ const target = configuration.client === "pg"
88
+ ? (await knex.raw("SELECT current_database() AS name, version() AS version")).rows[0]
89
+ : (await knex.raw("SELECT DATABASE() AS name, VERSION() AS version"))[0][0];
90
+ assert.equal(target.name, configuration.connection.database);
91
+ assert.match(target.name, /^jskit_repository_test_[a-z0-9_]+$/u);
92
+ console.log(`Disposable repository target: ${target.name}; engine: ${target.version}`);
85
93
  const tables = configuration.client === "pg"
86
94
  ? await knex("information_schema.tables").where({ table_schema: "public" }).select("table_name")
87
95
  : (await knex.raw("SHOW TABLES"))[0];
@@ -110,6 +118,11 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
110
118
  });
111
119
 
112
120
  beforeEach(async () => {
121
+ if (await knex.schema.hasTable("test_invitation_drafts")) {
122
+ for (const table of ["test_invitation_audits", "test_invitation_scopes", "test_invitation_drafts"]) {
123
+ await knex(table).delete();
124
+ }
125
+ }
113
126
  for (const table of TABLES) await knex(table).delete();
114
127
  });
115
128
 
@@ -320,4 +333,7 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
320
333
  assert.equal(Number((await knex("users").count({ count: "*" }).first()).count), 1);
321
334
  assert.equal((await profiles.findByEmail(owner.email)).id, owner.id);
322
335
  });
336
+ test("invitation creation and acceptance transaction participation", async (t) => {
337
+ await verifyInvitationTransactions(t, knex);
338
+ });
323
339
  });
@@ -0,0 +1,476 @@
1
+ import assert from "node:assert/strict";
2
+ import { createAuthExtensions } from "@jskit-ai/auth-core/server/authExtensions";
3
+ import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
4
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
+ import { createJsonRestApiHost } from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
6
+ import { UsersIdentityProvider } from "@jskit-ai/users-core/server/UsersIdentityProvider";
7
+ import { createUsersExtensions } from "@jskit-ai/users-core/server/usersExtensions";
8
+ import { createWorkspaceRoleCatalog } from "../../src/shared/roles.js";
9
+ import { installWorkspaceResources } from "../../src/server/WorkspacesFeature.js";
10
+ import { createRepository as invitesRepository } from "../../src/server/common/repositories/workspaceInvitesRepository.js";
11
+ import { createRepository as membershipsRepository } from "../../src/server/common/repositories/workspaceMembershipsRepository.js";
12
+ import { createRepository as workspacesRepository } from "../../src/server/common/repositories/workspacesRepository.js";
13
+ import { createService as membersService } from "../../src/server/workspaceMembers/workspaceMembersService.js";
14
+ import { createService as pendingService } from "../../src/server/workspacePendingInvitations/workspacePendingInvitationsService.js";
15
+ import { buildWorkspacePendingInvitationsActions } from "../../src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js";
16
+
17
+ // The caller verifies an empty disposable database and runs package migrations.
18
+ // These application fixtures demonstrate composition, not production policy.
19
+ export async function verifyInvitationTransactions(t, db) {
20
+ const native = ["mysql2", "pg"].includes(db.client.config.client);
21
+ const applicationTables = ["test_invitation_audits", "test_invitation_scopes", "test_invitation_drafts"];
22
+ t.after(async () => {
23
+ for (const table of applicationTables) await db.schema.dropTableIfExists(table);
24
+ });
25
+ await db.schema.createTable("test_invitation_drafts", (table) => {
26
+ table.increments("id");
27
+ table.bigInteger("workspace_id").unsigned().notNullable().references("id").inTable("workspaces");
28
+ table.bigInteger("invite_id").unsigned().nullable().unique().references("id").inTable("workspace_invites");
29
+ table.bigInteger("user_id").unsigned().nullable().references("id").inTable("users");
30
+ table.bigInteger("training_user_id").unsigned().nullable().references("id").inTable("users");
31
+ table.string("review_state").notNullable().defaultTo("reviewed");
32
+ });
33
+ for (const name of applicationTables.slice(0, 2)) {
34
+ await db.schema.createTable(name, (table) => {
35
+ table.increments("id");
36
+ table.integer("draft_id").unsigned().notNullable().unique().references("id").inTable("test_invitation_drafts");
37
+ });
38
+ }
39
+ const api = await createJsonRestApiHost({ knex: db, logger: { error() {}, warn() {}, info() {}, debug() {} } });
40
+ const { identity } = await UsersIdentityProvider.setup({
41
+ authExtensions: createAuthExtensions(), extensions: createUsersExtensions(), jsonRestApi: api
42
+ });
43
+ await installWorkspaceResources(api);
44
+ await api.addResource("invitationTestPeople", {
45
+ tableName: "test_invitation_drafts",
46
+ schema: {
47
+ id: { type: "id", primary: true },
48
+ userId: { type: "id", nullable: true, storage: { column: "user_id" } },
49
+ trainingUserId: { type: "id", nullable: true, storage: { column: "training_user_id" } }
50
+ }
51
+ });
52
+ let failCompletionForMethod = null;
53
+ await api.customize({ hooks: { afterCommit: ({ context }) => {
54
+ if (context.scopeName === "workspaceInvites" && context.method === failCompletionForMethod) {
55
+ throw new Error("Controlled completion failure");
56
+ }
57
+ } } });
58
+ const invites = invitesRepository({ api });
59
+ const memberships = membershipsRepository({ api });
60
+ const workspaces = workspacesRepository({ api });
61
+ const roleCatalog = createWorkspaceRoleCatalog({ roleCatalog: {
62
+ workspace: { defaultInviteRole: "member" },
63
+ roles: { owner: { assignable: false, permissions: ["*"] }, member: { assignable: true, permissions: [] } }
64
+ } });
65
+ const profile = (name) => identity.repositories.userProfiles.upsert({
66
+ authProvider: "local", authProviderUserSid: name, email: `${name}@example.test`, displayName: name
67
+ });
68
+ let sequence = 0;
69
+ async function fixture({ mailer = true, inviteRepository = invites } = {}) {
70
+ const suffix = `invitation-${++sequence}`;
71
+ const user = await profile(suffix);
72
+ const owner = await profile(`${suffix}-owner`);
73
+ const workspace = await workspaces.insert({ slug: suffix, name: suffix, ownerUserId: owner.id });
74
+ await db("test_invitation_drafts").insert({ workspace_id: workspace.id });
75
+ const { id: draftId } = await db("test_invitation_drafts").where({ workspace_id: workspace.id }).first();
76
+ const delivered = [];
77
+ const events = [];
78
+ let failDelivery = false;
79
+ const members = membersService({
80
+ workspaceInvitesRepository: inviteRepository, workspaceMembershipsRepository: memberships,
81
+ inviteExpiresInMs: 3600000, roleCatalog,
82
+ workspaceInviteMailer: mailer ? { async sendWorkspaceInvite(message, options) {
83
+ assert.equal(options.trx, undefined);
84
+ const saved = await db("workspace_invites").where({ workspace_id: workspace.id, status: "pending" }).first();
85
+ assert.ok(saved, "An independent read sees the committed invitation before delivery.");
86
+ assert.equal(message.email, user.email);
87
+ assert.equal(message.roleSid, "member");
88
+ assert.equal(message.workspace.name, workspace.name);
89
+ if (failDelivery) throw new Error(`Controlled mail failure containing secret ${message.inviteUrl}`);
90
+ delivered.push(String(saved.id));
91
+ return { status: "sent", providerMessageId: "controlled-message" };
92
+ } } : null
93
+ });
94
+ const pending = pendingService({ workspaceInvitesRepository: inviteRepository, workspaceMembershipsRepository: memberships });
95
+ const options = { context: { actor: user, channel: "api", permissions: ["workspace.members.invite"] } };
96
+ const payload = { email: user.email, roleSid: "member" };
97
+ const actions = createActionCatalogue({ events: { async publish(event) { events.push(event); } } });
98
+ actions.register({ contributorId: suffix, domain: "workspace", actions: buildWorkspacePendingInvitationsActions({ workspacePendingInvitationsService: pending }) });
99
+ const redeem = (prepared, decision = "accept", actor = user, extraInput = {}) => actions.execute({
100
+ actionId: "workspace.invite.redeem",
101
+ input: { token: prepared.inviteTokenPreview, decision, ...extraInput },
102
+ context: { ...options.context, actor }
103
+ });
104
+ // Lock workspace before application receipt rows, matching acceptance's
105
+ // lock order. A lost response/retry returns the exact persisted invitation.
106
+ async function prepareOnce({ fail = false } = {}) {
107
+ return invites.withTransaction(async (trx) => {
108
+ await invites.lockWorkspaceForInvitations(workspace.id, { trx });
109
+ const draft = await trx("test_invitation_drafts").where({ id: draftId, workspace_id: workspace.id }).forUpdate().first();
110
+ if (draft.invite_id) return { createdInviteId: String(draft.invite_id) };
111
+ const prepared = await members.prepareInvite(workspace, owner, payload, { ...options, trx });
112
+ await trx("test_invitation_drafts").where({ id: draftId }).update({ invite_id: prepared.createdInviteId });
113
+ if (fail) throw new Error("Controlled association failure");
114
+ return prepared;
115
+ });
116
+ }
117
+ async function applyAssociation({ invite, user: acceptingUser }, transactionOptions) {
118
+ const { trx, context } = transactionOptions;
119
+ assert.ok(Object.isFrozen(invite));
120
+ assert.ok(Object.isFrozen(transactionOptions));
121
+ assert.equal(invite.workspaceId, workspace.id);
122
+ assert.equal(acceptingUser.id, user.id);
123
+ assert.equal(context.actor.id, user.id);
124
+ const membership = await memberships.findByWorkspaceIdAndUserId(workspace.id, user.id, transactionOptions);
125
+ assert.equal(membership.status, "active", "Domain scope owners see uncommitted membership");
126
+ const draft = await trx("test_invitation_drafts").where({ invite_id: invite.id }).forUpdate().first();
127
+ if (!draft) return; // Ordinary invitation, no application association.
128
+ assert.equal(String(draft.workspace_id), invite.workspaceId, "Foreign workspace association");
129
+ assert.equal(draft.review_state, "reviewed", "Invalid reviewed association");
130
+ assert.ok(!draft.user_id || String(draft.user_id) === acceptingUser.id, "Conflicting account");
131
+ // Application identity/training owner uses actual managed resource writes.
132
+ await api.resources.invitationTestPeople.patch({
133
+ id: String(draft.id), data: { userId: acceptingUser.id, trainingUserId: acceptingUser.id },
134
+ transaction: trx, format: "plain", returning: "full"
135
+ });
136
+ await trx("test_invitation_scopes").insert({ draft_id: draft.id });
137
+ await trx("test_invitation_audits").insert({ draft_id: draft.id });
138
+ }
139
+ const draft = () => db("test_invitation_drafts").where({ id: draftId }).first();
140
+ const invitation = (prepared) => db("workspace_invites").where({ id: prepared.createdInviteId }).first();
141
+ async function assertUnlinked(prepared) {
142
+ assert.equal(await memberships.findByWorkspaceIdAndUserId(workspace.id, user.id), null);
143
+ assert.equal((await invitation(prepared)).status, "pending");
144
+ assert.equal((await draft()).user_id, null);
145
+ assert.equal((await draft()).training_user_id, null);
146
+ for (const table of applicationTables.slice(0, 2)) assert.equal((await db(table).where({ draft_id: draftId })).length, 0);
147
+ assert.equal(events.length, 0);
148
+ }
149
+ return { user, owner, workspace, draftId, draft, invitation, assertUnlinked, members, pending, redeem, events,
150
+ delivered, options, payload, prepareOnce, applyAssociation, failDelivery() { failDelivery = true; } };
151
+ }
152
+
153
+ await t.test("ordinary creation commits before controlled delivery and built-in acceptance", async () => {
154
+ const f = await fixture();
155
+ const created = await f.members.createInvite(f.workspace, f.owner, f.payload, f.options);
156
+ assert.equal(created.inviteDelivery.status, "sent");
157
+ assert.deepEqual(f.delivered, [created.createdInviteId]);
158
+ await f.redeem(created);
159
+ assert.equal((await f.invitation(created)).status, "accepted");
160
+ assert.equal((await memberships.findByWorkspaceIdAndUserId(f.workspace.id, f.user.id)).status, "active");
161
+ assert.ok(f.events.length > 0);
162
+ });
163
+
164
+ await t.test("ordinary invitation works with an application participant and no association", async () => {
165
+ const f = await fixture({ mailer: false });
166
+ const created = await f.members.createInvite(f.workspace, f.owner, f.payload);
167
+ assert.equal(created.inviteDelivery.status, "mailer_unconfigured");
168
+ f.pending.registerAcceptanceParticipant(f.applyAssociation);
169
+ await f.redeem(created);
170
+ assert.equal((await f.invitation(created)).status, "accepted");
171
+ assert.equal((await f.draft()).user_id, null);
172
+ });
173
+
174
+ await t.test("association rollback restores earlier invitation state and sends nothing", async () => {
175
+ const f = await fixture();
176
+ const previous = await f.members.createInvite(f.workspace, f.owner, f.payload);
177
+ await assert.rejects(f.prepareOnce({ fail: true }), (error) => error.transactionOutcome === "rolledBack");
178
+ const rows = await db("workspace_invites").where({ workspace_id: f.workspace.id });
179
+ assert.equal(rows.length, 1);
180
+ assert.equal(String(rows[0].id), previous.createdInviteId);
181
+ assert.equal(rows[0].status, "pending");
182
+ assert.equal((await f.draft()).invite_id, null);
183
+ assert.deepEqual(f.delivered, [previous.createdInviteId]);
184
+ });
185
+
186
+ await t.test("reviewed receipt survives a lost response; normal redeem commits all owners", async () => {
187
+ const f = await fixture();
188
+ const prepared = await f.prepareOnce();
189
+ const receipt = await f.prepareOnce();
190
+ assert.equal(receipt.createdInviteId, prepared.createdInviteId);
191
+ assert.equal(f.delivered.length, 0);
192
+ const sent = await f.members.sendInvite({ ...f.workspace, name: "Untrusted replacement" }, f.owner, {
193
+ ...receipt, email: "other@example.test", roleSid: "owner", inviteUrl: "https://untrusted.example"
194
+ });
195
+ assert.equal(sent.createdInviteId, prepared.createdInviteId);
196
+ f.pending.registerAcceptanceParticipant(f.applyAssociation);
197
+ await f.redeem(sent);
198
+ const linked = await f.draft();
199
+ assert.equal(String(linked.user_id), f.user.id);
200
+ assert.equal(String(linked.training_user_id), f.user.id);
201
+ assert.equal((await f.invitation(prepared)).status, "accepted");
202
+ for (const table of applicationTables.slice(0, 2)) assert.equal((await db(table).where({ draft_id: f.draftId })).length, 1);
203
+ });
204
+
205
+ await t.test("participant failure rolls back membership, identity, training, scopes, audit and status", async () => {
206
+ const f = await fixture();
207
+ const prepared = await f.prepareOnce();
208
+ f.pending.registerAcceptanceParticipant(async (...args) => {
209
+ await f.applyAssociation(...args);
210
+ throw new Error("Controlled audit failure");
211
+ });
212
+ await assert.rejects(f.redeem(prepared), (error) => {
213
+ assert.equal(error.transactionOutcome, "rolledBack");
214
+ assert.match(error.cause.message, /Controlled audit failure/);
215
+ return true;
216
+ });
217
+ await f.assertUnlinked(prepared);
218
+ });
219
+
220
+ await t.test("late status failure restores an existing membership and all application writes", async () => {
221
+ const f = await fixture({ inviteRepository: { ...invites, async markAcceptedById(id, options) {
222
+ await invites.markAcceptedById(id, options);
223
+ throw new Error("Controlled final status failure");
224
+ } } });
225
+ const prepared = await f.prepareOnce();
226
+ await memberships.upsertMembership(f.workspace.id, f.user.id, { roleSid: "existing-role", status: "revoked" });
227
+ f.pending.registerAcceptanceParticipant(f.applyAssociation);
228
+ await assert.rejects(f.redeem(prepared), /final status failure/);
229
+ const previous = await memberships.findByWorkspaceIdAndUserId(f.workspace.id, f.user.id);
230
+ assert.equal(previous.roleSid, "existing-role");
231
+ assert.equal(previous.status, "revoked");
232
+ assert.equal((await f.invitation(prepared)).status, "pending");
233
+ assert.equal((await f.draft()).user_id, null);
234
+ assert.equal((await f.draft()).training_user_id, null);
235
+ for (const table of applicationTables.slice(0, 2)) assert.equal((await db(table).where({ draft_id: f.draftId })).length, 0);
236
+ assert.equal(f.events.length, 0);
237
+ });
238
+
239
+ await t.test("acceptance completion failure reports committed and retains all accepted writes", async () => {
240
+ const f = await fixture();
241
+ const prepared = await f.prepareOnce();
242
+ f.pending.registerAcceptanceParticipant(f.applyAssociation);
243
+ failCompletionForMethod = "patch";
244
+ try {
245
+ await assert.rejects(f.redeem(prepared), (error) => error.transactionOutcome === "committed");
246
+ } finally {
247
+ failCompletionForMethod = null;
248
+ }
249
+ assert.equal((await f.invitation(prepared)).status, "accepted");
250
+ assert.equal((await memberships.findByWorkspaceIdAndUserId(f.workspace.id, f.user.id)).status, "active");
251
+ assert.equal(String((await f.draft()).user_id), f.user.id);
252
+ assert.equal(String((await f.draft()).training_user_id), f.user.id);
253
+ for (const table of applicationTables.slice(0, 2)) assert.equal((await db(table).where({ draft_id: f.draftId })).length, 1);
254
+ });
255
+
256
+ await t.test("preparation completion failure preserves its receipt without sending or recreating", async () => {
257
+ const f = await fixture();
258
+ failCompletionForMethod = "post";
259
+ try {
260
+ await assert.rejects(f.prepareOnce(), (error) => error.transactionOutcome === "committed");
261
+ } finally {
262
+ failCompletionForMethod = null;
263
+ }
264
+ const receipt = await f.prepareOnce();
265
+ assert.equal(receipt.createdInviteId, String((await f.draft()).invite_id));
266
+ assert.equal((await db("workspace_invites").where({ workspace_id: f.workspace.id })).length, 1);
267
+ assert.equal(f.delivered.length, 0);
268
+ });
269
+
270
+ await t.test("ordinary creation does not deliver after a preparation completion error", async () => {
271
+ const f = await fixture();
272
+ failCompletionForMethod = "post";
273
+ try {
274
+ await assert.rejects(f.members.createInvite(f.workspace, f.owner, f.payload), (error) => error.transactionOutcome === "committed");
275
+ } finally {
276
+ failCompletionForMethod = null;
277
+ }
278
+ assert.equal((await db("workspace_invites").where({ workspace_id: f.workspace.id, status: "pending" })).length, 1);
279
+ assert.equal(f.delivered.length, 0);
280
+ });
281
+
282
+ for (const state of ["stale", "revoked-review", "departed-person", "invalid-scope", "inviter-departed", "conflicting-account", "foreign-workspace"]) {
283
+ await t.test(`application rejects ${state} without partial activation`, async () => {
284
+ const f = await fixture();
285
+ const prepared = await f.prepareOnce();
286
+ const patch = state === "conflicting-account" ? { user_id: f.owner.id }
287
+ : state === "foreign-workspace" ? { workspace_id: (await fixture()).workspace.id }
288
+ : { review_state: state };
289
+ await db("test_invitation_drafts").where({ id: f.draftId }).update(patch);
290
+ const before = await f.draft();
291
+ f.pending.registerAcceptanceParticipant(f.applyAssociation);
292
+ await assert.rejects(f.redeem(prepared), (error) => error.transactionOutcome === "rolledBack");
293
+ assert.deepEqual(await f.draft(), before);
294
+ assert.equal(await memberships.findByWorkspaceIdAndUserId(f.workspace.id, f.user.id), null);
295
+ assert.equal((await f.invitation(prepared)).status, "pending");
296
+ for (const table of applicationTables.slice(0, 2)) assert.equal((await db(table).where({ draft_id: f.draftId })).length, 0);
297
+ });
298
+ }
299
+
300
+ for (const state of ["unauthenticated", "wrong-email", "missing", "expired", "revoked", "accepted"]) {
301
+ await t.test(`${state} invitation cannot reach the participant`, async () => {
302
+ const f = await fixture();
303
+ const prepared = await f.prepareOnce();
304
+ if (state === "expired") await db("workspace_invites").where({ id: prepared.createdInviteId }).update({ expires_at: "2000-01-01 00:00:00" });
305
+ if (state === "revoked") await f.members.revokeInvite(f.workspace, prepared.createdInviteId);
306
+ if (state === "accepted") await f.redeem(prepared);
307
+ let called = false;
308
+ // An already-used service intentionally seals registration; use a fresh
309
+ // instance to verify terminal-state validation independently of sealing.
310
+ const pending = pendingService({ workspaceInvitesRepository: invites, workspaceMembershipsRepository: memberships });
311
+ pending.registerAcceptanceParticipant(async () => { called = true; });
312
+ const membershipBefore = await memberships.findByWorkspaceIdAndUserId(f.workspace.id, f.user.id);
313
+ await assert.rejects(pending.acceptInviteByToken({
314
+ user: state === "unauthenticated" ? null : state === "wrong-email" ? f.owner : f.user,
315
+ token: state === "missing" ? encodeInviteTokenHash("f".repeat(64)) : prepared.inviteTokenPreview
316
+ }), (error) => [401, 403, 404, 409].includes(error.status));
317
+ assert.equal(called, false);
318
+ assert.deepEqual(await memberships.findByWorkspaceIdAndUserId(f.workspace.id, f.user.id), membershipBefore);
319
+ if (state === "expired") assert.equal((await f.invitation(prepared)).status, "revoked");
320
+ });
321
+ }
322
+
323
+ await t.test("built-in redeem cannot replace the authenticated actor from input", async () => {
324
+ const f = await fixture();
325
+ const prepared = await f.prepareOnce();
326
+ let called = false;
327
+ f.pending.registerAcceptanceParticipant(async () => { called = true; });
328
+ await assert.rejects(f.redeem(prepared, "accept", f.owner, { user: f.user, personId: f.draftId, roleSid: "owner" }));
329
+ assert.equal(called, false);
330
+ await f.assertUnlinked(prepared);
331
+ });
332
+
333
+ await t.test("transaction misuse fails without invitation or membership writes", async () => {
334
+ const f = await fixture();
335
+ await assert.rejects(f.members.prepareInvite(f.workspace, f.owner, f.payload), /requires a managed transaction/);
336
+ await assert.rejects(db.transaction((trx) => f.members.prepareInvite(f.workspace, f.owner, f.payload, { trx })));
337
+ assert.equal((await db("workspace_invites").where({ workspace_id: f.workspace.id })).length, 0);
338
+ const prepared = await f.prepareOnce();
339
+ await invites.withTransaction(async (trx) => {
340
+ await assert.rejects(f.members.createInvite(f.workspace, f.owner, f.payload, { trx }), /prepareInvite/);
341
+ await assert.rejects(f.members.sendInvite(f.workspace, f.owner, prepared, { trx }), /after.*commits/);
342
+ for (const method of ["acceptInviteByToken", "refuseInviteByToken"]) {
343
+ await assert.rejects(f.pending[method]({ user: f.user, token: prepared.inviteTokenPreview }, { trx }), /owns its transaction/);
344
+ }
345
+ });
346
+ await f.assertUnlinked(prepared);
347
+ assert.equal(f.delivered.length, 0);
348
+ });
349
+
350
+ for (const state of ["missing", "foreign", "expired", "revoked", "accepted", "mismatched", "empty-token"]) {
351
+ await t.test(`delivery rejects ${state} invitation`, async () => {
352
+ const f = await fixture();
353
+ const prepared = await f.prepareOnce();
354
+ if (state === "expired") await db("workspace_invites").where({ id: prepared.createdInviteId }).update({ expires_at: "2000-01-01 00:00:00" });
355
+ if (state === "revoked") await f.members.revokeInvite(f.workspace, prepared.createdInviteId);
356
+ if (state === "accepted") await f.redeem(prepared);
357
+ const delivery = state === "missing" ? { createdInviteId: "999999999" }
358
+ : ["mismatched", "empty-token"].includes(state) ? { ...prepared, inviteTokenPreview: state === "empty-token" ? "" : "wrong-token" }
359
+ : prepared;
360
+ const workspace = state === "foreign" ? (await fixture()).workspace : f.workspace;
361
+ await assert.rejects(f.members.sendInvite(workspace, f.owner, delivery), (error) => [400, 404, 409].includes(error.status));
362
+ assert.equal(f.delivered.length, 0);
363
+ });
364
+ }
365
+
366
+ await t.test("controlled delivery failure preserves association and redacts the provider error", async () => {
367
+ const f = await fixture();
368
+ const prepared = await f.prepareOnce();
369
+ f.failDelivery();
370
+ const response = await f.members.sendInvite(f.workspace, f.owner, prepared);
371
+ assert.equal(response.inviteDelivery.status, "failed");
372
+ assert.equal(response.inviteDelivery.message, "Unable to send workspace invite email.");
373
+ assert.equal((await f.prepareOnce()).createdInviteId, prepared.createdInviteId);
374
+ await f.assertUnlinked(prepared);
375
+ });
376
+
377
+ await t.test("terminal repository transitions cannot overwrite accepted or revoked invitations", async () => {
378
+ for (const decision of ["accept", "refuse"]) {
379
+ const f = await fixture();
380
+ const prepared = await f.prepareOnce();
381
+ await f.redeem(prepared, decision);
382
+ const before = await f.invitation(prepared);
383
+ for (const method of ["markAcceptedById", "revokeById"]) {
384
+ await assert.rejects(invites[method](prepared.createdInviteId), (error) => error.status === 404);
385
+ }
386
+ assert.deepEqual(await f.invitation(prepared), before);
387
+ }
388
+ });
389
+
390
+ await t.test("native uncommitted delivery and concurrent reviewed receipts", {
391
+ skip: !native && "SQLite single-connection fixture is not native locking evidence"
392
+ }, async () => {
393
+ const f = await fixture();
394
+ await invites.withTransaction(async (trx) => {
395
+ const prepared = await f.members.prepareInvite(f.workspace, f.owner, f.payload, { trx });
396
+ await assert.rejects(f.members.sendInvite(f.workspace, f.owner, prepared), (error) => error.status === 404);
397
+ });
398
+ const [first, second] = await Promise.all([f.prepareOnce(), f.prepareOnce()]);
399
+ assert.equal(first.createdInviteId, second.createdInviteId);
400
+ assert.equal((await db("workspace_invites").where({ workspace_id: f.workspace.id, status: "pending" })).length, 1);
401
+ assert.equal(f.delivered.length, 0);
402
+ });
403
+
404
+ for (const competitor of ["accept", "refuse", "revoke", "replace"]) {
405
+ for (const acceptanceWins of [true, false]) {
406
+ if (competitor === "accept" && !acceptanceWins) continue;
407
+ await t.test(`native acceptance versus ${competitor}, ${acceptanceWins ? "acceptance" : "competitor"} holds the first lock`, {
408
+ skip: !native && "SQLite single-connection fixture is not native locking evidence",
409
+ timeout: 15000
410
+ }, async () => {
411
+ const entered = Promise.withResolvers();
412
+ const release = Promise.withResolvers();
413
+ let holdTransaction = false;
414
+ const connections = [];
415
+ const repository = { ...invites, async withTransaction(work) {
416
+ return invites.withTransaction(async (trx) => {
417
+ const result = db.client.config.client === "pg"
418
+ ? (await trx.raw("SELECT pg_backend_pid() AS id")).rows[0]
419
+ : (await trx.raw("SELECT CONNECTION_ID() AS id"))[0][0];
420
+ connections.push(result.id);
421
+ const value = await work(trx);
422
+ if (holdTransaction) {
423
+ holdTransaction = false;
424
+ entered.resolve();
425
+ await release.promise;
426
+ }
427
+ return value;
428
+ });
429
+ } };
430
+ const f = await fixture({ inviteRepository: repository });
431
+ const prepared = await f.prepareOnce();
432
+ let participation = 0;
433
+ f.pending.registerAcceptanceParticipant(async (...args) => { participation++; await f.applyAssociation(...args); });
434
+ const compete = () => competitor === "accept" || competitor === "refuse" ? f.redeem(prepared, competitor)
435
+ : competitor === "revoke" ? f.members.revokeInvite(f.workspace, prepared.createdInviteId)
436
+ : f.members.createInvite(f.workspace, f.owner, f.payload);
437
+ holdTransaction = true;
438
+ connections.length = 0;
439
+ 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.
442
+ const firstSettled = Promise.allSettled([first]);
443
+ await entered.promise;
444
+ const waiting = Promise.withResolvers();
445
+ const queryListener = (query) => {
446
+ if (/select.*["`]workspaces["`].*for update/iu.test(query.sql)) waiting.resolve();
447
+ };
448
+ db.on("query", queryListener);
449
+ try {
450
+ const second = acceptanceWins ? compete() : f.redeem(prepared);
451
+ const secondSettled = Promise.allSettled([second]);
452
+ await waiting.promise;
453
+ release.resolve();
454
+ const [winner] = await firstSettled;
455
+ const [loser] = await secondSettled;
456
+ assert.equal(winner.status, "fulfilled", winner.reason?.stack);
457
+ if (acceptanceWins && competitor === "replace") assert.equal(loser.status, "fulfilled", loser.reason?.stack);
458
+ else {
459
+ assert.equal(loser.status, "rejected");
460
+ assert.equal(loser.reason.status, 404, loser.reason.stack);
461
+ }
462
+ assert.notEqual(connections[0], connections[1], "Competing units own independent database connections");
463
+ assert.equal(participation, acceptanceWins ? 1 : 0);
464
+ const expected = acceptanceWins ? "accepted" : competitor === "replace" ? "expired" : "revoked";
465
+ assert.equal((await f.invitation(prepared)).status, expected);
466
+ for (const table of applicationTables.slice(0, 2)) {
467
+ assert.equal((await db(table).where({ draft_id: f.draftId })).length, acceptanceWins ? 1 : 0);
468
+ }
469
+ } finally {
470
+ db.off("query", queryListener);
471
+ release.resolve();
472
+ }
473
+ });
474
+ }
475
+ }
476
+ }
@@ -43,13 +43,21 @@ function createWorkspaceInvitesApiStub({
43
43
  patchPayloads: []
44
44
  };
45
45
 
46
+ const trx = () => ({
47
+ where() { return this; },
48
+ select() { return this; },
49
+ async first() { return { id: "1" }; },
50
+ forUpdate() { return this; },
51
+ then(resolve) { return Promise.resolve([...rowById.values()].filter((row) => row.status === "pending")).then(resolve); }
52
+ });
46
53
  const api = {
54
+ async transaction(work) { return work(trx); },
47
55
  resources: {
48
56
  workspaceInvites: {
49
57
  async query({ queryParams, format }) {
50
58
  assert.equal(format, "plain");
51
59
  const filters = queryParams?.filters || {};
52
- const matching = rows.filter((row) => {
60
+ const matching = (rows.length ? rows : [...rowById.values()]).filter((row) => {
53
61
  if (Object.hasOwn(filters, "id") && String(row.id) !== String(filters.id)) {
54
62
  return false;
55
63
  }
@@ -213,7 +221,7 @@ test("workspaceInvitesRepository.markAcceptedById uses the internal invite resou
213
221
  workspace: { id: "1" },
214
222
  email: "invitee@example.com",
215
223
  roleSid: "member",
216
- status: "accepted",
224
+ status: "pending",
217
225
  tokenHash: "hash",
218
226
  invitedByUser: { id: "1" },
219
227
  expiresAt: "2026-03-16 00:26:35.709",