@cosmicdrift/kumiko-bundled-features 0.87.2 → 0.87.3

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.87.2",
3
+ "version": "0.87.3",
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>",
@@ -86,11 +86,11 @@
86
86
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
87
87
  },
88
88
  "dependencies": {
89
- "@cosmicdrift/kumiko-dispatcher-live": "0.87.2",
90
- "@cosmicdrift/kumiko-framework": "0.87.2",
91
- "@cosmicdrift/kumiko-headless": "0.87.2",
92
- "@cosmicdrift/kumiko-renderer": "0.87.2",
93
- "@cosmicdrift/kumiko-renderer-web": "0.87.2",
89
+ "@cosmicdrift/kumiko-dispatcher-live": "0.87.3",
90
+ "@cosmicdrift/kumiko-framework": "0.87.3",
91
+ "@cosmicdrift/kumiko-headless": "0.87.3",
92
+ "@cosmicdrift/kumiko-renderer": "0.87.3",
93
+ "@cosmicdrift/kumiko-renderer-web": "0.87.3",
94
94
  "@mollie/api-client": "^4.5.0",
95
95
  "@node-rs/argon2": "^2.0.2",
96
96
  "@types/nodemailer": "^8.0.0",
@@ -251,3 +251,46 @@ describe("multi-roles: switch-tenant erhält globale rollen", () => {
251
251
  expect(switchBody.roles).toEqual(["User"]);
252
252
  });
253
253
  });
254
+
255
+ // Read-time backstop (engine/membership-roles): the write paths reject reserved
256
+ // roles at command time, but a projection rebuild replays stored membership
257
+ // events through the apply path, bypassing that check. addMembership inserts
258
+ // straight into the projection table — the same shape a rebuild would produce —
259
+ // so a forbidden role lands in the membership without going through a handler.
260
+ // Every JWT mint must strip it; globalRoles must survive untouched.
261
+ describe("multi-roles: reserved membership role stripped at the mint", () => {
262
+ test("login: resurrected ['SystemAdmin'] in membership → stripped, ['Admin'] survives", async () => {
263
+ const userId = await seedUser("resurrect@example.com", "pw-long-enough");
264
+ await addMembership(userId, tenantA, ["SystemAdmin", "Admin"]);
265
+
266
+ const { user } = await login("resurrect@example.com", "pw-long-enough");
267
+ expect(user.roles).toEqual(["Admin"]);
268
+ expect(user.roles).not.toContain("SystemAdmin");
269
+ });
270
+
271
+ test("login: global SystemAdmin survives even when membership repeats it", async () => {
272
+ const userId = await seedUser("realadmin@example.com", "pw-long-enough", ["SystemAdmin"]);
273
+ await addMembership(userId, tenantA, ["SystemAdmin", "Admin"]);
274
+
275
+ const { user } = await login("realadmin@example.com", "pw-long-enough");
276
+ expect(user.roles.sort()).toEqual(["Admin", "SystemAdmin"]);
277
+ });
278
+
279
+ test("switch-tenant: resurrected ['SystemAdmin'] on target tenant → stripped", async () => {
280
+ const userId = await seedUser("resurrect2@example.com", "pw-long-enough");
281
+ await addMembership(userId, tenantA, ["Admin"]);
282
+ await addMembership(userId, tenantB, ["SystemAdmin", "User"]);
283
+
284
+ const { token } = await login("resurrect2@example.com", "pw-long-enough");
285
+ const switchRes = await stack.http.raw(
286
+ "POST",
287
+ "/api/auth/switch-tenant",
288
+ { tenantId: tenantB },
289
+ { authorization: `Bearer ${token}` },
290
+ );
291
+ expect(switchRes.status).toBe(200);
292
+ const switchBody = (await switchRes.json()) as { roles: string[] };
293
+ expect(switchBody.roles).toEqual(["User"]);
294
+ expect(switchBody.roles).not.toContain("SystemAdmin");
295
+ });
296
+ });
@@ -23,6 +23,7 @@ import {
23
23
  createSystemUser,
24
24
  defineWriteHandler,
25
25
  type SessionUser,
26
+ stripForbiddenMembershipRoles,
26
27
  type TenantId,
27
28
  } from "@cosmicdrift/kumiko-framework/engine";
28
29
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
@@ -164,7 +165,7 @@ export function createInviteAcceptWithLoginHandler() {
164
165
  const session: SessionUser = {
165
166
  id: userId,
166
167
  tenantId: invitationTenantId,
167
- roles: [invitationRole],
168
+ roles: stripForbiddenMembershipRoles([invitationRole]),
168
169
  };
169
170
 
170
171
  committed = true;
@@ -27,6 +27,7 @@ import {
27
27
  createSystemUser,
28
28
  defineWriteHandler,
29
29
  type SessionUser,
30
+ stripForbiddenMembershipRoles,
30
31
  type TenantId,
31
32
  } from "@cosmicdrift/kumiko-framework/engine";
32
33
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
@@ -160,7 +161,7 @@ export function createInviteSignupCompleteHandler() {
160
161
  const session: SessionUser = {
161
162
  id: userId,
162
163
  tenantId: invitationTenantId,
163
- roles: [invitationRole],
164
+ roles: stripForbiddenMembershipRoles([invitationRole]),
164
165
  };
165
166
 
166
167
  committed = true;
@@ -2,6 +2,7 @@ import {
2
2
  createSystemUser,
3
3
  defineWriteHandler,
4
4
  type SessionUser,
5
+ stripForbiddenMembershipRoles,
5
6
  type TenantId,
6
7
  } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
@@ -165,7 +166,11 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
165
166
  // membership. Dedupe via Set damit eine Rolle die in beiden Quellen
166
167
  // steht nicht doppelt im Session-Roles landet.
167
168
  const globalRoles = parseRoles(found.roles ?? null);
168
- const mergedRoles = Array.from(new Set([...globalRoles, ...chosen.roles]));
169
+ // Strip reserved roles from the membership portion only (globalRoles keeps
170
+ // SystemAdmin) — read-time backstop against a rebuild-resurrected role.
171
+ const mergedRoles = Array.from(
172
+ new Set([...globalRoles, ...stripForbiddenMembershipRoles(chosen.roles)]),
173
+ );
169
174
  const baseSession: SessionUser = {
170
175
  id: found.id,
171
176
  tenantId: chosen.tenantId,
@@ -6,18 +6,10 @@
6
6
  // this validator makes every membership-role write path enforce the same
7
7
  // invariant. Derived from the framework presets so it tracks access.privileged.
8
8
 
9
- import { access } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { findForbiddenMembershipRole } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
11
11
 
12
- const FORBIDDEN_MEMBERSHIP_ROLES: ReadonlySet<string> = new Set<string>([
13
- ...access.privileged, // system, SystemAdmin
14
- ...access.all, // all
15
- ...access.anonymous, // anonymous
16
- ]);
17
-
18
- export function findForbiddenMembershipRole(roles: readonly string[]): string | undefined {
19
- return roles.find((role) => FORBIDDEN_MEMBERSHIP_ROLES.has(role));
20
- }
12
+ export { findForbiddenMembershipRole };
21
13
 
22
14
  export function reservedMembershipRoleError(role: string): AccessDeniedError {
23
15
  return new AccessDeniedError({