@cosmicdrift/kumiko-bundled-features 0.215.6 → 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.
Files changed (35) hide show
  1. package/package.json +9 -8
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +35 -2
  3. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +86 -37
  4. package/src/auth-email-password/handlers/invite-create.write.ts +12 -3
  5. package/src/cap-counter/__tests__/cap-list.boot.test.ts +4 -0
  6. package/src/cap-counter/feature.ts +1 -0
  7. package/src/delivery/__tests__/delivery-security.integration.test.ts +139 -1
  8. package/src/delivery/feature.ts +1 -0
  9. package/src/delivery/handlers/log.query.ts +12 -3
  10. package/src/delivery/i18n.ts +1 -0
  11. package/src/jobs/is-manual-trigger.test.ts +17 -0
  12. package/src/shared/map-with-concurrency.test.ts +1 -1
  13. package/src/shared/session-locale-field.test.ts +16 -0
  14. package/src/shared/session-timezone-field.test.ts +16 -0
  15. package/src/tenant/__tests__/members-facet-i18n.test.ts +72 -0
  16. package/src/tenant/__tests__/members-screens.boot.test.ts +39 -2
  17. package/src/tenant/__tests__/seed-testing.integration.test.ts +11 -9
  18. package/src/tenant/__tests__/tenant-security.integration.test.ts +433 -5
  19. package/src/tenant/__tests__/tenant.integration.test.ts +3 -2
  20. package/src/tenant/constants.ts +3 -0
  21. package/src/tenant/feature.ts +4 -3
  22. package/src/tenant/handlers/team-list.query.ts +3 -0
  23. package/src/tenant/handlers/update-member-roles.write.ts +107 -20
  24. package/src/tenant/i18n.ts +10 -1
  25. package/src/tenant/membership-roles.ts +5 -2
  26. package/src/tenant/screens.ts +24 -0
  27. package/src/tenant/seeding.ts +9 -6
  28. package/src/user/__tests__/user.integration.test.ts +560 -3
  29. package/src/user/constants.ts +3 -0
  30. package/src/user/handlers/create.write.ts +36 -17
  31. package/src/user/handlers/update-roles.ts +169 -0
  32. package/src/user/handlers/update.write.ts +32 -8
  33. package/src/user/i18n.ts +1 -0
  34. package/src/user/screens.ts +1 -1
  35. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +43 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.215.6",
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.6",
130
- "@cosmicdrift/kumiko-framework": "0.215.6",
131
- "@cosmicdrift/kumiko-headless": "0.215.6",
132
- "@cosmicdrift/kumiko-renderer": "0.215.6",
133
- "@cosmicdrift/kumiko-renderer-web": "0.215.6",
134
- "@cosmicdrift/kumiko-types": "0.215.6",
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,6 +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.6"
163
+ "@cosmicdrift/kumiko-locale-de": "0.216.0",
164
+ "@cosmicdrift/kumiko-locale-es": "0.216.0"
164
165
  }
165
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
  }
@@ -11,6 +11,10 @@ describe("cap-counter list screen + handler access alignment", () => {
11
11
  expect(() => validateBoot(features)).not.toThrow();
12
12
  });
13
13
 
14
+ test("cap-list nav declares icon gauge", () => {
15
+ expect(capCounterFeature.navs["cap-list"]?.icon).toBe("gauge");
16
+ });
17
+
14
18
  test("cap-list screen is entityList, SystemAdmin-gated", () => {
15
19
  const screen = capCounterFeature.screens[CAP_COUNTER_LIST_SCREEN_ID];
16
20
  expect(screen?.type).toBe("entityList");
@@ -110,6 +110,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
110
110
  id: "cap-list",
111
111
  label: "cap-counter:nav.cap-list",
112
112
  screen: "cap-counter:screen:cap-list",
113
+ icon: "gauge",
113
114
  order: 60,
114
115
  });
115
116
  r.translations({
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { insertMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
- import { access, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { access, createRegistry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import {
5
5
  createTestUser,
6
6
  setupTestStack,
@@ -105,6 +105,144 @@ describe("delivery log tenant isolation", () => {
105
105
  });
106
106
  });
107
107
 
108
+ describe("delivery log SystemAdmin cross-tenant scope", () => {
109
+ // Platform waitlist confirmations are dispatched under SYSTEM_TENANT_ID
110
+ // (anonymous submit). SystemAdmin must see those rows even when their
111
+ // session tenant is a demo tenant; TenantAdmin must not.
112
+ const WAITLIST_CONFIRMATION = "waitlist:notify:confirmation";
113
+
114
+ test("SystemAdmin sees waitlist confirmation under SYSTEM_TENANT_ID and other tenants, with tenantId on each row", async () => {
115
+ const demoTenant = testTenantId(51);
116
+ const otherTenant = testTenantId(52);
117
+ const systemConfirmationId = crypto.randomUUID();
118
+ const demoActivationId = crypto.randomUUID();
119
+ const otherTenantId = crypto.randomUUID();
120
+ const failedConfirmationId = crypto.randomUUID();
121
+
122
+ await insertMany(stack.db, deliveryAttemptsTable, [
123
+ {
124
+ id: systemConfirmationId,
125
+ tenantId: SYSTEM_TENANT_ID,
126
+ notificationType: WAITLIST_CONFIRMATION,
127
+ channel: "email",
128
+ recipientAddress: null,
129
+ status: "sent",
130
+ },
131
+ {
132
+ id: failedConfirmationId,
133
+ tenantId: SYSTEM_TENANT_ID,
134
+ notificationType: WAITLIST_CONFIRMATION,
135
+ channel: "email",
136
+ recipientAddress: null,
137
+ status: "failed",
138
+ error: "smtp down",
139
+ },
140
+ {
141
+ id: demoActivationId,
142
+ tenantId: demoTenant,
143
+ notificationType: "auth-email-password:signup-activation",
144
+ channel: "email",
145
+ recipientAddress: null,
146
+ status: "sent",
147
+ },
148
+ {
149
+ id: otherTenantId,
150
+ tenantId: otherTenant,
151
+ notificationType: "welcome",
152
+ channel: "email",
153
+ recipientAddress: null,
154
+ status: "sent",
155
+ },
156
+ ]);
157
+
158
+ // Session on demo tenant — mirrors SystemAdmin browsing a tenant panel.
159
+ const systemAdmin = createTestUser({
160
+ id: 51,
161
+ roles: ["SystemAdmin"],
162
+ tenantId: demoTenant,
163
+ });
164
+ const res = await stack.http.queryOk<{
165
+ rows: readonly {
166
+ id: string;
167
+ tenantId: string;
168
+ type: string;
169
+ status: string;
170
+ error: string | null;
171
+ }[];
172
+ }>(DeliveryQueries.log, {}, systemAdmin);
173
+
174
+ const byId = new Map(res.rows.map((r) => [r.id, r]));
175
+ expect(byId.get(systemConfirmationId)?.tenantId).toBe(SYSTEM_TENANT_ID);
176
+ expect(byId.get(systemConfirmationId)?.type).toBe(WAITLIST_CONFIRMATION);
177
+ expect(byId.get(demoActivationId)?.tenantId).toBe(demoTenant);
178
+ expect(byId.get(otherTenantId)?.tenantId).toBe(otherTenant);
179
+
180
+ const failed = byId.get(failedConfirmationId);
181
+ expect(failed?.tenantId).toBe(SYSTEM_TENANT_ID);
182
+ expect(failed?.type).toBe(WAITLIST_CONFIRMATION);
183
+ // Failure path: confirmation delivery errors must surface in the log so
184
+ // SystemAdmin can diagnose SMTP/channel outages (status=failed).
185
+ expect(failed?.status).toBe("failed");
186
+
187
+ expect(res.rows.every((r) => typeof r.tenantId === "string" && r.tenantId.length > 0)).toBe(
188
+ true,
189
+ );
190
+ });
191
+
192
+ test("TenantAdmin cannot read SYSTEM_TENANT_ID or foreign-tenant delivery attempts", async () => {
193
+ const demoTenant = testTenantId(61);
194
+ const otherTenant = testTenantId(62);
195
+ await insertMany(stack.db, deliveryAttemptsTable, [
196
+ {
197
+ id: crypto.randomUUID(),
198
+ tenantId: SYSTEM_TENANT_ID,
199
+ notificationType: WAITLIST_CONFIRMATION,
200
+ channel: "email",
201
+ recipientAddress: null,
202
+ status: "sent",
203
+ },
204
+ {
205
+ id: crypto.randomUUID(),
206
+ tenantId: otherTenant,
207
+ notificationType: "welcome",
208
+ channel: "email",
209
+ recipientAddress: null,
210
+ status: "sent",
211
+ },
212
+ {
213
+ id: crypto.randomUUID(),
214
+ tenantId: demoTenant,
215
+ notificationType: "welcome",
216
+ channel: "email",
217
+ recipientAddress: null,
218
+ status: "sent",
219
+ },
220
+ ]);
221
+
222
+ const tenantAdmin = createTestUser({
223
+ id: 61,
224
+ roles: ["TenantAdmin"],
225
+ tenantId: demoTenant,
226
+ });
227
+ const res = await stack.http.queryOk<{ rows: readonly { tenantId: string }[] }>(
228
+ DeliveryQueries.log,
229
+ {},
230
+ tenantAdmin,
231
+ );
232
+
233
+ expect(res.rows.length).toBeGreaterThan(0);
234
+ expect(res.rows.every((r) => r.tenantId === demoTenant)).toBe(true);
235
+ expect(res.rows.some((r) => r.tenantId === SYSTEM_TENANT_ID)).toBe(false);
236
+ expect(res.rows.some((r) => r.tenantId === otherTenant)).toBe(false);
237
+ });
238
+
239
+ test("tenant User cannot query delivery log (negative authz)", async () => {
240
+ const demoTenant = testTenantId(71);
241
+ const user = createTestUser({ id: 71, roles: ["User"], tenantId: demoTenant });
242
+ expect((await stack.http.query(DeliveryQueries.log, {}, user)).status).toBe(403);
243
+ });
244
+ });
245
+
108
246
  describe("delivery preferences tenant isolation", () => {
109
247
  // A userId is not tenant-unique — the same person can be a member of
110
248
  // several tenants (tenant:write:add-member takes {userId, tenantId}).
@@ -143,6 +143,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
143
143
  type: "projectionList",
144
144
  query: DeliveryQueries.log,
145
145
  columns: [
146
+ { field: "tenantId", label: "delivery.log.col.tenantId" },
146
147
  { field: "type", label: "delivery.log.col.type" },
147
148
  { field: "channel", label: "delivery.log.col.channel" },
148
149
  { field: "recipient", label: "delivery.log.col.recipient" },
@@ -81,8 +81,15 @@ export const logQuery = definePagedQueryHandler({
81
81
  // delivery is r.systemScope()'d, so the TenantDb is system-mode (reads
82
82
  // unfiltered). assertTenantMatch is a self-check on the caller's own
83
83
  // tenantId, not a query filter — it returns that same unfiltered db, so
84
- // the explicit `where` below still does the actual tenant scoping.
85
- const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
84
+ // the explicit `where` below still does the actual tenant scoping for
85
+ // non-SystemAdmin callers. SystemAdmin acknowledges cross-tenant read
86
+ // (PII-bearing delivery logs across all tenants incl. SYSTEM_TENANT_ID).
87
+ const isSystemAdmin = query.user.roles.includes("SystemAdmin");
88
+ const db = isSystemAdmin
89
+ ? ctx.systemDb.acknowledgeCrossTenant(
90
+ "SystemAdmin reads delivery attempts across all tenants including SYSTEM_TENANT_ID",
91
+ )
92
+ : ctx.systemDb.assertTenantMatch(query.user.tenantId);
86
93
 
87
94
  const requestedSort = query.payload.sort;
88
95
  const sortField: DeliveryLogSortField =
@@ -92,7 +99,9 @@ export const logQuery = definePagedQueryHandler({
92
99
  const sortDirection = query.payload.sortDirection ?? "desc";
93
100
  const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
94
101
 
95
- const where: WhereObject = { tenantId: query.user.tenantId };
102
+ // TenantAdmin/Admin stay strictly tenant-scoped; SystemAdmin sees every
103
+ // tenant's attempts (platform waitlist confirmations live on SYSTEM_TENANT_ID).
104
+ const where: WhereObject = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
96
105
  if (query.payload.cursor) {
97
106
  where[sortColumn] = {
98
107
  [sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
@@ -3,6 +3,7 @@ type LocalizedString = { readonly en: string };
3
3
  export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:delivery-log.title": { en: "Delivery log" },
5
5
  "delivery:nav.deliveryLog": { en: "Delivery" },
6
+ "delivery.log.col.tenantId": { en: "Tenant" },
6
7
  "delivery.log.col.type": { en: "Type" },
7
8
  "delivery.log.col.channel": { en: "Channel" },
8
9
  "delivery.log.col.recipient": { en: "Recipient" },
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isManualTrigger } from "./is-manual-trigger";
3
+
4
+ describe("isManualTrigger", () => {
5
+ test("manual trigger → true", () => {
6
+ expect(isManualTrigger({ manual: true })).toBe(true);
7
+ });
8
+
9
+ test("cron trigger → false", () => {
10
+ expect(isManualTrigger({ cron: "*/5 * * * *" })).toBe(false);
11
+ });
12
+
13
+ test("event trigger → false", () => {
14
+ expect(isManualTrigger({ on: "entity.created" })).toBe(false);
15
+ expect(isManualTrigger({ on: ["entity.created", "entity.updated"] })).toBe(false);
16
+ });
17
+ });
@@ -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
  });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sessionLocaleField } from "./session-locale-field";
3
+
4
+ describe("sessionLocaleField", () => {
5
+ test("set locale → { locale }", () => {
6
+ expect(sessionLocaleField("de-DE")).toEqual({ locale: "de-DE" });
7
+ });
8
+
9
+ test("null locale → empty object", () => {
10
+ expect(sessionLocaleField(null)).toEqual({});
11
+ });
12
+
13
+ test("undefined locale → empty object", () => {
14
+ expect(sessionLocaleField(undefined)).toEqual({});
15
+ });
16
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sessionTimezoneField } from "./session-timezone-field";
3
+
4
+ describe("sessionTimezoneField", () => {
5
+ test("set timezone → { timezone }", () => {
6
+ expect(sessionTimezoneField("Europe/Berlin")).toEqual({ timezone: "Europe/Berlin" });
7
+ });
8
+
9
+ test("null timezone → empty object", () => {
10
+ expect(sessionTimezoneField(null)).toEqual({});
11
+ });
12
+
13
+ test("undefined timezone → empty object", () => {
14
+ expect(sessionTimezoneField(undefined)).toEqual({});
15
+ });
16
+ });