@cosmicdrift/kumiko-bundled-features 0.328.0 → 0.328.1
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/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +34 -3
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +204 -20
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +14 -28
- package/src/auth-email-password/handlers/invite-accept.write.ts +15 -39
- package/src/tenant/constants.ts +1 -0
- package/src/tenant/handlers/remove-member.write.ts +75 -3
- package/src/tenant/invited-membership.ts +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.328.
|
|
3
|
+
"version": "0.328.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -134,12 +134,12 @@
|
|
|
134
134
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.328.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.328.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.328.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.328.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.328.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "0.328.
|
|
137
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.328.1",
|
|
138
|
+
"@cosmicdrift/kumiko-framework": "0.328.1",
|
|
139
|
+
"@cosmicdrift/kumiko-headless": "0.328.1",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer": "0.328.1",
|
|
141
|
+
"@cosmicdrift/kumiko-renderer-web": "0.328.1",
|
|
142
|
+
"@cosmicdrift/kumiko-types": "0.328.1",
|
|
143
143
|
"@mollie/api-client": "^4.5.0",
|
|
144
144
|
"@node-rs/argon2": "^2.0.2",
|
|
145
145
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.328.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.328.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.328.1",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.328.1",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -36,8 +36,12 @@ import { createRendererFoundationFeature } from "../../renderer-foundation/featu
|
|
|
36
36
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
37
37
|
import { decryptStoredPii, hashPassword } from "../../shared";
|
|
38
38
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
39
|
-
import { createTenantFeature } from "../../tenant";
|
|
40
|
-
import {
|
|
39
|
+
import { createTenantFeature, TenantHandlers } from "../../tenant";
|
|
40
|
+
import {
|
|
41
|
+
INVITATION_STATUS,
|
|
42
|
+
tenantInvitationEntity,
|
|
43
|
+
tenantInvitationsTable,
|
|
44
|
+
} from "../../tenant/invitation-table";
|
|
41
45
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
42
46
|
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
43
47
|
import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
|
|
@@ -47,7 +51,7 @@ import {
|
|
|
47
51
|
tenantInvitationDeleteHook,
|
|
48
52
|
tenantInvitationExportHook,
|
|
49
53
|
} from "../../user-data-rights-defaults";
|
|
50
|
-
import { AuthHandlers } from "../constants";
|
|
54
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
51
55
|
import { createAuthEmailPasswordFeature } from "../feature";
|
|
52
56
|
import { seedUser } from "../seeding";
|
|
53
57
|
|
|
@@ -257,6 +261,33 @@ describe("auth flows with active KMS + blind index", () => {
|
|
|
257
261
|
expect(memberships).toHaveLength(2);
|
|
258
262
|
});
|
|
259
263
|
|
|
264
|
+
test("removed member cannot rejoin via an invitation issued before removal", async () => {
|
|
265
|
+
await seedTenantMembership(stack.db, {
|
|
266
|
+
userId: bobId,
|
|
267
|
+
tenantId: TENANT_A_ID,
|
|
268
|
+
roles: ["Editor"],
|
|
269
|
+
});
|
|
270
|
+
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
271
|
+
|
|
272
|
+
await stack.http.writeOk(
|
|
273
|
+
TenantHandlers.removeMember,
|
|
274
|
+
{ userId: bobId, tenantId: TENANT_A_ID },
|
|
275
|
+
{ id: "system-admin", tenantId: TENANT_A_ID, roles: ["SystemAdmin"] },
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const res = await stack.http.raw(
|
|
279
|
+
"POST",
|
|
280
|
+
"/api/auth/invite-accept",
|
|
281
|
+
{ token },
|
|
282
|
+
{ Authorization: `Bearer ${await stack.jwt.sign(bobSession())}` },
|
|
283
|
+
);
|
|
284
|
+
expect(res.status).toBe(422);
|
|
285
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
286
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
|
|
287
|
+
const [invitation] = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
|
|
288
|
+
expect(invitation?.["status"]).toBe(INVITATION_STATUS.cancelled);
|
|
289
|
+
});
|
|
290
|
+
|
|
260
291
|
test("Branch 3: invite-signup-complete creates the user from the DECRYPTED invitation email", async () => {
|
|
261
292
|
const token = await inviteEmail(CAROL_EMAIL, "Admin");
|
|
262
293
|
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
unsafePushTables,
|
|
30
30
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
31
31
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
32
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
32
33
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
33
34
|
import { createConfigFeature } from "../../config";
|
|
34
35
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -39,7 +40,7 @@ import { createRendererFoundationFeature } from "../../renderer-foundation/featu
|
|
|
39
40
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
40
41
|
import { hashPassword } from "../../shared";
|
|
41
42
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
42
|
-
import { createTenantFeature } from "../../tenant";
|
|
43
|
+
import { createTenantFeature, TenantErrors, TenantHandlers } from "../../tenant";
|
|
43
44
|
import {
|
|
44
45
|
INVITATION_STATUS,
|
|
45
46
|
tenantInvitationEntity,
|
|
@@ -216,6 +217,34 @@ async function authedRaw(
|
|
|
216
217
|
return stack.http.raw(method, path, body, { Authorization: `Bearer ${token}` });
|
|
217
218
|
}
|
|
218
219
|
|
|
220
|
+
async function membershipRowOf(userId: string, tenantId: TenantId) {
|
|
221
|
+
const [row] = await selectMany(stack.db, tenantMembershipsTable, { userId, tenantId });
|
|
222
|
+
if (!row) throw new Error(`no membership for ${userId} in ${tenantId}`);
|
|
223
|
+
return row;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function membershipRolesOf(userId: string, tenantId: TenantId): Promise<string[]> {
|
|
227
|
+
return [...parseRoles((await membershipRowOf(userId, tenantId))["roles"])].sort();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function membershipVersionOf(userId: string, tenantId: TenantId): Promise<unknown> {
|
|
231
|
+
return (await membershipRowOf(userId, tenantId))["version"];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function changeBobRolesInTenantA(roles: readonly string[]): Promise<void> {
|
|
235
|
+
await stack.http.writeOk(
|
|
236
|
+
TenantHandlers.updateMemberRoles,
|
|
237
|
+
{ userId: bobId, roles },
|
|
238
|
+
aliceSession(),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function expectInvitationSuperseded(res: Response): Promise<void> {
|
|
243
|
+
expect(res.status).toBe(409);
|
|
244
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
245
|
+
expect(body.error?.details?.reason).toBe(TenantErrors.invitationSuperseded);
|
|
246
|
+
}
|
|
247
|
+
|
|
219
248
|
async function inviteEmail(email: string, role: string): Promise<string> {
|
|
220
249
|
// invite-create geht via /api/write (Admin-Auth via JWT). Der Handler
|
|
221
250
|
// dispatcht die Invite-Mail via delivery; der Token erreicht den Invitee
|
|
@@ -327,8 +356,7 @@ describe("invite-accept (Branch 1: logged-in)", () => {
|
|
|
327
356
|
expect(body.error?.details?.reason).toBe(AuthErrors.inviteEmailMismatch);
|
|
328
357
|
});
|
|
329
358
|
|
|
330
|
-
test("Already-Member: Bob ist schon Member →
|
|
331
|
-
// Bob direkt zu Tenant-A hinzufügen
|
|
359
|
+
test("Already-Member: Bob ist schon Member → invited role is added, existing role kept, alreadyMember=true", async () => {
|
|
332
360
|
await seedTenantMembership(stack.db, {
|
|
333
361
|
userId: bobId,
|
|
334
362
|
tenantId: TENANT_A_ID,
|
|
@@ -344,6 +372,66 @@ describe("invite-accept (Branch 1: logged-in)", () => {
|
|
|
344
372
|
bobSession(),
|
|
345
373
|
)) as { alreadyMember: boolean };
|
|
346
374
|
expect(result.alreadyMember).toBe(true);
|
|
375
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["Admin", "User"]);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("bare member of the inviting tenant, logged in there, gets the invited role", async () => {
|
|
379
|
+
await seedTenantMembership(stack.db, { userId: bobId, tenantId: TENANT_A_ID, roles: [] });
|
|
380
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
381
|
+
|
|
382
|
+
const result = (await stack.http.writeOk(
|
|
383
|
+
AuthHandlers.inviteAccept,
|
|
384
|
+
{ token },
|
|
385
|
+
{ id: bobId, tenantId: TENANT_A_ID, roles: [] },
|
|
386
|
+
)) as { alreadyMember: boolean };
|
|
387
|
+
|
|
388
|
+
expect(result.alreadyMember).toBe(true);
|
|
389
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["Editor"]);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("member who already holds the invited role keeps an unchanged membership stream", async () => {
|
|
393
|
+
await seedTenantMembership(stack.db, {
|
|
394
|
+
userId: bobId,
|
|
395
|
+
tenantId: TENANT_A_ID,
|
|
396
|
+
roles: ["Admin"],
|
|
397
|
+
});
|
|
398
|
+
const versionBefore = await membershipVersionOf(bobId, TENANT_A_ID);
|
|
399
|
+
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
400
|
+
|
|
401
|
+
await stack.http.writeOk(AuthHandlers.inviteAccept, { token }, bobSession());
|
|
402
|
+
|
|
403
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["Admin"]);
|
|
404
|
+
expect(await membershipVersionOf(bobId, TENANT_A_ID)).toBe(versionBefore);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("roles changed after the invitation was issued → accept rejected, roles unchanged", async () => {
|
|
408
|
+
await seedTenantMembership(stack.db, {
|
|
409
|
+
userId: bobId,
|
|
410
|
+
tenantId: TENANT_A_ID,
|
|
411
|
+
roles: ["Editor"],
|
|
412
|
+
});
|
|
413
|
+
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
414
|
+
await changeBobRolesInTenantA(["User"]);
|
|
415
|
+
|
|
416
|
+
const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
|
|
417
|
+
|
|
418
|
+
await expectInvitationSuperseded(res);
|
|
419
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["User"]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("invitation re-issued after a role change is accepted again", async () => {
|
|
423
|
+
await seedTenantMembership(stack.db, {
|
|
424
|
+
userId: bobId,
|
|
425
|
+
tenantId: TENANT_A_ID,
|
|
426
|
+
roles: ["Editor"],
|
|
427
|
+
});
|
|
428
|
+
await inviteEmail(BOB_EMAIL, "Admin");
|
|
429
|
+
await changeBobRolesInTenantA(["User"]);
|
|
430
|
+
const reissuedToken = await inviteEmail(BOB_EMAIL, "Admin");
|
|
431
|
+
|
|
432
|
+
await stack.http.writeOk(AuthHandlers.inviteAccept, { token: reissuedToken }, bobSession());
|
|
433
|
+
|
|
434
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["Admin", "User"]);
|
|
347
435
|
});
|
|
348
436
|
});
|
|
349
437
|
|
|
@@ -380,6 +468,61 @@ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
|
|
|
380
468
|
expect(payload.locale).toBeUndefined();
|
|
381
469
|
});
|
|
382
470
|
|
|
471
|
+
test("bare member of the inviting tenant accepts with login → membership and session carry the invited role", async () => {
|
|
472
|
+
await seedTenantMembership(stack.db, { userId: bobId, tenantId: TENANT_A_ID, roles: [] });
|
|
473
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
474
|
+
|
|
475
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
476
|
+
token,
|
|
477
|
+
email: BOB_EMAIL,
|
|
478
|
+
password: BOB_PASSWORD,
|
|
479
|
+
});
|
|
480
|
+
expect(res.status).toBe(200);
|
|
481
|
+
const body = (await res.json()) as { user: { roles: string[] } };
|
|
482
|
+
|
|
483
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["Editor"]);
|
|
484
|
+
expect(body.user.roles).toContain("Editor");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("an existing Admin invited as Editor keeps Admin (accepting never demotes)", async () => {
|
|
488
|
+
await seedTenantMembership(stack.db, {
|
|
489
|
+
userId: bobId,
|
|
490
|
+
tenantId: TENANT_A_ID,
|
|
491
|
+
roles: ["Admin"],
|
|
492
|
+
});
|
|
493
|
+
const token = await inviteEmail(BOB_EMAIL, "Editor");
|
|
494
|
+
|
|
495
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
496
|
+
token,
|
|
497
|
+
email: BOB_EMAIL,
|
|
498
|
+
password: BOB_PASSWORD,
|
|
499
|
+
});
|
|
500
|
+
expect(res.status).toBe(200);
|
|
501
|
+
const body = (await res.json()) as { user: { roles: string[] } };
|
|
502
|
+
|
|
503
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["Admin", "Editor"]);
|
|
504
|
+
expect(body.user.roles).toEqual(expect.arrayContaining(["Admin", "Editor"]));
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("roles changed after the invitation was issued → accept with login rejected, roles unchanged", async () => {
|
|
508
|
+
await seedTenantMembership(stack.db, {
|
|
509
|
+
userId: bobId,
|
|
510
|
+
tenantId: TENANT_A_ID,
|
|
511
|
+
roles: ["Editor"],
|
|
512
|
+
});
|
|
513
|
+
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
514
|
+
await changeBobRolesInTenantA(["User"]);
|
|
515
|
+
|
|
516
|
+
const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
|
|
517
|
+
token,
|
|
518
|
+
email: BOB_EMAIL,
|
|
519
|
+
password: BOB_PASSWORD,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
await expectInvitationSuperseded(res);
|
|
523
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["User"]);
|
|
524
|
+
});
|
|
525
|
+
|
|
383
526
|
test("Bob accepts with a locale → JWT retains the locale", async () => {
|
|
384
527
|
await asRawClient(stack.db).unsafe(
|
|
385
528
|
`UPDATE "${userTable.tableName}" SET locale = $1 WHERE id = $2`,
|
|
@@ -690,20 +833,9 @@ describe("invite-accept defense-in-depth (assertAssignableMembershipRoles)", ()
|
|
|
690
833
|
});
|
|
691
834
|
|
|
692
835
|
describe("invite-accept-with-login/signup-complete defense-in-depth (637/1)", () => {
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
// — a forbidden invitation role is rejected outright, 403, before it ever
|
|
697
|
-
// reaches stripForbiddenMembershipRoles. Branch 2 (invite-accept-with-login)
|
|
698
|
-
// is different: it skips seedTenantMembership entirely when the user is
|
|
699
|
-
// ALREADY a member of the invited tenant (idempotent-accept path) — for
|
|
700
|
-
// that one case, stripForbiddenMembershipRoles at the session mint is the
|
|
701
|
-
// ONLY protection. Removing that call would silently mint a
|
|
702
|
-
// SystemAdmin-carrying session with no other test catching it.
|
|
703
|
-
test("invite-accept-with-login: already-member path strips a forbidden invitation role (seedTenantMembership's guard is skipped here)", async () => {
|
|
704
|
-
// Bob already has a (legitimate) membership in TENANT_A_ID — the
|
|
705
|
-
// handler's alreadyMember check short-circuits before seedTenantMembership,
|
|
706
|
-
// so assertAssignableMembershipRoles never runs for this accept.
|
|
836
|
+
// The already-member path writes the invitation role too, so it needs the
|
|
837
|
+
// create path's reserved-role guard, not just the session-mint strip.
|
|
838
|
+
test("invite-accept-with-login: already-member path rejects a forbidden invitation role and leaves the membership untouched", async () => {
|
|
707
839
|
await seedTenantMembership(stack.db, {
|
|
708
840
|
userId: bobId,
|
|
709
841
|
tenantId: TENANT_A_ID,
|
|
@@ -724,9 +856,8 @@ describe("invite-accept-with-login/signup-complete defense-in-depth (637/1)", ()
|
|
|
724
856
|
email: BOB_EMAIL,
|
|
725
857
|
password: BOB_PASSWORD,
|
|
726
858
|
});
|
|
727
|
-
expect(res.status).toBe(
|
|
728
|
-
|
|
729
|
-
expect(body.user.roles).toEqual([]);
|
|
859
|
+
expect(res.status).toBe(403);
|
|
860
|
+
expect(await membershipRolesOf(bobId, TENANT_A_ID)).toEqual(["User"]);
|
|
730
861
|
});
|
|
731
862
|
|
|
732
863
|
test("invite-signup-complete: a brand-new user always hits seedTenantMembership's guard — forbidden role rejected, not silently stripped", async () => {
|
|
@@ -764,6 +895,59 @@ describe("Single-Use-Burn (alle Branches)", () => {
|
|
|
764
895
|
});
|
|
765
896
|
});
|
|
766
897
|
|
|
898
|
+
describe("remove-member cancels the removed member's open invitations", () => {
|
|
899
|
+
async function removeBobFromTenantA(): Promise<void> {
|
|
900
|
+
await stack.http.writeOk(
|
|
901
|
+
TenantHandlers.removeMember,
|
|
902
|
+
{ userId: bobId, tenantId: TENANT_A_ID },
|
|
903
|
+
{ id: "system-admin", tenantId: TENANT_A_ID, roles: ["SystemAdmin"] },
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function invitationStatusOf(email: string): Promise<unknown> {
|
|
908
|
+
const [row] = await selectMany(stack.db, tenantInvitationsTable, {
|
|
909
|
+
email,
|
|
910
|
+
tenantId: TENANT_A_ID,
|
|
911
|
+
});
|
|
912
|
+
return row?.["status"];
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
test("removed member cannot rejoin via an invitation issued before removal", async () => {
|
|
916
|
+
await seedTenantMembership(stack.db, {
|
|
917
|
+
userId: bobId,
|
|
918
|
+
tenantId: TENANT_A_ID,
|
|
919
|
+
roles: ["Editor"],
|
|
920
|
+
});
|
|
921
|
+
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
922
|
+
|
|
923
|
+
await removeBobFromTenantA();
|
|
924
|
+
|
|
925
|
+
const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
|
|
926
|
+
expect(res.status).toBe(422);
|
|
927
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
928
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
|
|
929
|
+
expect(await invitationStatusOf(BOB_EMAIL)).toBe(INVITATION_STATUS.cancelled);
|
|
930
|
+
const memberships = await selectMany(stack.db, tenantMembershipsTable, {
|
|
931
|
+
userId: bobId,
|
|
932
|
+
tenantId: TENANT_A_ID,
|
|
933
|
+
});
|
|
934
|
+
expect(memberships).toHaveLength(0);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
test("invitations to other emails stay pending", async () => {
|
|
938
|
+
await seedTenantMembership(stack.db, {
|
|
939
|
+
userId: bobId,
|
|
940
|
+
tenantId: TENANT_A_ID,
|
|
941
|
+
roles: ["Editor"],
|
|
942
|
+
});
|
|
943
|
+
await inviteEmail(CAROL_EMAIL, "Editor");
|
|
944
|
+
|
|
945
|
+
await removeBobFromTenantA();
|
|
946
|
+
|
|
947
|
+
expect(await invitationStatusOf(CAROL_EMAIL)).toBe(INVITATION_STATUS.pending);
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
|
|
767
951
|
describe("cancel-invitation", () => {
|
|
768
952
|
test("Admin cancellt → status=cancelled + token weg, accept wird invalid", async () => {
|
|
769
953
|
const token = await inviteEmail(BOB_EMAIL, "Admin");
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.328.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Accepting an invitation grants the invited role to existing members of the tenant",
|
|
6
|
+
"detail": "`invite-accept` and `invite-accept-with-login` used to skip the membership write when the user already belonged to the invited tenant, so a member without roles who accepted an invitation kept no roles and saw no accessible screen. The invited role is now added to the existing membership through the event-store executor. Existing roles are kept, because accepting an invitation never demotes. `invite-accept-with-login` builds the session from the resulting membership roles and rejects a reserved invitation role on this path with 403, the same as the other invite branches. An invitation issued before a later change to the membership is rejected with 409 and `TenantErrors.invitationSuperseded`; re-sending the invitation issues it anew. Removing a member cancels their open invitations in that tenant."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.328.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type TenantId,
|
|
32
32
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
33
33
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
34
|
+
import type { Temporal } from "temporal-polyfill";
|
|
34
35
|
import * as z from "zod";
|
|
35
36
|
import { decryptStoredPii, sessionLocaleField, sessionTimezoneField } from "../../shared";
|
|
36
37
|
// kumiko-lint-ignore cross-feature-import invite-flow
|
|
@@ -39,13 +40,8 @@ import {
|
|
|
39
40
|
tenantInvitationEntity,
|
|
40
41
|
tenantInvitationsTable,
|
|
41
42
|
} from "../../tenant/invitation-table";
|
|
42
|
-
// kumiko-lint-ignore cross-feature-import
|
|
43
|
-
import {
|
|
44
|
-
findForbiddenMembershipRole,
|
|
45
|
-
reservedMembershipRoleError,
|
|
46
|
-
} from "../../tenant/membership-roles";
|
|
47
|
-
// kumiko-lint-ignore cross-feature-import membership-seed-helper for a privileged cross-tenant add
|
|
48
|
-
import { seedTenantMembership } from "../../tenant/seeding";
|
|
43
|
+
// kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add
|
|
44
|
+
import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership";
|
|
49
45
|
// kumiko-lint-ignore cross-feature-import login-style password-check
|
|
50
46
|
import { userTable } from "../../user/schema/user";
|
|
51
47
|
import {
|
|
@@ -147,6 +143,8 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
147
143
|
readonly email: string;
|
|
148
144
|
readonly role: string;
|
|
149
145
|
readonly version: number;
|
|
146
|
+
readonly insertedAt: Temporal.Instant;
|
|
147
|
+
readonly modifiedAt: Temporal.Instant | null;
|
|
150
148
|
};
|
|
151
149
|
type UserAuthRow = {
|
|
152
150
|
readonly id: string;
|
|
@@ -215,29 +213,17 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
215
213
|
|
|
216
214
|
const userId = userRow.id;
|
|
217
215
|
|
|
218
|
-
// Already-member check (idempotent)
|
|
219
|
-
const memberships = (await ctx.queryAs(
|
|
220
|
-
createSystemUser(invitationTenantId),
|
|
221
|
-
"tenant:query:memberships",
|
|
222
|
-
{ userId },
|
|
223
|
-
)) as Array<{ tenantId: string }>; // @cast-boundary db-row
|
|
224
|
-
const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
|
|
225
|
-
|
|
226
216
|
const dbConn = ctx.db.unsafeRaw(
|
|
227
217
|
"adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant",
|
|
228
218
|
);
|
|
229
219
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
tenantId: invitationTenantId,
|
|
238
|
-
roles: [invitationRole],
|
|
239
|
-
});
|
|
240
|
-
}
|
|
220
|
+
const grant = await grantInvitedMembershipRole(dbConn, {
|
|
221
|
+
userId,
|
|
222
|
+
tenantId: invitationTenantId,
|
|
223
|
+
role: invitationRole,
|
|
224
|
+
invitationIssuedAt: invitationIssuedAt(invitation),
|
|
225
|
+
});
|
|
226
|
+
if (!grant.isSuccess) return grant;
|
|
241
227
|
|
|
242
228
|
// Invitation → accepted: TenantDb for the invitation's tenant.
|
|
243
229
|
const invitationTdb = createTenantDb(dbConn, invitationTenantId, "system");
|
|
@@ -255,8 +241,8 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
255
241
|
await deleteInviteToken(ctx.redis, { invitationId, token: event.payload.token });
|
|
256
242
|
|
|
257
243
|
// buildSessionRoles calls stripForbiddenMembershipRoles internally —
|
|
258
|
-
// a reserved role on
|
|
259
|
-
const mergedRoles = buildSessionRoles([],
|
|
244
|
+
// a reserved role on an existing membership must never reach the session.
|
|
245
|
+
const mergedRoles = buildSessionRoles([], grant.data.roles);
|
|
260
246
|
|
|
261
247
|
// MFA gate runs after membership is granted (mirrors login.write.ts's
|
|
262
248
|
// own order: membership resolution before MFA) — a challenge halts
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// 2. Burn (single-use)
|
|
7
7
|
// 3. Invitation-Row aus DB
|
|
8
8
|
// 4. Email-Match: invitation.email === user.email (sonst inviteEmailMismatch)
|
|
9
|
-
// 5.
|
|
10
|
-
//
|
|
9
|
+
// 5+6. grantInvitedMembershipRole: create the membership, or add the
|
|
10
|
+
// invited role to an existing one (add-only)
|
|
11
11
|
// 7. Invitation-Row → status=accepted
|
|
12
12
|
// 8. Redis-Keys löschen (Burn-Key bleibt für Replay-Schutz)
|
|
13
13
|
//
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type TenantId,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
25
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
26
|
+
import type { Temporal } from "temporal-polyfill";
|
|
26
27
|
import * as z from "zod";
|
|
27
28
|
import { decryptStoredPii } from "../../shared";
|
|
28
29
|
// kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
|
|
@@ -31,15 +32,8 @@ import {
|
|
|
31
32
|
tenantInvitationEntity,
|
|
32
33
|
tenantInvitationsTable,
|
|
33
34
|
} from "../../tenant/invitation-table";
|
|
34
|
-
// kumiko-lint-ignore cross-feature-import
|
|
35
|
-
import {
|
|
36
|
-
findForbiddenMembershipRole,
|
|
37
|
-
reservedMembershipRoleError,
|
|
38
|
-
} from "../../tenant/membership-roles";
|
|
39
|
-
// kumiko-lint-ignore cross-feature-import direkter membership-Lookup (ungefiltert, s. alreadyMember-Kommentar)
|
|
40
|
-
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
41
|
-
// kumiko-lint-ignore cross-feature-import membership-seed-helper für privilegierten cross-tenant-add (analog provisionSignupAccount)
|
|
42
|
-
import { seedTenantMembership } from "../../tenant/seeding";
|
|
35
|
+
// kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add (like provisionSignupAccount)
|
|
36
|
+
import { grantInvitedMembershipRole, invitationIssuedAt } from "../../tenant/invited-membership";
|
|
43
37
|
// kumiko-lint-ignore cross-feature-import auth handler reads user-row für email-match
|
|
44
38
|
import { userTable } from "../../user/schema/user";
|
|
45
39
|
import { invalidInviteToken, inviteEmailMismatch } from "../errors";
|
|
@@ -113,6 +107,8 @@ export function createInviteAcceptHandler() {
|
|
|
113
107
|
readonly email: string;
|
|
114
108
|
readonly role: string;
|
|
115
109
|
readonly version: number;
|
|
110
|
+
readonly insertedAt: Temporal.Instant;
|
|
111
|
+
readonly modifiedAt: Temporal.Instant | null;
|
|
116
112
|
};
|
|
117
113
|
type UserEmailRow = { readonly email: string };
|
|
118
114
|
|
|
@@ -148,37 +144,17 @@ export function createInviteAcceptHandler() {
|
|
|
148
144
|
return inviteEmailMismatch();
|
|
149
145
|
}
|
|
150
146
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// Idempotenz: schon Member → no-op + 200 mit alreadyMember=true.
|
|
156
|
-
const invitationTenantRunner = ctx.db.unsafeRaw(ADD_MEMBERSHIP_INVITATION_TENANT_REASON);
|
|
157
|
-
const membershipRow = await fetchOne(invitationTenantRunner, tenantMembershipsTable, {
|
|
147
|
+
// Executor instead of dispatcher.writeAs(addMember): addMember only
|
|
148
|
+
// accepts SystemAdmin, createSystemUser carries "system".
|
|
149
|
+
const dbConn = ctx.db.unsafeRaw(ADD_MEMBERSHIP_INVITATION_TENANT_REASON);
|
|
150
|
+
const grant = await grantInvitedMembershipRole(dbConn, {
|
|
158
151
|
userId: event.user.id,
|
|
159
152
|
tenantId: invitationTenantId,
|
|
153
|
+
role: invitationRole,
|
|
154
|
+
invitationIssuedAt: invitationIssuedAt(invitation),
|
|
160
155
|
});
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const dbConn = invitationTenantRunner;
|
|
164
|
-
|
|
165
|
-
if (!alreadyMember) {
|
|
166
|
-
// Membership-Add via seedTenantMembership-helper (event-store-
|
|
167
|
-
// executor pattern, gleich wie provisionSignupAccount). Nicht
|
|
168
|
-
// dispatcher.writeAs(addMember) weil addMember-Handler nur
|
|
169
|
-
// ["SystemAdmin"]-Role akzeptiert; createSystemUser produziert
|
|
170
|
-
// "system"-Role die NICHT matcht. Direkt-via-Executor bypassed
|
|
171
|
-
// den Access-Check für privilegierte Cross-Tenant-Operationen.
|
|
172
|
-
const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
|
|
173
|
-
if (forbiddenInviteRole !== undefined) {
|
|
174
|
-
return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
|
|
175
|
-
}
|
|
176
|
-
await seedTenantMembership(dbConn, {
|
|
177
|
-
userId: event.user.id,
|
|
178
|
-
tenantId: invitationTenantId,
|
|
179
|
-
roles: [invitationRole],
|
|
180
|
-
});
|
|
181
|
-
}
|
|
156
|
+
if (!grant.isSuccess) return grant;
|
|
157
|
+
const { alreadyMember } = grant.data;
|
|
182
158
|
|
|
183
159
|
// Invitation-Status → accepted via event-store-executor.
|
|
184
160
|
// Tenant-scoping: ctx.db ist auf event.user.tenantId gescopt
|
package/src/tenant/constants.ts
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createEventStoreExecutor,
|
|
4
|
+
type DbRow,
|
|
5
|
+
type TenantDb,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
3
7
|
import {
|
|
4
8
|
createSystemUser,
|
|
5
9
|
defineWriteHandler,
|
|
10
|
+
type SessionUser,
|
|
6
11
|
withResponseData,
|
|
7
12
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
InternalError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
type WriteFailure,
|
|
17
|
+
writeFailure,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
9
19
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
20
|
+
import type { Redis } from "ioredis";
|
|
10
21
|
import * as z from "zod";
|
|
22
|
+
// kumiko-lint-ignore cross-feature-import cancel needs invite-token-store for Redis cleanup
|
|
23
|
+
import { invalidateExistingInviteToken } from "../../auth-email-password/invite-token-store";
|
|
24
|
+
import { decryptStoredPii } from "../../shared";
|
|
25
|
+
import { userTable } from "../../user";
|
|
26
|
+
import {
|
|
27
|
+
INVITATION_STATUS,
|
|
28
|
+
tenantInvitationEntity,
|
|
29
|
+
tenantInvitationsTable,
|
|
30
|
+
} from "../invitation-table";
|
|
11
31
|
import { assertNotLastTenantAdmin } from "../last-tenant-admin";
|
|
12
32
|
import { tenantMembershipEntity, tenantMembershipsTable } from "../membership-table";
|
|
13
33
|
|
|
@@ -15,6 +35,48 @@ const executor = createEventStoreExecutor(tenantMembershipsTable, tenantMembersh
|
|
|
15
35
|
entityName: "tenant-membership",
|
|
16
36
|
});
|
|
17
37
|
|
|
38
|
+
const invitationExecutor = createEventStoreExecutor(
|
|
39
|
+
tenantInvitationsTable,
|
|
40
|
+
tenantInvitationEntity,
|
|
41
|
+
{ entityName: "tenant-invitation" },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
type PendingInvitationCancel = {
|
|
45
|
+
readonly userId: string;
|
|
46
|
+
readonly tenantId: string;
|
|
47
|
+
readonly actor: SessionUser;
|
|
48
|
+
readonly redis: Redis | undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
async function cancelPendingInvitationsOfUser(
|
|
52
|
+
db: TenantDb,
|
|
53
|
+
options: PendingInvitationCancel,
|
|
54
|
+
): Promise<WriteFailure | undefined> {
|
|
55
|
+
const user = await fetchOne<{ email: string | null }>(db, userTable, { id: options.userId });
|
|
56
|
+
if (!user?.email) return undefined;
|
|
57
|
+
const email = (await decryptStoredPii(user.email, "email", "tenant:remove-member")).toLowerCase();
|
|
58
|
+
|
|
59
|
+
const pendingInvitations = await selectMany<{ id: string; version: number }>(
|
|
60
|
+
db,
|
|
61
|
+
tenantInvitationsTable,
|
|
62
|
+
{ tenantId: options.tenantId, email, status: INVITATION_STATUS.pending },
|
|
63
|
+
);
|
|
64
|
+
for (const invitation of pendingInvitations) {
|
|
65
|
+
const updateResult = await invitationExecutor.update(
|
|
66
|
+
{
|
|
67
|
+
id: invitation.id,
|
|
68
|
+
version: invitation.version,
|
|
69
|
+
changes: { status: INVITATION_STATUS.cancelled },
|
|
70
|
+
},
|
|
71
|
+
options.actor,
|
|
72
|
+
db,
|
|
73
|
+
);
|
|
74
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
75
|
+
if (options.redis) await invalidateExistingInviteToken(options.redis, invitation.id);
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
18
80
|
// Literal QN, not an import off the sessions feature — tenant is
|
|
19
81
|
// foundational and must boot without sessions mounted (no r.requires/
|
|
20
82
|
// r.usesApi here, unlike user-data-rights:restrict-account's hard dep).
|
|
@@ -78,6 +140,16 @@ export const removeMemberWrite = defineWriteHandler({
|
|
|
78
140
|
event.user,
|
|
79
141
|
db,
|
|
80
142
|
);
|
|
143
|
+
if (!result.isSuccess) return result;
|
|
144
|
+
|
|
145
|
+
// Actor tenant = invitation tenant so the update hits the invitation's stream.
|
|
146
|
+
const cancelFailure = await cancelPendingInvitationsOfUser(db, {
|
|
147
|
+
userId: event.payload.userId,
|
|
148
|
+
tenantId: event.payload.tenantId,
|
|
149
|
+
actor: { ...event.user, tenantId: event.payload.tenantId },
|
|
150
|
+
redis: ctx.redis,
|
|
151
|
+
});
|
|
152
|
+
if (cancelFailure !== undefined) return cancelFailure;
|
|
81
153
|
|
|
82
154
|
return withResponseData(result, event.payload);
|
|
83
155
|
},
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
createEventStoreExecutor,
|
|
4
|
+
createTenantDb,
|
|
5
|
+
type DbRunner,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
|
+
import { Temporal } from "temporal-polyfill";
|
|
11
|
+
import { TenantErrors } from "./constants";
|
|
12
|
+
import { findForbiddenMembershipRole, reservedMembershipRoleError } from "./membership-roles";
|
|
13
|
+
import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
|
|
14
|
+
import { seedTenantMembership } from "./seeding";
|
|
15
|
+
|
|
16
|
+
const membershipExecutor = createEventStoreExecutor(
|
|
17
|
+
tenantMembershipsTable,
|
|
18
|
+
tenantMembershipEntity,
|
|
19
|
+
{ entityName: "tenant-membership" },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export type InvitedMembershipOptions = {
|
|
23
|
+
readonly userId: string;
|
|
24
|
+
readonly tenantId: TenantId;
|
|
25
|
+
readonly role: string;
|
|
26
|
+
readonly invitationIssuedAt: Temporal.Instant;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type MembershipRow = {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly version: number;
|
|
32
|
+
readonly roles: string;
|
|
33
|
+
readonly insertedAt: Temporal.Instant;
|
|
34
|
+
readonly modifiedAt: Temporal.Instant | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Add-only, so unlike updateMemberRoles no session revoke or last-TenantAdmin check.
|
|
38
|
+
// Read unfiltered: tenant:query:memberships hides disabled tenants.
|
|
39
|
+
export async function grantInvitedMembershipRole(db: DbRunner, options: InvitedMembershipOptions) {
|
|
40
|
+
const forbiddenRole = findForbiddenMembershipRole([options.role]);
|
|
41
|
+
if (forbiddenRole !== undefined) return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
42
|
+
|
|
43
|
+
const existing = await fetchOne<MembershipRow>(db, tenantMembershipsTable, {
|
|
44
|
+
userId: options.userId,
|
|
45
|
+
tenantId: options.tenantId,
|
|
46
|
+
});
|
|
47
|
+
if (!existing) {
|
|
48
|
+
await seedTenantMembership(db, {
|
|
49
|
+
userId: options.userId,
|
|
50
|
+
tenantId: options.tenantId,
|
|
51
|
+
roles: [options.role],
|
|
52
|
+
});
|
|
53
|
+
return grantedMembership(false, [options.role]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A membership decision made after the invitation was issued wins over it.
|
|
57
|
+
if (isMembershipChangedAfter(existing, options.invitationIssuedAt)) {
|
|
58
|
+
return writeFailure(invitationSupersededError());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const currentRoles = parseRoles(existing.roles);
|
|
62
|
+
if (currentRoles.includes(options.role)) return grantedMembership(true, currentRoles);
|
|
63
|
+
|
|
64
|
+
const roles = [...currentRoles, options.role];
|
|
65
|
+
const updateResult = await membershipExecutor.update(
|
|
66
|
+
{ id: existing.id, version: existing.version, changes: { roles: JSON.stringify(roles) } },
|
|
67
|
+
createSystemUser(options.tenantId),
|
|
68
|
+
createTenantDb(db, options.tenantId, "system"),
|
|
69
|
+
);
|
|
70
|
+
if (!updateResult.isSuccess) return updateResult;
|
|
71
|
+
return grantedMembership(true, roles);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Resend reuses the invitation row, so its last modification is the latest issuance.
|
|
75
|
+
export function invitationIssuedAt(invitation: {
|
|
76
|
+
readonly insertedAt: Temporal.Instant;
|
|
77
|
+
readonly modifiedAt: Temporal.Instant | null;
|
|
78
|
+
}): Temporal.Instant {
|
|
79
|
+
return invitation.modifiedAt ?? invitation.insertedAt;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isMembershipChangedAfter(membership: MembershipRow, instant: Temporal.Instant): boolean {
|
|
83
|
+
const lastChangedAt = membership.modifiedAt ?? membership.insertedAt;
|
|
84
|
+
return Temporal.Instant.compare(lastChangedAt, instant) > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function invitationSupersededError(): ConflictError {
|
|
88
|
+
return new ConflictError({
|
|
89
|
+
message: "the membership changed after this invitation was issued",
|
|
90
|
+
i18nKey: "tenant.errors.invitationSuperseded",
|
|
91
|
+
details: { reason: TenantErrors.invitationSuperseded },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function grantedMembership(alreadyMember: boolean, roles: readonly string[]) {
|
|
96
|
+
return { isSuccess: true, data: { alreadyMember, roles } } as const;
|
|
97
|
+
}
|