@cosmicdrift/kumiko-bundled-features 0.220.0 → 0.221.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 (121) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
  3. package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
  4. package/src/audit/__tests__/constants.test.ts +8 -0
  5. package/src/audit/web/audit-log-detail-screen.tsx +30 -14
  6. package/src/audit/web/audit-log-screen.tsx +21 -7
  7. package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
  8. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
  9. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
  10. package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
  11. package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
  12. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
  13. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
  14. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
  15. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
  16. package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
  17. package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
  19. package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
  20. package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
  21. package/src/auth-email-password/i18n.ts +1 -0
  22. package/src/auth-email-password/magic-link-mail.ts +17 -1
  23. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
  24. package/src/auth-email-password/web/auth-client.ts +1 -7
  25. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
  26. package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
  27. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
  28. package/src/custom-fields/__tests__/feature.test.ts +10 -12
  29. package/src/custom-fields/constants.ts +4 -3
  30. package/src/custom-fields/feature.ts +7 -8
  31. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
  32. package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
  33. package/src/delivery/db/queries/preferences.ts +3 -2
  34. package/src/delivery/handlers/log.query.ts +1 -0
  35. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
  36. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
  37. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
  38. package/src/feature-toggles/feature.ts +24 -4
  39. package/src/feature-toggles/handlers/registered.query.ts +27 -16
  40. package/src/feature-toggles/i18n.ts +5 -0
  41. package/src/feature-toggles/web/client-plugin.tsx +1 -5
  42. package/src/feature-toggles/web/index.ts +0 -1
  43. package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
  44. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
  45. package/src/file-derivatives/feature.ts +7 -6
  46. package/src/file-provider-s3/feature.ts +1 -2
  47. package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
  48. package/src/form-draft/handlers/cleanup.job.ts +31 -20
  49. package/src/form-draft/handlers/discard.write.ts +8 -8
  50. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
  51. package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
  52. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
  53. package/src/jobs/feature.ts +7 -2
  54. package/src/jobs/handlers/detail.query.ts +22 -11
  55. package/src/jobs/handlers/list.query.ts +17 -6
  56. package/src/jobs/job-run-logger.ts +78 -36
  57. package/src/jobs/web/job-run-detail-screen.tsx +4 -4
  58. package/src/mail-transport-smtp/feature.ts +1 -2
  59. package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
  60. package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
  61. package/src/notes-history/handlers/add-note.write.ts +7 -3
  62. package/src/notes-history/web/notes-section.tsx +10 -2
  63. package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
  64. package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
  65. package/src/secrets/feature.ts +9 -2
  66. package/src/secrets/handlers/delete.write.ts +0 -4
  67. package/src/secrets/handlers/list.query.ts +0 -4
  68. package/src/secrets/handlers/set.write.ts +0 -4
  69. package/src/secrets/index.ts +3 -3
  70. package/src/shared/index.ts +1 -0
  71. package/src/shared/map-with-concurrency.test.ts +2 -2
  72. package/src/shared/session-field.ts +10 -0
  73. package/src/shared/session-locale-field.test.ts +9 -0
  74. package/src/shared/session-locale-field.ts +4 -1
  75. package/src/shared/session-timezone-field.ts +2 -1
  76. package/src/subscription-stripe/feature.ts +5 -11
  77. package/src/tags/__tests__/feature.test.ts +18 -5
  78. package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
  79. package/src/tags/constants.ts +3 -2
  80. package/src/tags/entity.ts +2 -0
  81. package/src/tags/feature.ts +20 -11
  82. package/src/tags/handlers/create-tag.write.ts +5 -2
  83. package/src/tags/handlers/delete-tag.write.ts +5 -2
  84. package/src/tags/handlers/update-tag.write.ts +5 -2
  85. package/src/tags/i18n.ts +4 -0
  86. package/src/tags/index.ts +2 -0
  87. package/src/tags/screens.ts +66 -0
  88. package/src/tags/web/client-plugin.tsx +1 -6
  89. package/src/tags/web/index.ts +1 -0
  90. package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
  91. package/src/template-resolver/seeding.ts +78 -6
  92. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  93. package/src/template-resolver/web/client-plugin.tsx +2 -1
  94. package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
  95. package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
  96. package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
  97. package/src/tenant/handlers/members.query.ts +6 -1
  98. package/src/tenant/handlers/team-list.query.ts +3 -1
  99. package/src/tenant/handlers/update-member-roles.write.ts +16 -2
  100. package/src/tenant/i18n.ts +2 -3
  101. package/src/tenant/screens.ts +3 -2
  102. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
  103. package/src/tenant-settings/feature.ts +6 -30
  104. package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
  105. package/src/user/__tests__/roles-input-schema.test.ts +20 -0
  106. package/src/user/__tests__/user.integration.test.ts +156 -118
  107. package/src/user/handlers/create.write.ts +5 -4
  108. package/src/user/handlers/detail.query.ts +24 -2
  109. package/src/user/handlers/list.query.ts +19 -26
  110. package/src/user/handlers/update-roles.ts +23 -6
  111. package/src/user/handlers/update.write.ts +6 -5
  112. package/src/user/i18n.ts +0 -6
  113. package/src/user/roles-input-schema.ts +20 -0
  114. package/src/user/schema/user.ts +2 -3
  115. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
  116. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
  117. package/src/user-data-rights/run-forget-cleanup.ts +34 -24
  118. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
  119. package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
  120. package/src/user-profile/web/profile-screen.tsx +1 -1
  121. package/src/feature-toggles/web/toggle-admin-screen.tsx +0 -137
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
- import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import {
5
5
  createTestUser,
6
6
  setupTestStack,
@@ -8,6 +8,7 @@ import {
8
8
  TestUsers,
9
9
  testTenantId,
10
10
  unsafeCreateEntityTable,
11
+ unsafePushTables,
11
12
  } from "@cosmicdrift/kumiko-framework/stack";
12
13
  import {
13
14
  expectErrorIncludes,
@@ -15,6 +16,8 @@ import {
15
16
  updateRows,
16
17
  } from "@cosmicdrift/kumiko-framework/testing";
17
18
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
19
+ import { tenantEntity, tenantMembershipsTable } from "../../tenant";
20
+ import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
18
21
  import { UserErrors, UserHandlers, UserQueries } from "../constants";
19
22
  import { createUserFeature } from "../feature";
20
23
  import { userEntity, userTable } from "../schema/user";
@@ -27,6 +30,10 @@ const userFeature = createUserFeature();
27
30
  beforeAll(async () => {
28
31
  stack = await setupTestStack({ features: [userFeature] });
29
32
  await unsafeCreateEntityTable(stack.db, userEntity);
33
+ // Not part of userFeature — list.query.ts cross-feature-joins these to
34
+ // derive the `tenants` column, so the tables must exist for that test.
35
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
36
+ await unsafePushTables(stack.db, { tenantMembershipsTable });
30
37
  });
31
38
 
32
39
  afterAll(async () => {
@@ -322,6 +329,51 @@ describe("scenario 4: detail + list access", () => {
322
329
  expect(result.rows.length).toBeGreaterThanOrEqual(2);
323
330
  });
324
331
 
332
+ test("list enriches rows with a `tenants` label built from active memberships", async () => {
333
+ const tenantA = "10000000-0000-4000-8000-0000000000a1" as TenantId;
334
+ const tenantB = "10000000-0000-4000-8000-0000000000b1" as TenantId;
335
+ await seedTenant(stack.db, { id: tenantA, key: "tenants-col-a", name: "Tenant Alpha" });
336
+ await seedTenant(stack.db, { id: tenantB, key: "tenants-col-b", name: "Tenant Beta" });
337
+
338
+ const withMemberships = await seedUser({
339
+ email: "multi-tenant-user@example.com",
340
+ displayName: "Multi Tenant User",
341
+ });
342
+ const withoutMemberships = await seedUser({
343
+ email: "no-tenant-user@example.com",
344
+ displayName: "No Tenant User",
345
+ });
346
+
347
+ await seedTenantMembership(stack.db, {
348
+ userId: withMemberships.id,
349
+ tenantId: tenantA,
350
+ roles: ["TenantAdmin"],
351
+ });
352
+ await seedTenantMembership(stack.db, {
353
+ userId: withMemberships.id,
354
+ tenantId: tenantB,
355
+ roles: [],
356
+ });
357
+
358
+ const result = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
359
+ UserQueries.list,
360
+ {},
361
+ systemAdmin,
362
+ );
363
+
364
+ const enrichedRow = result.rows.find((r) => r["id"] === withMemberships.id);
365
+ const bareRow = result.rows.find((r) => r["id"] === withoutMemberships.id);
366
+ if (!enrichedRow || !bareRow) throw new Error("expected both seeded users in the list result");
367
+
368
+ // Membership order from the join isn't guaranteed — compare as a sorted
369
+ // set of labels instead of a fixed-order string.
370
+ const labels = String(enrichedRow["tenants"]).split(", ").sort();
371
+ expect(labels).toEqual(["Tenant Alpha (TenantAdmin)", "Tenant Beta"].sort());
372
+
373
+ // No memberships: empty string (renderer formats); not a hard-coded em-dash.
374
+ expect(bareRow["tenants"]).toBe("");
375
+ });
376
+
325
377
  test("normal user cannot list", async () => {
326
378
  const signedIn = createTestUser({ id: 2000, roles: ["User"] });
327
379
  const res = await stack.http.query(UserQueries.list, {}, signedIn);
@@ -404,36 +456,6 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
404
456
  expect(parseRoles(reloaded["roles"])).toEqual(["SystemAdmin"]);
405
457
  });
406
458
 
407
- test("promoted user with SystemAdmin role can perform SystemAdmin-only queries", async () => {
408
- const created = await seedUser({
409
- email: "promoted-admin@example.com",
410
- displayName: "PromotedAdmin",
411
- });
412
- const loaded = await stack.http.queryOk<Record<string, unknown>>(
413
- UserQueries.detail,
414
- { id: created.id },
415
- systemAdmin,
416
- );
417
-
418
- await stack.http.writeOk(
419
- UserHandlers.update,
420
- {
421
- id: created.id,
422
- version: loaded["version"],
423
- changes: { roles: ["SystemAdmin"] },
424
- },
425
- systemAdmin,
426
- );
427
-
428
- const newlyPromoted = createTestUser({ id: created.id, roles: ["SystemAdmin"] });
429
- const listResult = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
430
- UserQueries.list,
431
- {},
432
- newlyPromoted,
433
- );
434
- expect(listResult.rows.length).toBeGreaterThanOrEqual(1);
435
- });
436
-
437
459
  test("SystemAdmin updates target user roles to multiple valid roles", async () => {
438
460
  const created = await seedUser({ email: "multirole@example.com", displayName: "MultiRole" });
439
461
  const loaded = await stack.http.queryOk<Record<string, unknown>>(
@@ -507,6 +529,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
507
529
  actor,
508
530
  );
509
531
  expectErrorIncludes(error, "field_access_denied");
532
+ expect(error.httpStatus).toBe(403);
510
533
  });
511
534
 
512
535
  test("normal user gets 403 when attempting to assign global roles to self", async () => {
@@ -529,6 +552,7 @@ describe("scenario 5: global roles mutation & elevation guard (#2388)", () => {
529
552
  normalUser,
530
553
  );
531
554
  expectErrorIncludes(error, "field_access_denied");
555
+ expect(error.httpStatus).toBe(403);
532
556
  });
533
557
 
534
558
  test("normal user gets 403 when attempting to assign global roles to another user", async () => {
@@ -729,31 +753,29 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
729
753
  expect(reloaded["displayName"]).toBe("UpdatedAdminName");
730
754
  });
731
755
 
732
- test("updating non-SystemAdmin user roles succeeds when only one SystemAdmin exists", async () => {
733
- // Seed 1 active SystemAdmin
756
+ test("demoting a SystemAdmin succeeds when another active SystemAdmin still exists", async () => {
757
+ // Extra active SystemAdmin so demoting the target is not a last-admin conflict.
734
758
  await seedUser({
735
759
  email: "the-system-admin@example.com",
736
760
  displayName: "TheAdmin",
737
761
  roles: ["SystemAdmin"],
738
762
  });
739
763
 
740
- // Seed a normal user
741
- const normalUser = await seedUser({
742
- email: "regular-user@example.com",
743
- displayName: "RegularUser",
744
- roles: ["User"],
764
+ const targetAdmin = await seedUser({
765
+ email: "second-admin@example.com",
766
+ displayName: "SecondAdmin",
767
+ roles: ["SystemAdmin"],
745
768
  });
746
769
  const loaded = await stack.http.queryOk<Record<string, unknown>>(
747
770
  UserQueries.detail,
748
- { id: normalUser.id },
771
+ { id: targetAdmin.id },
749
772
  systemAdmin,
750
773
  );
751
774
 
752
- // Updating normal user roles to ["User", "Editor"] should succeed without conflict
753
775
  await stack.http.writeOk(
754
776
  UserHandlers.update,
755
777
  {
756
- id: normalUser.id,
778
+ id: targetAdmin.id,
757
779
  version: loaded["version"],
758
780
  changes: { roles: ["User", "Editor"] },
759
781
  },
@@ -762,92 +784,108 @@ describe("scenario 6: last active SystemAdmin protection (#2388)", () => {
762
784
 
763
785
  const reloaded = await stack.http.queryOk<Record<string, unknown>>(
764
786
  UserQueries.detail,
765
- { id: normalUser.id },
787
+ { id: targetAdmin.id },
766
788
  systemAdmin,
767
789
  );
768
790
  expect(parseRoles(reloaded["roles"])).toEqual(["User", "Editor"]);
769
791
  });
770
792
 
771
793
  test("concurrent demotions of the last two SystemAdmins: one wins, one gets 409", async () => {
772
- const adminA = await seedUser({
773
- email: "race-admin-a@example.com",
774
- displayName: "Race Admin A",
775
- roles: ["SystemAdmin"],
776
- });
777
- const adminB = await seedUser({
778
- email: "race-admin-b@example.com",
779
- displayName: "Race Admin B",
780
- roles: ["SystemAdmin"],
781
- });
782
-
783
- const loadedA = await stack.http.queryOk<Record<string, unknown>>(
784
- UserQueries.detail,
785
- { id: adminA.id },
786
- systemAdmin,
787
- );
788
- const loadedB = await stack.http.queryOk<Record<string, unknown>>(
789
- UserQueries.detail,
790
- { id: adminB.id },
791
- systemAdmin,
792
- );
793
-
794
- const [resA, resB] = await Promise.all([
795
- stack.http.write(
796
- UserHandlers.update,
797
- {
798
- id: adminA.id,
799
- version: loadedA["version"],
800
- changes: { roles: ["User"] },
801
- },
794
+ // Repeated across fresh admin pairs: a single trial is a flaky regression
795
+ // guard — measured ~25% of runs still land on 1 success/1×409 by pure
796
+ // request-interleaving luck even with acquireNamespacedAdvisoryLock
797
+ // removed from applyUserRolesUpdate. 10 independent trials push the
798
+ // chance of missing a reintroduced race down to roughly 0.25^10.
799
+ async function raceTwoAdminDemotions(trial: number): Promise<void> {
800
+ // beforeEach only resets the table once per test, not per loop
801
+ // iteration — without this, a demoted-loser leftover from a prior
802
+ // trial would count as an "other active SystemAdmin" and mask the race.
803
+ await resetTestTables(stack.db, [userTable]);
804
+ const adminA = await seedUser({
805
+ email: `race-admin-a-${trial}@example.com`,
806
+ displayName: "Race Admin A",
807
+ roles: ["SystemAdmin"],
808
+ });
809
+ const adminB = await seedUser({
810
+ email: `race-admin-b-${trial}@example.com`,
811
+ displayName: "Race Admin B",
812
+ roles: ["SystemAdmin"],
813
+ });
814
+
815
+ const loadedA = await stack.http.queryOk<Record<string, unknown>>(
816
+ UserQueries.detail,
817
+ { id: adminA.id },
802
818
  systemAdmin,
803
- ),
804
- stack.http.write(
805
- UserHandlers.update,
806
- {
807
- id: adminB.id,
808
- version: loadedB["version"],
809
- changes: { roles: ["User"] },
810
- },
819
+ );
820
+ const loadedB = await stack.http.queryOk<Record<string, unknown>>(
821
+ UserQueries.detail,
822
+ { id: adminB.id },
811
823
  systemAdmin,
812
- ),
813
- ]);
814
-
815
- const bodyA = (await resA.json()) as {
816
- isSuccess: boolean;
817
- error?: { code?: string; details?: { reason?: string } };
818
- };
819
- const bodyB = (await resB.json()) as {
820
- isSuccess: boolean;
821
- error?: { code?: string; details?: { reason?: string } };
822
- };
823
-
824
- const outcomes = [
825
- { status: resA.status, body: bodyA },
826
- { status: resB.status, body: bodyB },
827
- ];
828
- const successes = outcomes.filter((o) => o.body.isSuccess === true);
829
- const failures = outcomes.filter((o) => o.body.isSuccess === false);
830
-
831
- expect(successes).toHaveLength(1);
832
- expect(failures).toHaveLength(1);
833
- const loser = failures[0];
834
- if (!loser) throw new Error("expected exactly one failing demotion");
835
- expect(loser.status).toBe(409);
836
- expect(loser.body.error?.code).toBe("conflict");
837
- expect(loser.body.error?.details?.reason).toBe(UserErrors.cannotDemoteLastSystemAdmin);
838
-
839
- // Exactly one active SystemAdmin must remain among the two race targets.
840
- const stillAdmin = await Promise.all(
841
- [adminA.id, adminB.id].map(async (id) => {
842
- const row = await stack.http.queryOk<Record<string, unknown>>(
843
- UserQueries.detail,
844
- { id },
824
+ );
825
+
826
+ const [resA, resB] = await Promise.all([
827
+ stack.http.write(
828
+ UserHandlers.update,
829
+ {
830
+ id: adminA.id,
831
+ version: loadedA["version"],
832
+ changes: { roles: ["User"] },
833
+ },
845
834
  systemAdmin,
846
- );
847
- return parseRoles(row["roles"]).includes("SystemAdmin");
848
- }),
849
- );
850
- expect(stillAdmin.filter(Boolean)).toHaveLength(1);
835
+ ),
836
+ stack.http.write(
837
+ UserHandlers.update,
838
+ {
839
+ id: adminB.id,
840
+ version: loadedB["version"],
841
+ changes: { roles: ["User"] },
842
+ },
843
+ systemAdmin,
844
+ ),
845
+ ]);
846
+
847
+ const bodyA = (await resA.json()) as {
848
+ isSuccess: boolean;
849
+ error?: { code?: string; details?: { reason?: string } };
850
+ };
851
+ const bodyB = (await resB.json()) as {
852
+ isSuccess: boolean;
853
+ error?: { code?: string; details?: { reason?: string } };
854
+ };
855
+
856
+ const outcomes = [
857
+ { status: resA.status, body: bodyA },
858
+ { status: resB.status, body: bodyB },
859
+ ];
860
+ const successes = outcomes.filter((o) => o.body.isSuccess === true);
861
+ const failures = outcomes.filter((o) => o.body.isSuccess === false);
862
+
863
+ expect(successes).toHaveLength(1);
864
+ expect(failures).toHaveLength(1);
865
+ const loser = failures[0];
866
+ if (!loser) throw new Error("expected exactly one failing demotion");
867
+ expect(loser.status).toBe(409);
868
+ expect(loser.body.error?.code).toBe("conflict");
869
+ expect(loser.body.error?.details?.reason).toBe(UserErrors.cannotDemoteLastSystemAdmin);
870
+
871
+ // Exactly one active SystemAdmin must remain among the two race targets.
872
+ const stillAdmin = await Promise.all(
873
+ [adminA.id, adminB.id].map(async (id) => {
874
+ const row = await stack.http.queryOk<Record<string, unknown>>(
875
+ UserQueries.detail,
876
+ { id },
877
+ systemAdmin,
878
+ );
879
+ return parseRoles(row["roles"]).includes("SystemAdmin");
880
+ }),
881
+ );
882
+ expect(stillAdmin.filter(Boolean)).toHaveLength(1);
883
+ }
884
+
885
+ const trials = 10;
886
+ for (let trial = 0; trial < trials; trial++) {
887
+ await raceTwoAdminDemotions(trial);
888
+ }
851
889
  });
852
890
 
853
891
  test("soft-deleted SystemAdmin does not count toward last-admin protection", async () => {
@@ -11,10 +11,12 @@ import {
11
11
  type WriteErrorInfo,
12
12
  writeFailure,
13
13
  } from "@cosmicdrift/kumiko-framework/errors";
14
+ import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
14
15
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
15
16
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
16
17
  import { z } from "zod";
17
18
  import { UserErrors } from "../constants";
19
+ import { rolesInputSchema } from "../roles-input-schema";
18
20
  import { userEntity, userTable } from "../schema/user";
19
21
 
20
22
  const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
@@ -37,12 +39,11 @@ export const createWrite = defineWriteHandler({
37
39
  email: z.email(),
38
40
  passwordHash: z.string().optional(),
39
41
  displayName: z.string().min(1).max(100),
40
- locale: z.string().min(2).max(10).optional(),
42
+ locale: z.string().min(2).max(10).refine(isValidLocaleTag, "invalid locale tag").optional(),
41
43
  timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
42
- // Global roles — multiSelect jsonb string[]. Optional (entity default []).
43
44
  // Field-level write access (privileged) is defense-in-depth — create is
44
45
  // already system/SystemAdmin-only.
45
- roles: z.union([z.string(), z.array(z.string())]).optional(),
46
+ roles: rolesInputSchema.optional(),
46
47
  }),
47
48
  access: { roles: ["system", "SystemAdmin"] },
48
49
  handler: async (event, ctx) => {
@@ -70,7 +71,7 @@ export const createWrite = defineWriteHandler({
70
71
  let createPayload = event.payload;
71
72
  if (event.payload.roles !== undefined) {
72
73
  const newRoles = parseRoles(event.payload.roles);
73
- const forbidden = findForbiddenRoleAssignment(event.user.roles, newRoles);
74
+ const forbidden = findForbiddenRoleAssignment(event.user.roles, newRoles, []);
74
75
  if (forbidden !== undefined) {
75
76
  return writeFailure(
76
77
  new AccessDeniedError({
@@ -1,9 +1,31 @@
1
- import { access, defineEntityDetailHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ access,
3
+ defineEntityDetailHandler,
4
+ type HandlerContext,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import { userEntity } from "../schema/user";
7
+ import { attachTenantLabels, dbForList } from "./list.query";
3
8
 
4
9
  // Only SystemAdmins can read arbitrary users. Tenant-level "Admin" does NOT
5
10
  // grant this — the user feature is tenant-agnostic, and an Admin's scope is
6
11
  // bound to their own tenant's memberships (served by the tenant feature).
7
- export const detailQuery = defineEntityDetailHandler("user", userEntity, {
12
+ const baseDetail = defineEntityDetailHandler("user", userEntity, {
8
13
  access: { roles: access.systemAdmin },
9
14
  });
15
+
16
+ // Same tenants enrichment as user:list — derived field placeholder is "".
17
+ export const detailQuery = {
18
+ ...baseDetail,
19
+ handler: async (
20
+ query: Parameters<NonNullable<typeof baseDetail.handler>>[0],
21
+ ctx: HandlerContext,
22
+ ) => {
23
+ const result = await baseDetail.handler?.(query, ctx);
24
+ if (result === null || typeof result !== "object") return result;
25
+ const [enriched] = await attachTenantLabels(
26
+ [result as Record<string, unknown>],
27
+ dbForList(ctx),
28
+ );
29
+ return enriched ?? result;
30
+ },
31
+ };
@@ -6,6 +6,7 @@ import {
6
6
  type HandlerContext,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
+ import type { QueryHandlerDef } from "@cosmicdrift/kumiko-types/handlers";
9
10
  // kumiko-lint-ignore cross-feature-import SystemAdmin user-list joins memberships for tenants column
10
11
  import { tenantMembershipsTable, tenantTable } from "../../tenant";
11
12
  import { userEntity } from "../schema/user";
@@ -17,23 +18,16 @@ const baseList = defineEntityListHandler("user", userEntity, {
17
18
  type MembershipRow = { userId: unknown; tenantId: unknown; roles?: unknown };
18
19
  type TenantRow = { id: unknown; name?: unknown; key?: unknown };
19
20
 
20
- function isMissingRelation(err: unknown): boolean {
21
- const msg = err instanceof Error ? err.message : String(err);
22
- return msg.includes("does not exist") || msg.includes("42P01");
23
- }
24
-
25
21
  async function loadMemberships(
26
22
  db: TenantDb,
27
23
  userIds: readonly string[],
28
- ): Promise<readonly MembershipRow[] | null> {
29
- try {
30
- return await selectMany<MembershipRow>(db, tenantMembershipsTable, {
31
- userId: { in: [...userIds] },
32
- });
33
- } catch (err) {
34
- if (isMissingRelation(err)) return null;
35
- throw err;
36
- }
24
+ ): Promise<readonly MembershipRow[]> {
25
+ // Membership tables ship with the tenant feature; user:list is SystemAdmin
26
+ // roster code that always mounts both. Do not swallow missing-relation
27
+ // errors here — that hid real schema bugs behind a permanent "—" column.
28
+ return await selectMany<MembershipRow>(db, tenantMembershipsTable, {
29
+ userId: { in: [...userIds] },
30
+ });
37
31
  }
38
32
 
39
33
  function tenantLabelById(tenants: readonly TenantRow[]): Map<string, string> {
@@ -70,7 +64,7 @@ function labelsByUserId(
70
64
  return map;
71
65
  }
72
66
 
73
- async function attachTenantLabels(
67
+ export async function attachTenantLabels(
74
68
  rows: readonly Record<string, unknown>[],
75
69
  db: TenantDb,
76
70
  ): Promise<Record<string, unknown>[]> {
@@ -79,9 +73,6 @@ async function attachTenantLabels(
79
73
  if (userIds.length === 0) return [...rows];
80
74
 
81
75
  const memberships = await loadMemberships(db, userIds);
82
- if (memberships === null) {
83
- return rows.map((row) => ({ ...row, tenants: "—" }));
84
- }
85
76
 
86
77
  const tenantIds = [
87
78
  ...new Set(memberships.map((m) => String(m.tenantId ?? "")).filter((id) => id !== "")),
@@ -93,11 +84,12 @@ async function attachTenantLabels(
93
84
  const byUser = labelsByUserId(memberships, tenantLabelById(tenants));
94
85
  return rows.map((row) => {
95
86
  const labels = byUser.get(String(row["id"] ?? "")) ?? [];
96
- return { ...row, tenants: labels.length > 0 ? labels.join(", ") : "—" };
87
+ const sorted = [...labels].sort((a, b) => a.localeCompare(b));
88
+ return { ...row, tenants: sorted.length > 0 ? sorted.join(", ") : "" };
97
89
  });
98
90
  }
99
91
 
100
- function dbForList(ctx: HandlerContext): TenantDb {
92
+ export function dbForList(ctx: HandlerContext): TenantDb {
101
93
  if (!ctx.systemDb) {
102
94
  throw new Error("user:list requires r.systemScope() / ctx.systemDb");
103
95
  }
@@ -110,13 +102,14 @@ function dbForList(ctx: HandlerContext): TenantDb {
110
102
  // members via the tenant feature (which scopes by membership, not globally).
111
103
  // Wraps the entity-convention list so each row carries a `tenants` label
112
104
  // (derived field) — without it the platform roster is unusable for ops.
113
- export const listQuery = {
105
+ export const listQuery: QueryHandlerDef = {
114
106
  ...baseList,
115
- handler: async (
116
- query: Parameters<NonNullable<typeof baseList.handler>>[0],
117
- ctx: HandlerContext,
118
- ) => {
119
- const result = await baseList.handler?.(query, ctx);
107
+ handler: async (query, ctx) => {
108
+ const baseHandler = baseList.handler;
109
+ if (baseHandler === undefined) {
110
+ throw new Error("user:list base handler missing");
111
+ }
112
+ const result = await baseHandler(query, ctx);
120
113
  if (result === null || typeof result !== "object" || !("rows" in result)) {
121
114
  return result;
122
115
  }
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  AccessDeniedError,
17
17
  ConflictError,
18
+ InternalError,
18
19
  NotFoundError,
19
20
  writeFailure,
20
21
  } from "@cosmicdrift/kumiko-framework/errors";
@@ -32,7 +33,8 @@ const LAST_SYSTEM_ADMIN_LOCK_NAMESPACE = 0x7361646d; // 'sadm'
32
33
 
33
34
  type UserRolesRow = {
34
35
  id: string;
35
- roles: string | null;
36
+ // jsonb string[] at rest; legacy text rows + parseRoles(unknown) still accepted
37
+ roles: readonly string[] | string | null;
36
38
  status: string | null;
37
39
  isDeleted?: boolean;
38
40
  };
@@ -51,7 +53,10 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
51
53
  }
52
54
 
53
55
  async function countOtherActiveSystemAdmins(db: TenantDb, excludeUserId: string): Promise<number> {
54
- const tableName = asEntityTableMeta(userTable)?.tableName ?? "read_users";
56
+ const tableName = asEntityTableMeta(userTable)?.tableName;
57
+ if (!tableName) {
58
+ throw new InternalError({ message: "user read table meta missing" });
59
+ }
55
60
  // kumiko-lint-ignore raw-sql jsonb @> prefilter for SystemAdmin roster under advisory lock
56
61
  const rows = (await asRawClient(db.raw).unsafe(
57
62
  `SELECT id, roles, status, is_deleted AS "isDeleted"
@@ -145,9 +150,10 @@ export async function applyUserRolesUpdate(
145
150
  };
146
151
 
147
152
  // Persist roles first. Session revoke runs only after a successful update so a
148
- // version conflict / projection error cannot leave sessions revoked while roles
149
- // stay unchanged. Dispatcher TX still wraps both: revoke shares the write tx
150
- // and rolls back with a failed outer write.
153
+ // version conflict / permission error cannot leave sessions revoked while roles
154
+ // stay unchanged. Nested writeAs returns {isSuccess:false} instead of throwing —
155
+ // we must propagate that failure so the dispatcher TX rolls the role update
156
+ // back with the revoke (writeAs alone does not abort the outer handler).
151
157
  const updateResult = await crud.update(
152
158
  { ...event.payload, changes: normalizedChanges },
153
159
  event.user,
@@ -159,13 +165,24 @@ export async function applyUserRolesUpdate(
159
165
 
160
166
  const revoker = ctx.registry.getWriteHandler(REVOKE_ALL_SESSIONS_QN);
161
167
  if (revoker) {
162
- await ctx.writeAs(
168
+ const revokeResult = await ctx.writeAs(
163
169
  createSystemUser(event.user.tenantId ?? SYSTEM_TENANT_ID),
164
170
  REVOKE_ALL_SESSIONS_QN,
165
171
  {
166
172
  userId: event.payload.id,
167
173
  },
168
174
  );
175
+ if (!revokeResult.isSuccess) return revokeResult;
176
+ } else {
177
+ // skip: sessions feature not mounted — no live JWTs to revoke; role update
178
+ // still commits. Apps that mount user without sessions accept stale claims
179
+ // until JWT TTL.
180
+ ctx.log?.warn(
181
+ "user:update-roles: sessions revoke-all handler missing; role change commits without session invalidation",
182
+ {
183
+ userId: event.payload.id,
184
+ },
185
+ );
169
186
  }
170
187
 
171
188
  return updateResult;
@@ -5,9 +5,11 @@ import {
5
5
  InternalError,
6
6
  writeFailure,
7
7
  } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
8
9
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
9
10
  import { z } from "zod";
10
11
  import { UserErrors } from "../constants";
12
+ import { rolesInputSchema } from "../roles-input-schema";
11
13
  import { userEntity, userTable } from "../schema/user";
12
14
  import { applyUserRolesUpdate } from "./update-roles";
13
15
 
@@ -27,16 +29,15 @@ export const updateWrite = defineWriteHandler({
27
29
  version: z.number(),
28
30
  changes: z.object({
29
31
  displayName: z.string().min(1).max(100).optional(),
30
- locale: z.string().min(2).max(10).optional(),
32
+ locale: z.string().min(2).max(10).refine(isValidLocaleTag, "invalid locale tag").optional(),
31
33
  timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
32
34
  email: z.email().optional(),
33
35
  passwordHash: z.string().optional(),
34
36
  lastActiveTenantId: z.string().optional(),
35
37
  emailVerified: z.boolean().optional(),
36
- // Global roles — JSON-encoded string[] or string[]. Field-level write
37
- // access is privileged (see userEntity.roles); handler also requires a
38
- // privileged actor and runs the elevation guard.
39
- roles: z.union([z.string(), z.array(z.string())]).optional(),
38
+ // Field-level write access is privileged (see userEntity.roles); handler
39
+ // also requires a privileged actor and runs the elevation guard.
40
+ roles: rolesInputSchema.optional(),
40
41
  }),
41
42
  }),
42
43
  access: { openToAll: true },
package/src/user/i18n.ts CHANGED
@@ -7,12 +7,6 @@ export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
7
7
  "user:entity:user:field:displayName": { en: "Display name" },
8
8
  "user:entity:user:field:roles": { en: "Platform roles" },
9
9
  "user:entity:user:field:tenants": { en: "Tenants" },
10
- "user:entity:user:field:tenants:description": {
11
- en: "Membership tenants with roles, e.g. Offlot Demo (TenantAdmin). Edit under Team → Members.",
12
- },
13
- "user:entity:user:field:roles:description": {
14
- en: "Global platform roles only (SystemAdmin). TenantAdmin/Admin are membership roles under Team → Members.",
15
- },
16
10
  "user:entity:user:field:roles:option:SystemAdmin": { en: "SystemAdmin" },
17
11
  "user:entity:user:field:status": { en: "Status" },
18
12
  "user:entity:user:field:emailVerified": { en: "Email verified" },
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+
3
+ /** Accept string[] or a JSON-encoded string[]; reject bare non-JSON strings. */
4
+ export const rolesInputSchema = z.union([
5
+ z.array(z.string()),
6
+ // Legacy wire form: JSON-encoded string[]. Bare "SystemAdmin" used to
7
+ // parseRoles → [] and silently wipe/clear roles — reject that at the boundary.
8
+ z.string().transform((raw, ctx) => {
9
+ try {
10
+ const parsed: unknown = JSON.parse(raw);
11
+ if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string")) {
12
+ return parsed as string[];
13
+ }
14
+ } catch {
15
+ /* fall through */
16
+ }
17
+ ctx.addIssue({ code: "custom", message: "roles must be a string[] or JSON string array" });
18
+ return z.NEVER;
19
+ }),
20
+ ]);