@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.328.0",
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.0",
138
- "@cosmicdrift/kumiko-framework": "0.328.0",
139
- "@cosmicdrift/kumiko-headless": "0.328.0",
140
- "@cosmicdrift/kumiko-renderer": "0.328.0",
141
- "@cosmicdrift/kumiko-renderer-web": "0.328.0",
142
- "@cosmicdrift/kumiko-types": "0.328.0",
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.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.328.0",
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 { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
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 → idempotent no-op + alreadyMember=true", async () => {
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
- // Branch 1 (invite-accept, logged-in) and Branch 3 (invite-signup-complete)
694
- // both route every new membership through seedTenantMembership, which
695
- // calls assertAssignableMembershipRoles unconditionally (tenant/seeding.ts)
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(200);
728
- const body = (await res.json()) as { user: { roles: string[] } };
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 reserved-role check owned by tenant-feature
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
- if (!alreadyMember) {
231
- const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
232
- if (forbiddenInviteRole !== undefined) {
233
- return writeFailure(reservedMembershipRoleError(forbiddenInviteRole));
234
- }
235
- await seedTenantMembership(dbConn, {
236
- userId,
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 the invitation itself must never reach the session.
259
- const mergedRoles = buildSessionRoles([], [invitationRole]);
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. Already-Member-Check: User schon Member im invited Tenant → no-op success
10
- // 6. Membership-Add via system-dispatcher (TenantHandlers.addMember)
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 reserved-role check owned by tenant-feature
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
- // Already-Member-Check direkt gegen die memberships-Projektion —
152
- // NICHT via tenant:query:memberships, die disabled Tenants filtert:
153
- // ein Re-Invite in einen (vorübergehend) disabled Tenant würde dort
154
- // alreadyMember=false sehen und am Unique-Constraint scheitern.
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
- const alreadyMember = membershipRow !== undefined;
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
@@ -68,4 +68,5 @@ export const TenantErrors = {
68
68
  membershipNotFound: "membership_not_found",
69
69
  membershipAlreadyExists: "membership_already_exists",
70
70
  lastTenantAdmin: "last_tenant_admin",
71
+ invitationSuperseded: "invitation_superseded",
71
72
  } as const;
@@ -1,13 +1,33 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { createEventStoreExecutor, type DbRow } from "@cosmicdrift/kumiko-framework/db";
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 { InternalError, NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
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
+ }