@cosmicdrift/kumiko-bundled-features 0.215.7 → 0.216.0

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.215.7",
3
+ "version": "0.216.0",
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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.215.7",
130
- "@cosmicdrift/kumiko-framework": "0.215.7",
131
- "@cosmicdrift/kumiko-headless": "0.215.7",
132
- "@cosmicdrift/kumiko-renderer": "0.215.7",
133
- "@cosmicdrift/kumiko-renderer-web": "0.215.7",
134
- "@cosmicdrift/kumiko-types": "0.215.7",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.216.0",
130
+ "@cosmicdrift/kumiko-framework": "0.216.0",
131
+ "@cosmicdrift/kumiko-headless": "0.216.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.216.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.216.0",
134
+ "@cosmicdrift/kumiko-types": "0.216.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,7 +160,7 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.215.7",
164
- "@cosmicdrift/kumiko-locale-es": "0.215.7"
163
+ "@cosmicdrift/kumiko-locale-de": "0.216.0",
164
+ "@cosmicdrift/kumiko-locale-es": "0.216.0"
165
165
  }
166
166
  }
@@ -24,7 +24,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
24
24
  import { randomBytes } from "node:crypto";
25
25
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
26
26
  import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
27
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
27
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
28
28
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
29
29
  import {
30
30
  setupTestStack,
@@ -54,7 +54,7 @@ import { createTenantFeature } from "../tenant";
54
54
  import { TenantHandlers } from "../tenant/constants";
55
55
  import { tenantMembershipsTable } from "../tenant/membership-table";
56
56
  import { tenantEntity } from "../tenant/schema/tenant";
57
- import { UserHandlers } from "../user";
57
+ import { UserHandlers, UserQueries } from "../user";
58
58
  import { createUserFeature } from "../user/feature";
59
59
  import { userEntity, userTable } from "../user/schema/user";
60
60
 
@@ -276,4 +276,37 @@ describe("access-invalidation event consumer", () => {
276
276
 
277
277
  expect(invalidated).toEqual([userId]);
278
278
  });
279
+
280
+ test("user:update (global role change) revokes sessions and pushes an invalidation", async () => {
281
+ const { userId } = await h.seedUser("globalrolechange@example.com", "pw-long-enough");
282
+ const { sid } = await h.login("globalrolechange@example.com", "pw-long-enough");
283
+ trackInvalidation(userId);
284
+
285
+ const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
286
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
287
+ UserQueries.detail,
288
+ { id: userId },
289
+ admin,
290
+ );
291
+
292
+ const result = await stack.http.writeOk(
293
+ UserHandlers.update,
294
+ {
295
+ id: userId,
296
+ version: detail["version"],
297
+ changes: { roles: JSON.stringify(["SystemAdmin"]) },
298
+ },
299
+ admin,
300
+ );
301
+ expect(result).toBeTruthy();
302
+
303
+ await stack.eventDispatcher?.runOnce();
304
+
305
+ expect(invalidated).toEqual([userId]);
306
+
307
+ const sessionRow = await fetchOne<{ revokedAt: unknown }>(stack.db, userSessionTable, {
308
+ id: sid,
309
+ });
310
+ expect(sessionRow?.revokedAt).not.toBeNull();
311
+ });
279
312
  });
@@ -660,10 +660,9 @@ describe("invitations-query (pending list)", () => {
660
660
  // merges flat into the session and unlocks the SystemAdmin-gated cross-tenant
661
661
  // handler surface (hasAccess can't tell membership roles from global ones).
662
662
  describe("privilege escalation via invite role", () => {
663
- // Each forbidden value is a platform-global/reserved role that must never
664
- // reach a tenant membership. Proven exploitable before the fix: inviting
665
- // "SystemAdmin" gave the invitee a JWT carrying SystemAdmin flat, which
666
- // passed every SystemAdmin gate cross-tenant.
663
+ // Reserved / global roles: must NEVER reach a tenant invitation. hasAccess
664
+ // is flat, so a tenant-member invite for SystemAdmin would escalate on
665
+ // login/switch without any cross-tenant check.
667
666
  const FORBIDDEN_ROLES = ["SystemAdmin", "system", "all", "anonymous"];
668
667
 
669
668
  test("invite-create rejects reserved/global roles — no invitation persisted, no mail", async () => {
@@ -673,49 +672,100 @@ describe("privilege escalation via invite role", () => {
673
672
  { email: CAROL_EMAIL, role },
674
673
  aliceSession(),
675
674
  );
676
- expect(err.code).toBe("access_denied");
675
+ expect(err.details).toMatchObject({ reason: "reserved_membership_role", role });
677
676
  const rows = await selectMany(stack.db, tenantInvitationsTable, { email: CAROL_EMAIL });
678
677
  expect(rows).toHaveLength(0);
678
+ expect(emailTransport.sent).toHaveLength(0);
679
679
  }
680
- // The forbidden-role check fires before the mail dispatch.
681
- expect(emailTransport.sent).toHaveLength(0);
682
680
  });
683
681
 
684
682
  test("legitimate tenant role still issues an invitation", async () => {
685
683
  const result = (await stack.http.writeOk(
686
684
  AuthHandlers.inviteCreate,
687
- { email: CAROL_EMAIL, role: "Editor" },
685
+ { email: CAROL_EMAIL, role: "Admin" },
688
686
  aliceSession(),
689
687
  )) as { role: string };
690
- expect(result.role).toBe("Editor");
688
+ expect(result.role).toBe("Admin");
691
689
  });
692
690
 
693
- // Regression guard for the opt-in canAssignRole hook (#security): this
694
- // stack's invite feature was mounted WITHOUT canAssignRole, so any
695
- // non-reserved app-defined role must still go through unchanged — the
696
- // hierarchy gate below is a separate, opt-in stack, not a default.
697
- test("without canAssignRole configured, any non-reserved app-defined role is still assignable", async () => {
698
- const result = (await stack.http.writeOk(
691
+ test("TenantAdmin can invite TenantAdmin, Admin, Editor, and User", async () => {
692
+ const tenantAdminSession: SessionUser = {
693
+ id: aliceId,
694
+ tenantId: TENANT_A_ID,
695
+ roles: ["TenantAdmin"],
696
+ };
697
+ for (const role of ["TenantAdmin", "Admin", "Editor", "User"]) {
698
+ const email = `target-${role.toLowerCase()}@example.com`;
699
+ const result = (await stack.http.writeOk(
700
+ AuthHandlers.inviteCreate,
701
+ { email, role },
702
+ tenantAdminSession,
703
+ )) as { role: string };
704
+ expect(result.role).toBe(role);
705
+ }
706
+ });
707
+
708
+ test("Admin cannot invite TenantAdmin (elevation guard default)", async () => {
709
+ const err = await stack.http.writeErr(
699
710
  AuthHandlers.inviteCreate,
700
- { email: "dpo-candidate@example.com", role: "DPO" },
711
+ { email: "elevate-target@example.com", role: "TenantAdmin" },
712
+ aliceSession(), // roles: ["Admin"]
713
+ );
714
+ expect(err.details).toMatchObject({
715
+ reason: "unassignable_membership_role",
716
+ role: "TenantAdmin",
717
+ });
718
+ const rows = await selectMany(stack.db, tenantInvitationsTable, {
719
+ email: "elevate-target@example.com",
720
+ });
721
+ expect(rows).toHaveLength(0);
722
+ expect(emailTransport.sent).toHaveLength(0);
723
+ });
724
+
725
+ test("TenantAdmin cannot invite SystemAdmin (rejected by reserved role guard)", async () => {
726
+ const tenantAdminSession: SessionUser = {
727
+ id: aliceId,
728
+ tenantId: TENANT_A_ID,
729
+ roles: ["TenantAdmin"],
730
+ };
731
+ const err = await stack.http.writeErr(
732
+ AuthHandlers.inviteCreate,
733
+ { email: "sysadmin-target@example.com", role: "SystemAdmin" },
734
+ tenantAdminSession,
735
+ );
736
+ expect(err.details).toMatchObject({
737
+ reason: "reserved_membership_role",
738
+ role: "SystemAdmin",
739
+ });
740
+ const rows = await selectMany(stack.db, tenantInvitationsTable, {
741
+ email: "sysadmin-target@example.com",
742
+ });
743
+ expect(rows).toHaveLength(0);
744
+ });
745
+
746
+ test("rejects unknown/unranked roles fail-closed", async () => {
747
+ const err = await stack.http.writeErr(
748
+ AuthHandlers.inviteCreate,
749
+ { email: "unknown-role@example.com", role: "CustomRole" },
701
750
  aliceSession(),
702
- )) as { role: string };
703
- expect(result.role).toBe("DPO");
751
+ );
752
+ expect(err.details).toMatchObject({
753
+ reason: "unassignable_membership_role",
754
+ role: "CustomRole",
755
+ });
704
756
  });
705
757
  });
706
758
 
707
- // canAssignRole is a role-hierarchy gate that must live in the app (roles are
708
- // app-defined strings, not a framework concept) — a separate stack mounts the
709
- // invite feature WITH the hook to prove it's actually enforced, distinct from
710
- // the framework-reserved-role check covered above.
759
+ // canAssignRole further restricts ranked roles after the default elevation
760
+ // guard — a separate stack mounts the invite feature WITH the hook.
711
761
  describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
712
762
  let gateStack: TestStack;
713
763
  let gateAliceId: string;
714
764
  let gateTenantId: TenantId;
715
765
  const gateTransport = createInMemoryTransport();
716
766
 
717
- function gateAliceSession(): SessionUser {
718
- return { id: gateAliceId, tenantId: gateTenantId, roles: ["Admin"] };
767
+ function gateAliceSession(roles: readonly string[] = ["TenantAdmin"]): SessionUser {
768
+ return { id: gateAliceId, tenantId: gateTenantId, roles: [...roles] };
719
769
  }
720
770
 
721
771
  beforeAll(async () => {
@@ -737,11 +787,9 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
737
787
  invite: {
738
788
  tokenTtlMinutes: 60,
739
789
  appUrl: APP_ACCEPT_URL,
740
- // Only "Admin" may grant "DPO" everything else may only grant
741
- // non-DPO roles. A minimal, deliberately app-shaped hierarchy;
742
- // the framework itself knows nothing about "DPO".
790
+ // Custom app policy: only TenantAdmin with special flag may grant "Admin"
743
791
  canAssignRole: (inviterRoles, targetRole) =>
744
- targetRole !== "DPO" || inviterRoles.includes("Admin"),
792
+ targetRole !== "Admin" || inviterRoles.includes("SpecialAdmin"),
745
793
  },
746
794
  }),
747
795
  ],
@@ -784,7 +832,7 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
784
832
  await seedTenantMembership(gateStack.db, {
785
833
  userId: gateAliceId,
786
834
  tenantId: gateTenantId,
787
- roles: ["Admin"],
835
+ roles: ["TenantAdmin"],
788
836
  });
789
837
  });
790
838
 
@@ -793,14 +841,14 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
793
841
  });
794
842
 
795
843
  test("canAssignRole → false blocks invite-create with a role-hierarchy error, no invitation persisted", async () => {
796
- // TenantAdmin clears the handler's own access.admin gate but canAssignRole
797
- // above only lets literal "Admin" grant "DPO" the case this hook exists for.
844
+ // TenantAdmin clears the handler's own access.admin gate and default elevation
845
+ // guard for "Admin", but canAssignRole blocks "Admin" because Alice lacks "SpecialAdmin".
798
846
  const err = await gateStack.http.writeErr(
799
847
  AuthHandlers.inviteCreate,
800
- { email: "blocked-target@example.com", role: "DPO" },
801
- { id: gateAliceId, tenantId: gateTenantId, roles: ["TenantAdmin"] },
848
+ { email: "blocked-target@example.com", role: "Admin" },
849
+ gateAliceSession(),
802
850
  );
803
- expect(err.details).toMatchObject({ reason: "unassignable_membership_role", role: "DPO" });
851
+ expect(err.details).toMatchObject({ reason: "unassignable_membership_role", role: "Admin" });
804
852
  const rows = await selectMany(gateStack.db, tenantInvitationsTable, {
805
853
  email: "blocked-target@example.com",
806
854
  });
@@ -809,11 +857,12 @@ describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
809
857
  });
810
858
 
811
859
  test("canAssignRole → true allows invite-create through", async () => {
860
+ // Elevation alone would allow TenantAdmin → Admin; hook requires SpecialAdmin.
812
861
  const result = (await gateStack.http.writeOk(
813
862
  AuthHandlers.inviteCreate,
814
- { email: "allowed-target@example.com", role: "DPO" },
815
- gateAliceSession(),
863
+ { email: "allowed-target@example.com", role: "Admin" },
864
+ gateAliceSession(["TenantAdmin", "SpecialAdmin"]),
816
865
  )) as { role: string };
817
- expect(result.role).toBe("DPO");
866
+ expect(result.role).toBe("Admin");
818
867
  });
819
868
  });
@@ -33,6 +33,7 @@ import {
33
33
  // kumiko-lint-ignore cross-feature-import membership-role validation owned by tenant-feature
34
34
  import {
35
35
  findForbiddenMembershipRole,
36
+ findForbiddenRoleAssignment,
36
37
  reservedMembershipRoleError,
37
38
  unassignableMembershipRoleError,
38
39
  } from "../../tenant/membership-roles";
@@ -71,9 +72,10 @@ export type InviteCreateOptions = {
71
72
  * carries no locale signal (no X-Locale header, no usable
72
73
  * Accept-Language) — see the locale resolution in the handler below. */
73
74
  readonly locale?: AuthMailLocale;
74
- // Opt-in role-hierarchy gate. Roles are app-defined strings, not a framework
75
- // concept, so the hierarchy itself must live in the app — this hook lets it
76
- // plug in without the framework hardcoding any role names.
75
+ // Optional app policy on top of the default elevation guard
76
+ // (findForbiddenRoleAssignment). Cannot authorize reserved or unranked
77
+ // roles, and cannot raise above the inviter's max framework rank only
78
+ // further restrict assignable ranked roles.
77
79
  readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
78
80
  };
79
81
 
@@ -101,6 +103,13 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
101
103
  return writeFailure(reservedMembershipRoleError(forbiddenRole));
102
104
  }
103
105
 
106
+ const elevationForbidden = findForbiddenRoleAssignment(event.user.roles, [
107
+ event.payload.role,
108
+ ]);
109
+ if (elevationForbidden !== undefined) {
110
+ return writeFailure(unassignableMembershipRoleError(elevationForbidden));
111
+ }
112
+
104
113
  if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
105
114
  return writeFailure(unassignableMembershipRoleError(event.payload.role));
106
115
  }
@@ -68,7 +68,7 @@ describe("mapWithConcurrency", () => {
68
68
  // for a real unhandled rejection (if any) to fire.
69
69
  await new Promise((resolve) => setTimeout(resolve, 50));
70
70
  } finally {
71
- process.off("unhandledRejection", onUnhandledRejection);
71
+ process.off("unhandledRejection" as never, onUnhandledRejection);
72
72
  }
73
73
  expect(unhandled).toEqual([]);
74
74
  });
@@ -4,7 +4,9 @@ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
4
  import { AuthHandlers } from "../../auth-email-password/constants";
5
5
  import { createConfigFeature } from "../../config/feature";
6
6
  import {
7
+ DEFAULT_INVITE_ROLE_OPTIONS,
7
8
  INVITE_CREATE_SCREEN_ID,
9
+ MEMBER_ROLES_EDIT_SCREEN_ID,
8
10
  MEMBERS_SCREEN_ID,
9
11
  TenantHandlers,
10
12
  TenantQueries,
@@ -64,6 +66,37 @@ describe("tenant members screen + handler access alignment", () => {
64
66
  expect(rowAction.visible).toEqual({ field: "status", eq: "pending" });
65
67
  });
66
68
 
69
+ test("edit-roles row action is only visible on active rows and navigates to member-roles-edit", () => {
70
+ const tenant = createTenantFeature();
71
+ const screen = tenant.screens[MEMBERS_SCREEN_ID];
72
+ if (screen?.type !== "projectionList")
73
+ throw new Error("expected members screen to be projectionList");
74
+ const rowAction = screen.rowActions?.find((a) => a.id === "edit-roles");
75
+ if (rowAction?.kind !== "navigate") throw new Error("expected a navigate row action");
76
+ expect(rowAction.screen).toBe(MEMBER_ROLES_EDIT_SCREEN_ID);
77
+ expect(rowAction.params).toEqual({ map: { userId: "userId" } });
78
+ expect(rowAction.visible).toEqual({ field: "status", eq: "active" });
79
+ });
80
+
81
+ test("member-roles-edit actionForm screen is registered and bound to updateMemberRoles, access.admin-gated", () => {
82
+ const tenant = createTenantFeature();
83
+ const screen = tenant.screens[MEMBER_ROLES_EDIT_SCREEN_ID];
84
+ if (screen?.type !== "actionForm")
85
+ throw new Error("expected member-roles-edit screen to be actionForm");
86
+ expect(screen.handler).toBe(TenantHandlers.updateMemberRoles);
87
+ expect(screen.fields["userId"]).toEqual({ type: "text", required: true });
88
+ expect(screen.fields["roles"]).toEqual({
89
+ type: "multiSelect",
90
+ options: DEFAULT_INVITE_ROLE_OPTIONS,
91
+ required: true,
92
+ });
93
+ if (screen && "access" in screen && screen.access && "roles" in screen.access) {
94
+ expect(rolesOf(screen.access)).toEqual([...access.admin]);
95
+ } else {
96
+ throw new Error("expected member-roles-edit screen to have access.roles");
97
+ }
98
+ });
99
+
67
100
  // The generic drawer-open/submit/close/refetch mechanics for kind:"drawer"
68
101
  // toolbar actions are already covered end-to-end with a synthetic screen in
69
102
  // renderer-web's projection-list-actions.test.tsx — this only proves OUR
@@ -103,6 +136,10 @@ describe("tenant members screen + handler access alignment", () => {
103
136
  expect(rolesOf(tenant.queryHandlers["invitations"]?.access)).toEqual(adminRoles);
104
137
  expect(rolesOf(tenant.queryHandlers["team:list"]?.access)).toEqual(adminRoles);
105
138
  expect(rolesOf(tenant.writeHandlers["cancel-invitation"]?.access)).toEqual(adminRoles);
139
+ expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
140
+ "system",
141
+ ...adminRoles,
142
+ ]);
106
143
  // invite-create lives on auth feature — checked in tenant-security.integration.test.ts
107
144
  void AuthHandlers;
108
145
  void TenantHandlers;
@@ -114,11 +151,11 @@ describe("tenant members screen + handler access alignment", () => {
114
151
  expect(nav?.label).toBe("tenant.nav.members");
115
152
  });
116
153
 
117
- test("updateMemberRoles stays SystemAdmin/system-only (not on members screen)", () => {
154
+ test("updateMemberRoles allows system and access.admin", () => {
118
155
  const tenant = createTenantFeature();
119
156
  expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
120
157
  "system",
121
- "SystemAdmin",
158
+ ...access.admin,
122
159
  ]);
123
160
  });
124
161
  });
@@ -13,13 +13,12 @@
13
13
 
14
14
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { SYSTEM_USER_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
17
17
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
18
  import {
19
19
  createTestUser,
20
20
  setupTestStack,
21
21
  type TestStack,
22
- TestUsers,
23
22
  unsafeCreateEntityTable,
24
23
  unsafePushTables,
25
24
  } from "@cosmicdrift/kumiko-framework/stack";
@@ -200,9 +199,9 @@ describe("seedTenantMembership", () => {
200
199
  });
201
200
 
202
201
  test("records the `by` user as insertedById on the projection", async () => {
203
- // Audit-queries that join events → users need a stable actor. Default
204
- // `by` is TestUsers.systemAdmin; override to a custom test user and
205
- // assert it propagates to the projection's inserted_by_id column.
202
+ // Audit-queries that join events → users need a stable actor. Override
203
+ // default createSystemUser(tenantId) with a custom test user and assert
204
+ // it propagates to the projection's inserted_by_id column.
206
205
  const seedActor = createTestUser({ id: 99, tenantId: TENANT_A });
207
206
  await seedTenantMembership(stack.db, {
208
207
  userId: ALICE_ID,
@@ -215,15 +214,18 @@ describe("seedTenantMembership", () => {
215
214
  expect(row?.["insertedById"]).toBe(seedActor.id);
216
215
  });
217
216
 
218
- test("default `by` is TestUsers.systemAdmin", async () => {
219
- // Documents the fallback — a regression that changed the default would
220
- // silently skew audit queries across 18 call-sites.
217
+ test("default `by` is createSystemUser(tenantId)", async () => {
221
218
  await seedTenantMembership(stack.db, {
222
219
  userId: ALICE_ID,
223
220
  tenantId: TENANT_A,
224
221
  roles: ["User"],
225
222
  });
226
223
  const [row] = await selectMany(stack.db, tenantMembershipsTable, { userId: ALICE_ID });
227
- expect(row?.["insertedById"]).toBe(TestUsers.systemAdmin.id);
224
+ expect(row?.["insertedById"]).toBe(SYSTEM_USER_ID);
225
+
226
+ const events = await selectMany(stack.db, eventsTable, { aggregateType: "tenant-membership" });
227
+ const created = events.filter((e) => e.type === "tenant-membership.created");
228
+ expect(created).toHaveLength(1);
229
+ expect(created[0]?.tenantId).toBe(TENANT_A);
228
230
  });
229
231
  });