@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.
- package/package.json +9 -9
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +2 -1
- package/src/audit/__tests__/audit-log-screen.test.tsx +81 -7
- package/src/audit/__tests__/constants.test.ts +8 -0
- package/src/audit/web/audit-log-detail-screen.tsx +30 -14
- package/src/audit/web/audit-log-screen.tsx +21 -7
- package/src/auth-email-password/__tests__/auth.integration.test.ts +29 -0
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +83 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +36 -0
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +89 -1
- package/src/auth-email-password/__tests__/magic-link-mail.test.ts +38 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +2 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +1 -5
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +10 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +8 -9
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +3 -11
- package/src/auth-email-password/handlers/token-request-handler.ts +2 -10
- package/src/auth-email-password/i18n.ts +1 -0
- package/src/auth-email-password/magic-link-mail.ts +17 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +1 -1
- package/src/auth-email-password/web/auth-client.ts +1 -7
- package/src/auth-email-password/web/signup-complete-screen.tsx +11 -11
- package/src/config/db/queries/__tests__/resolver.test.ts +18 -10
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +165 -0
- package/src/custom-fields/__tests__/feature.test.ts +10 -12
- package/src/custom-fields/constants.ts +4 -3
- package/src/custom-fields/feature.ts +7 -8
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +28 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +92 -1
- package/src/delivery/db/queries/preferences.ts +3 -2
- package/src/delivery/handlers/log.query.ts +1 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +2 -2
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +11 -3
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +3 -2
- package/src/feature-toggles/feature.ts +24 -4
- package/src/feature-toggles/handlers/registered.query.ts +27 -16
- package/src/feature-toggles/i18n.ts +5 -0
- package/src/feature-toggles/web/client-plugin.tsx +1 -5
- package/src/feature-toggles/web/index.ts +0 -1
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +24 -35
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +2 -2
- package/src/file-derivatives/feature.ts +7 -6
- package/src/file-provider-s3/feature.ts +1 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +7 -8
- package/src/form-draft/handlers/cleanup.job.ts +31 -20
- package/src/form-draft/handlers/discard.write.ts +8 -8
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +60 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +12 -0
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +158 -20
- package/src/jobs/feature.ts +7 -2
- package/src/jobs/handlers/detail.query.ts +22 -11
- package/src/jobs/handlers/list.query.ts +17 -6
- package/src/jobs/job-run-logger.ts +78 -36
- package/src/jobs/web/job-run-detail-screen.tsx +4 -4
- package/src/mail-transport-smtp/feature.ts +1 -2
- package/src/notes-history/__tests__/notes-history.integration.test.ts +2 -1
- package/src/notes-history/__tests__/notes-section.test.tsx +24 -1
- package/src/notes-history/handlers/add-note.write.ts +7 -3
- package/src/notes-history/web/notes-section.tsx +10 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +18 -0
- package/src/secrets/__tests__/access-options.integration.test.ts +12 -2
- package/src/secrets/feature.ts +9 -2
- package/src/secrets/handlers/delete.write.ts +0 -4
- package/src/secrets/handlers/list.query.ts +0 -4
- package/src/secrets/handlers/set.write.ts +0 -4
- package/src/secrets/index.ts +3 -3
- package/src/shared/index.ts +1 -0
- package/src/shared/map-with-concurrency.test.ts +2 -2
- package/src/shared/session-field.ts +10 -0
- package/src/shared/session-locale-field.test.ts +9 -0
- package/src/shared/session-locale-field.ts +4 -1
- package/src/shared/session-timezone-field.ts +2 -1
- package/src/subscription-stripe/feature.ts +5 -11
- package/src/tags/__tests__/feature.test.ts +18 -5
- package/src/tags/__tests__/tag-screens.boot.test.ts +51 -0
- package/src/tags/constants.ts +3 -2
- package/src/tags/entity.ts +2 -0
- package/src/tags/feature.ts +20 -11
- package/src/tags/handlers/create-tag.write.ts +5 -2
- package/src/tags/handlers/delete-tag.write.ts +5 -2
- package/src/tags/handlers/update-tag.write.ts +5 -2
- package/src/tags/i18n.ts +4 -0
- package/src/tags/index.ts +2 -0
- package/src/tags/screens.ts +66 -0
- package/src/tags/web/client-plugin.tsx +1 -6
- package/src/tags/web/index.ts +1 -0
- package/src/template-resolver/__tests__/seed-legal-content.integration.test.ts +64 -1
- package/src/template-resolver/seeding.ts +78 -6
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
- package/src/template-resolver/web/client-plugin.tsx +2 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +38 -6
- package/src/tenant/__tests__/members-screens.boot.test.ts +6 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +106 -40
- package/src/tenant/handlers/members.query.ts +6 -1
- package/src/tenant/handlers/team-list.query.ts +3 -1
- package/src/tenant/handlers/update-member-roles.write.ts +16 -2
- package/src/tenant/i18n.ts +2 -3
- package/src/tenant/screens.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +54 -36
- package/src/tenant-settings/feature.ts +6 -30
- package/src/tier-engine/web/tier-admin-screen.tsx +3 -2
- package/src/user/__tests__/roles-input-schema.test.ts +20 -0
- package/src/user/__tests__/user.integration.test.ts +156 -118
- package/src/user/handlers/create.write.ts +5 -4
- package/src/user/handlers/detail.query.ts +24 -2
- package/src/user/handlers/list.query.ts +19 -26
- package/src/user/handlers/update-roles.ts +23 -6
- package/src/user/handlers/update.write.ts +6 -5
- package/src/user/i18n.ts +0 -6
- package/src/user/roles-input-schema.ts +20 -0
- package/src/user/schema/user.ts +2 -3
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +45 -7
- package/src/user-data-rights/run-forget-cleanup.ts +34 -24
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +23 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +1 -1
- package/src/user-profile/web/profile-screen.tsx +1 -1
- 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("
|
|
733
|
-
//
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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.
|
|
805
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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[]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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 /
|
|
149
|
-
// stay unchanged.
|
|
150
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
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
|
+
]);
|