@cosmicdrift/kumiko-bundled-features 0.295.0 → 0.296.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.295.0",
3
+ "version": "0.296.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>",
@@ -133,12 +133,12 @@
133
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
134
  },
135
135
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.295.0",
137
- "@cosmicdrift/kumiko-framework": "0.295.0",
138
- "@cosmicdrift/kumiko-headless": "0.295.0",
139
- "@cosmicdrift/kumiko-renderer": "0.295.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.295.0",
141
- "@cosmicdrift/kumiko-types": "0.295.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.296.0",
137
+ "@cosmicdrift/kumiko-framework": "0.296.0",
138
+ "@cosmicdrift/kumiko-headless": "0.296.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.296.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.296.0",
141
+ "@cosmicdrift/kumiko-types": "0.296.0",
142
142
  "@mollie/api-client": "^4.5.0",
143
143
  "@node-rs/argon2": "^2.0.2",
144
144
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.295.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.295.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.296.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.296.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -27,9 +27,10 @@ describe("audit log screen + handler access alignment", () => {
27
27
  });
28
28
 
29
29
  test("the user:user actor reference makes `user` a hard boot dependency (fw#3103)", () => {
30
+ // fw#3108's refEntity check now catches the missing "user" feature before the "requires" check does.
30
31
  expect(() =>
31
32
  validateBoot([createConfigFeature(), createTenantFeature(), createAuditFeature()]),
32
- ).toThrow(/requires feature "user"/);
33
+ ).toThrow(/refEntity\) targets entity "user:user".*Known entities in feature "user": \(none\)/);
33
34
  });
34
35
 
35
36
  test("audit-log screen is declarative, access.admin-gated", () => {
@@ -59,8 +59,6 @@ export function createAuditFeature(): FeatureDefinition {
59
59
  {
60
60
  field: "createdBy",
61
61
  label: i18nKey("audit.log.col.actor"),
62
- // Resolves only for SystemAdmin until fw#3107: the lookup QN this
63
- // derives, user:query:user:list, is SystemAdmin-only.
64
62
  refEntity: "user:user",
65
63
  refLabelField: "displayName",
66
64
  },
@@ -11,6 +11,8 @@ import {
11
11
  testTenantId,
12
12
  unsafePushTables,
13
13
  } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { createConfigFeature } from "../../config/feature";
15
+ import { createTenantFeature } from "../../tenant/feature";
14
16
  import { DeliveryQueries } from "../constants";
15
17
  import { createDeliveryFeature } from "../feature";
16
18
  import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
@@ -31,7 +33,9 @@ const admin = createTestUser({ id: 701, roles: ["TenantAdmin"], tenantId });
31
33
  const systemAdmin = createTestUser({ id: 709, roles: ["SystemAdmin"], tenantId });
32
34
 
33
35
  beforeAll(async () => {
34
- stack = await setupTestStack({ features: [createDeliveryFeature()] });
36
+ stack = await setupTestStack({
37
+ features: [createConfigFeature(), createTenantFeature(), createDeliveryFeature()],
38
+ });
35
39
  await unsafePushTables(stack.db, { deliveryAttemptsTable, notificationPreferencesTable });
36
40
  await insertMany(stack.db, deliveryAttemptsTable, [
37
41
  {
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { access, isOpenToAllGranted, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
+ import { createConfigFeature } from "../../config/feature";
5
+ import { createTenantFeature } from "../../tenant/feature";
4
6
  import { DELIVERY_LOG_SCREEN_ID } from "../constants";
5
7
  import { createDeliveryFeature } from "../feature";
6
8
 
7
9
  describe("delivery screens + handler access alignment", () => {
8
- const features = [createDeliveryFeature()];
10
+ const features = [createConfigFeature(), createTenantFeature(), createDeliveryFeature()];
9
11
 
10
12
  test("boot-validates with delivery-log screen registered", () => {
11
13
  expect(() => validateBoot(features)).not.toThrow();
@@ -38,7 +40,11 @@ describe("delivery screens + handler access alignment", () => {
38
40
 
39
41
  test("boot-validates with a narrowed access option", () => {
40
42
  expect(() =>
41
- validateBoot([createDeliveryFeature({ access: access.systemAdmin })]),
43
+ validateBoot([
44
+ createConfigFeature(),
45
+ createTenantFeature(),
46
+ createDeliveryFeature({ access: access.systemAdmin }),
47
+ ]),
42
48
  ).not.toThrow();
43
49
  });
44
50
  });
@@ -9,6 +9,8 @@ import {
9
9
  unsafePushTables,
10
10
  } from "@cosmicdrift/kumiko-framework/stack";
11
11
  import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
12
+ import { createConfigFeature } from "../../config/feature";
13
+ import { createTenantFeature } from "../../tenant/feature";
12
14
  import { DELIVERY_LOG_SCREEN_ID, DeliveryHandlers, DeliveryQueries } from "../constants";
13
15
  import { createDeliveryFeature } from "../feature";
14
16
  import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
@@ -17,7 +19,7 @@ let stack: TestStack;
17
19
 
18
20
  beforeAll(async () => {
19
21
  stack = await setupTestStack({
20
- features: [createDeliveryFeature()],
22
+ features: [createConfigFeature(), createTenantFeature(), createDeliveryFeature()],
21
23
  });
22
24
  await unsafePushTables(stack.db, { deliveryAttemptsTable, notificationPreferencesTable });
23
25
  });
@@ -28,7 +30,11 @@ afterAll(async () => {
28
30
 
29
31
  describe("delivery access matrix", () => {
30
32
  test("delivery log handler and screen share access.admin", () => {
31
- const registry = createRegistry([createDeliveryFeature()]);
33
+ const registry = createRegistry([
34
+ createConfigFeature(),
35
+ createTenantFeature(),
36
+ createDeliveryFeature(),
37
+ ]);
32
38
  expect(rolesOf(registry.getQueryHandler(DeliveryQueries.log)?.access)).toEqual([
33
39
  ...access.admin,
34
40
  ]);
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.296.0",
4
+ "type": "breaking",
5
+ "title": "delivery requires tenant",
6
+ "migration": "delivery now declares r.requires(\"tenant\") because its delivery-log screen references tenant:tenant. Stacks that mount delivery must also mount createTenantFeature() (and its config dependency)."
7
+ },
2
8
  {
3
9
  "version": "0.271.0",
4
10
  "type": "improvement",
@@ -47,6 +47,8 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
47
47
  recommended: true,
48
48
  });
49
49
  r.systemScope();
50
+ // delivery-log's tenantId column has refEntity: "tenant:tenant" (boot-checked).
51
+ r.requires("tenant");
50
52
  // Backing table: the (tenant,user,type,channel) uniqueIndex lives only on
51
53
  // the physical table, not on the entity fields, so the generator would
52
54
  // otherwise omit it → duplicate preference rows on concurrent upserts.
@@ -0,0 +1,179 @@
1
+ // tenant:query:member-directory backs every `user:user` reference label
2
+ // (fw#3107). It replaced the SystemAdmin-only user:query:user:list as lookup
3
+ // source, so the invariants that matter: a TenantAdmin reaches it, sees only
4
+ // their own tenant and gets no more than id + display name, while a SystemAdmin
5
+ // keeps the global reach the roster gave them.
6
+
7
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
9
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ setupTestStack,
12
+ type TestStack,
13
+ testTenantId,
14
+ unsafeCreateEntityTable,
15
+ unsafePushTables,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { REFERENCE_LOOKUP_SOURCES } from "@cosmicdrift/kumiko-framework/ui-types";
19
+ import { createConfigFeature } from "../../config";
20
+ import { configValuesTable } from "../../config/table";
21
+ import { createUserFeature } from "../../user/feature";
22
+ import { userEntity, userTable } from "../../user/schema/user";
23
+ import { seedUser } from "../../user/seeding";
24
+ import { TenantQueries } from "../constants";
25
+ import { createTenantFeature } from "../feature";
26
+ import { tenantMembershipsTable } from "../membership-table";
27
+ import { tenantEntity } from "../schema/tenant";
28
+ import { seedTenant, seedTenantMembership } from "../seeding";
29
+
30
+ const ownTenantId = testTenantId(1);
31
+ const foreignTenantId = testTenantId(2);
32
+
33
+ type DirectoryRow = Readonly<Record<string, unknown>>;
34
+
35
+ let stack: TestStack;
36
+
37
+ function tenantAdmin(userId: string): SessionUser {
38
+ return { id: userId, tenantId: ownTenantId, roles: ["TenantAdmin"] };
39
+ }
40
+
41
+ async function seedMember(
42
+ tenantId: string,
43
+ displayName: string,
44
+ roles: readonly string[],
45
+ ): Promise<string> {
46
+ const { id } = await seedUser(stack.db, {
47
+ email: `${displayName.toLowerCase().replace(/\s+/g, "-")}@example.com`,
48
+ displayName,
49
+ emailVerified: true,
50
+ });
51
+ await seedTenantMembership(stack.db, { userId: id, tenantId, roles: [...roles] });
52
+ return id;
53
+ }
54
+
55
+ async function queryDirectory(caller: SessionUser): Promise<readonly DirectoryRow[]> {
56
+ const result = await stack.http.queryOk<{ rows: readonly DirectoryRow[] }>(
57
+ TenantQueries.memberDirectory,
58
+ {},
59
+ caller,
60
+ );
61
+ return result.rows;
62
+ }
63
+
64
+ beforeAll(async () => {
65
+ stack = await setupTestStack({
66
+ features: [createConfigFeature(), createUserFeature(), createTenantFeature()],
67
+ });
68
+ await unsafeCreateEntityTable(stack.db, userEntity);
69
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
70
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
71
+ });
72
+
73
+ beforeEach(async () => {
74
+ await resetTestTables(stack.db, [userTable, tenantMembershipsTable]);
75
+ await seedTenant(stack.db, { id: ownTenantId, key: "directory-own", name: "Own Tenant" });
76
+ await seedTenant(stack.db, {
77
+ id: foreignTenantId,
78
+ key: "directory-foreign",
79
+ name: "Foreign Tenant",
80
+ });
81
+ });
82
+
83
+ afterEach(() => {
84
+ resetPiiSubjectKmsForTests();
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await stack.cleanup();
89
+ });
90
+
91
+ describe("tenant:query:member-directory (fw#3107)", () => {
92
+ test("is the lookup source the renderer uses for user:user references", () => {
93
+ expect(REFERENCE_LOOKUP_SOURCES["user:user"]).toEqual({
94
+ queryQn: TenantQueries.memberDirectory,
95
+ labelKey: "label",
96
+ });
97
+ });
98
+
99
+ test("a TenantAdmin gets their own members as id/label, never a foreign-tenant user", async () => {
100
+ const adminId = await seedMember(ownTenantId, "Own Admin", ["TenantAdmin"]);
101
+ const colleagueId = await seedMember(ownTenantId, "Own Colleague", ["User"]);
102
+ const foreignId = await seedMember(foreignTenantId, "Foreign Person", ["TenantAdmin"]);
103
+
104
+ const rows = await queryDirectory(tenantAdmin(adminId));
105
+
106
+ const byId = new Map(rows.map((row) => [String(row["id"]), row]));
107
+ expect(byId.get(adminId)?.["label"]).toBe("Own Admin");
108
+ expect(byId.get(colleagueId)?.["label"]).toBe("Own Colleague");
109
+ expect(byId.has(foreignId)).toBe(false);
110
+ expect(rows.length).toBe(2);
111
+ });
112
+
113
+ test("rows carry only id and label — no email or roles leave the directory", async () => {
114
+ const adminId = await seedMember(ownTenantId, "Own Admin", ["TenantAdmin"]);
115
+
116
+ const rows = await queryDirectory(tenantAdmin(adminId));
117
+
118
+ expect(rows.map((row) => Object.keys(row).sort())).toEqual([["id", "label"]]);
119
+ });
120
+
121
+ test("a user shared with a foreign tenant is still listed once, under the display name", async () => {
122
+ const adminId = await seedMember(ownTenantId, "Own Admin", ["TenantAdmin"]);
123
+ const sharedId = await seedMember(foreignTenantId, "Shared Person", ["User"]);
124
+ await seedTenantMembership(stack.db, {
125
+ userId: sharedId,
126
+ tenantId: ownTenantId,
127
+ roles: ["User"],
128
+ });
129
+
130
+ const rows = await queryDirectory(tenantAdmin(adminId));
131
+
132
+ expect(rows.filter((row) => row["id"] === sharedId)).toEqual([
133
+ { id: sharedId, label: "Shared Person" },
134
+ ]);
135
+ });
136
+
137
+ test("display names come back decrypted when a PII-subject KMS is active", async () => {
138
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
139
+ const adminId = await seedMember(ownTenantId, "Encrypted Admin", ["TenantAdmin"]);
140
+
141
+ const rows = await queryDirectory(tenantAdmin(adminId));
142
+
143
+ expect(rows).toEqual([{ id: adminId, label: "Encrypted Admin" }]);
144
+ });
145
+
146
+ test("a SystemAdmin without membership in the active tenant still resolves every user, themselves included", async () => {
147
+ const memberId = await seedMember(ownTenantId, "Own Colleague", ["User"]);
148
+ const foreignId = await seedMember(foreignTenantId, "Foreign Person", ["User"]);
149
+ const { id: operatorId } = await seedUser(stack.db, {
150
+ email: "operator@example.com",
151
+ displayName: "Platform Operator",
152
+ emailVerified: true,
153
+ });
154
+
155
+ const rows = await queryDirectory({
156
+ id: operatorId,
157
+ tenantId: ownTenantId,
158
+ roles: ["SystemAdmin"],
159
+ });
160
+
161
+ const labelById = new Map(rows.map((row) => [String(row["id"]), row["label"]]));
162
+ expect(labelById.get(operatorId)).toBe("Platform Operator");
163
+ expect(labelById.get(memberId)).toBe("Own Colleague");
164
+ expect(labelById.get(foreignId)).toBe("Foreign Person");
165
+ });
166
+
167
+ test("a plain member without an admin role is refused", async () => {
168
+ await seedMember(ownTenantId, "Own Admin", ["TenantAdmin"]);
169
+ const memberId = await seedMember(ownTenantId, "Plain Member", ["User"]);
170
+
171
+ const res = await stack.http.query(
172
+ TenantQueries.memberDirectory,
173
+ {},
174
+ { id: memberId, tenantId: ownTenantId, roles: ["User"] },
175
+ );
176
+
177
+ expect(res.status).toBe(403);
178
+ });
179
+ });
@@ -0,0 +1,124 @@
1
+ // tenant:query:tenant-directory backs every `tenant:tenant` reference label
2
+ // (fw#3142). It replaced the SystemAdmin-only tenant:query:tenant:list as
3
+ // lookup source, so the invariants that matter: a TenantAdmin reaches it and
4
+ // sees only their own tenant, while a SystemAdmin keeps the global reach the
5
+ // entity-convention list gave them.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
9
+ import {
10
+ setupTestStack,
11
+ type TestStack,
12
+ testTenantId,
13
+ unsafeCreateEntityTable,
14
+ unsafePushTables,
15
+ } from "@cosmicdrift/kumiko-framework/stack";
16
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
17
+ import { REFERENCE_LOOKUP_SOURCES } from "@cosmicdrift/kumiko-framework/ui-types";
18
+ import { createConfigFeature } from "../../config";
19
+ import { configValuesTable } from "../../config/table";
20
+ import { createUserFeature } from "../../user/feature";
21
+ import { userEntity, userTable } from "../../user/schema/user";
22
+ import { seedUser } from "../../user/seeding";
23
+ import { TenantQueries } from "../constants";
24
+ import { createTenantFeature } from "../feature";
25
+ import { tenantEntity } from "../schema/tenant";
26
+ import { seedTenant } from "../seeding";
27
+
28
+ const ownTenantId = testTenantId(1);
29
+ const foreignTenantId = testTenantId(2);
30
+
31
+ type DirectoryRow = Readonly<Record<string, unknown>>;
32
+
33
+ let stack: TestStack;
34
+
35
+ function tenantAdmin(userId: string): SessionUser {
36
+ return { id: userId, tenantId: ownTenantId, roles: ["TenantAdmin"] };
37
+ }
38
+
39
+ async function queryDirectory(caller: SessionUser): Promise<readonly DirectoryRow[]> {
40
+ const result = await stack.http.queryOk<{ rows: readonly DirectoryRow[] }>(
41
+ TenantQueries.tenantDirectory,
42
+ {},
43
+ caller,
44
+ );
45
+ return result.rows;
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ stack = await setupTestStack({
50
+ features: [createConfigFeature(), createUserFeature(), createTenantFeature()],
51
+ });
52
+ await unsafeCreateEntityTable(stack.db, userEntity);
53
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
54
+ await unsafePushTables(stack.db, { configValuesTable });
55
+ });
56
+
57
+ beforeEach(async () => {
58
+ await resetTestTables(stack.db, [userTable]);
59
+ await seedTenant(stack.db, { id: ownTenantId, key: "directory-own", name: "Own Tenant" });
60
+ await seedTenant(stack.db, {
61
+ id: foreignTenantId,
62
+ key: "directory-foreign",
63
+ name: "Foreign Tenant",
64
+ });
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await stack.cleanup();
69
+ });
70
+
71
+ describe("tenant:query:tenant-directory (fw#3142)", () => {
72
+ test("is the lookup source the renderer uses for tenant:tenant references", () => {
73
+ expect(REFERENCE_LOOKUP_SOURCES["tenant:tenant"]).toEqual({
74
+ queryQn: TenantQueries.tenantDirectory,
75
+ labelKey: "label",
76
+ });
77
+ });
78
+
79
+ test("a TenantAdmin gets only their own tenant as id/label", async () => {
80
+ const { id: adminId } = await seedUser(stack.db, {
81
+ email: "own-admin@example.com",
82
+ displayName: "Own Admin",
83
+ emailVerified: true,
84
+ });
85
+
86
+ const rows = await queryDirectory(tenantAdmin(adminId));
87
+
88
+ expect(rows).toEqual([{ id: ownTenantId, label: "Own Tenant" }]);
89
+ });
90
+
91
+ test("a SystemAdmin without membership in the active tenant still resolves every tenant", async () => {
92
+ const { id: operatorId } = await seedUser(stack.db, {
93
+ email: "operator@example.com",
94
+ displayName: "Platform Operator",
95
+ emailVerified: true,
96
+ });
97
+
98
+ const rows = await queryDirectory({
99
+ id: operatorId,
100
+ tenantId: ownTenantId,
101
+ roles: ["SystemAdmin"],
102
+ });
103
+
104
+ const labelById = new Map(rows.map((row) => [String(row["id"]), row["label"]]));
105
+ expect(labelById.get(ownTenantId)).toBe("Own Tenant");
106
+ expect(labelById.get(foreignTenantId)).toBe("Foreign Tenant");
107
+ });
108
+
109
+ test("a plain member without an admin role is refused", async () => {
110
+ const { id: memberId } = await seedUser(stack.db, {
111
+ email: "plain-member@example.com",
112
+ displayName: "Plain Member",
113
+ emailVerified: true,
114
+ });
115
+
116
+ const res = await stack.http.query(
117
+ TenantQueries.tenantDirectory,
118
+ {},
119
+ { id: memberId, tenantId: ownTenantId, roles: ["User"] },
120
+ );
121
+
122
+ expect(res.status).toBe(403);
123
+ });
124
+ });
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.296.0",
4
+ "type": "improvement",
5
+ "title": "Tenant reference columns show display names for TenantAdmins (fw#3142)",
6
+ "detail": "Every `tenant:tenant` reference (the delivery-log tenantId column, `LIST_ROW_META_REFERENCES.tenantId`, and any screen that references a tenant) resolved its label through the entity-convention query `tenant:query:tenant:list`. That handler is a SystemAdmin-only entity-list handler, so a TenantAdmin got a 403 and every cell fell back to the raw UUID.\nThe entity-convention handler stays SystemAdmin-only. Instead the new `tenant:query:tenant-directory` (`access.admin`) returns just `{ id, label }` pairs: an admin gets their own tenant, a SystemAdmin keeps the global reach of `tenant:query:tenant:list`. The query is not exposed to agents. The central `REFERENCE_LOOKUP_SOURCES` map in `@cosmicdrift/kumiko-framework/ui-types` now routes `tenant:tenant` lookups there, the same way it already routes `user:user` lookups to the member directory (fw#3107).",
7
+ "migration": "No code change needed. A `tenant:tenant` reference picker or column in an edit form or list now shows a non-SystemAdmin their own tenant's name instead of failing with a 403 and falling back to the raw id. Set `optionsQuery` on the reference field if a screen needs a different source."
8
+ },
9
+ {
10
+ "version": "0.296.0",
11
+ "type": "improvement",
12
+ "title": "User reference columns show display names for TenantAdmins (fw#3107)",
13
+ "detail": "Every `user:user` reference (the audit-log actor, the sessions user column, their detail screens) resolved its label through the entity-convention query `user:query:user:list`. That handler is the SystemAdmin cross-tenant user roster, so a TenantAdmin got a 403 and every cell fell back to the raw UUID — in practice every tenant admin reading their own audit log.\nThe roster stays SystemAdmin-only: its `tenants` column joins every membership of a user and would expose foreign tenant names. Instead the new `tenant:query:member-directory` (`access.admin`) returns just `{ id, label }` pairs — the display name, no email, no roles. An admin gets their own tenant's members; a SystemAdmin keeps the global reach the roster gave them, so operator actions in tenants they are no member of still resolve. The query is not exposed to agents. The new central `REFERENCE_LOOKUP_SOURCES` map in `@cosmicdrift/kumiko-framework/ui-types` routes `user:user` lookups there, the same way `SYSTEM_REFERENCE_LABELS` already applies per referenced entity. The list lookup, the read-only detail value, the reference combobox and embedded-list reference cells all consult it; a reference field's own `optionsQuery` still wins.",
14
+ "migration": "No code change needed. Two behavior shifts to be aware of: for a non-SystemAdmin, a `user:user` reference picker in an edit form now offers the active tenant's members (it used to fail with a 403 and stay empty) — set `optionsQuery` on the reference field if a screen needs a different source. And a `user:user` label now only resolves when the `tenant` feature is mounted. In an app that mounts `sessions` without `tenant`, a SystemAdmin — who used to see names there — now sees the raw id, the same fallback a TenantAdmin got before; mount `createTenantFeature()` to get names back."
15
+ },
2
16
  {
3
17
  "version": "0.271.0",
4
18
  "type": "improvement",
@@ -15,31 +29,31 @@
15
29
  "version": "0.209.0",
16
30
  "type": "improvement",
17
31
  "title": "tenant: single /members screen replaces the members-card/pending-card split (#2223)",
18
- "detail": "New `tenant:query:team:list` handler merges active memberships and pending invitations into one projectionList screen (`membersScreen`), with a `status` facet, sortable/searchable columns, and a danger-styled `cancel-invitation` row action visible only on pending rows. `createTenantFeature({ inviteScreen: true })` additionally wires a drawer-hosted invite actionForm onto the toolbar (bound to auth-email-password's `invite-create` handler) \u2014 off by default, and only safe to enable once the app also configures `createAuthEmailPasswordFeature({ invite: {...} })`, since the boot validator rejects the actionForm's cross-feature handler QN otherwise. The old `members`/`invitations` query handlers and their custom `MembersScreen` component are unaffected and stay registered for existing callers; `updateMemberRoles` stays SystemAdmin-only and off this screen."
32
+ "detail": "New `tenant:query:team:list` handler merges active memberships and pending invitations into one projectionList screen (`membersScreen`), with a `status` facet, sortable/searchable columns, and a danger-styled `cancel-invitation` row action visible only on pending rows. `createTenantFeature({ inviteScreen: true })` additionally wires a drawer-hosted invite actionForm onto the toolbar (bound to auth-email-password's `invite-create` handler) — off by default, and only safe to enable once the app also configures `createAuthEmailPasswordFeature({ invite: {...} })`, since the boot validator rejects the actionForm's cross-feature handler QN otherwise. The old `members`/`invitations` query handlers and their custom `MembersScreen` component are unaffected and stay registered for existing callers; `updateMemberRoles` stays SystemAdmin-only and off this screen."
19
33
  },
20
34
  {
21
35
  "version": "0.165.0",
22
36
  "type": "fix",
23
37
  "title": "resolveRawDb: removed redundant null check",
24
- "detail": "CodeQL flagged db !== null after if (!db) \u2014 truthiness already excludes null. Internal cleanup, no API break."
38
+ "detail": "CodeQL flagged db !== null after if (!db) — truthiness already excludes null. Internal cleanup, no API break."
25
39
  },
26
40
  {
27
41
  "version": "0.165.0",
28
42
  "type": "fix",
29
43
  "title": "Seed hook: explicit SessionUser typing",
30
- "detail": "Fixed tenant seeding type annotation \u2014 prevents implicit any propagation in seed handlers."
44
+ "detail": "Fixed tenant seeding type annotation — prevents implicit any propagation in seed handlers."
31
45
  },
32
46
  {
33
47
  "version": "0.165.0",
34
48
  "type": "fix",
35
49
  "title": "Seed afterCommit + real tenant rescope",
36
- "detail": "ScheduleAfterCommit now available on HandlerContext \u2014 fireEntityPostSave queues after TX commit instead of mid-transaction. DB/notify/hasFeature/_tenantId are correctly rescoped when targeting an entity's tenant.",
37
- "migration": "Only affects custom seed handlers using fireEntityPostSave \u2014 now afterCommit-compatible."
50
+ "detail": "ScheduleAfterCommit now available on HandlerContext — fireEntityPostSave queues after TX commit instead of mid-transaction. DB/notify/hasFeature/_tenantId are correctly rescoped when targeting an entity's tenant.",
51
+ "migration": "Only affects custom seed handlers using fireEntityPostSave — now afterCommit-compatible."
38
52
  },
39
53
  {
40
54
  "version": "0.163.0",
41
55
  "type": "improvement",
42
56
  "title": "tenant: seedTenant fires entity postSave hooks (#1463).",
43
- "detail": "tenant: seedTenant fires entity postSave hooks (#1463). `seedTenant` writes through the raw event-store executor, which never fired postSave hooks \u2014 self-signup (`provisionSignupAccount` \u2192 `signup-confirm`) silently skipped feature-registered entity hooks like tier-engine's auto-default-tier and app-level auto-default-compliance, leaving new tenants without a tier/compliance row. `seedTenant` and `provisionSignupAccount` take a new optional `hooks: { registry, context }` param; passing it fires the same entity-scoped postSave hooks the dispatcher would, without the full dispatch roundtrip. Existing call-sites are unaffected \u2014 the param is optional and append-only."
57
+ "detail": "tenant: seedTenant fires entity postSave hooks (#1463). `seedTenant` writes through the raw event-store executor, which never fired postSave hooks — self-signup (`provisionSignupAccount` → `signup-confirm`) silently skipped feature-registered entity hooks like tier-engine's auto-default-tier and app-level auto-default-compliance, leaving new tenants without a tier/compliance row. `seedTenant` and `provisionSignupAccount` take a new optional `hooks: { registry, context }` param; passing it fires the same entity-scoped postSave hooks the dispatcher would, without the full dispatch roundtrip. Existing call-sites are unaffected — the param is optional and append-only."
44
58
  }
45
59
  ]
@@ -49,6 +49,12 @@ export const TenantQueries = {
49
49
  list: "tenant:query:list",
50
50
  memberships: "tenant:query:memberships",
51
51
  members: "tenant:query:members",
52
+ // Label source for `user:user` reference columns — see
53
+ // REFERENCE_LOOKUP_SOURCES in the framework's ui-types.
54
+ memberDirectory: "tenant:query:member-directory",
55
+ // Label source for `tenant:tenant` reference columns — see
56
+ // REFERENCE_LOOKUP_SOURCES in the framework's ui-types.
57
+ tenantDirectory: "tenant:query:tenant-directory",
52
58
  activeTenantIds: "tenant:query:active-tenant-ids",
53
59
  resolveUserIds: "tenant:query:resolve-user-ids",
54
60
  // Pending Invitations für den aktuellen Tenant (Admin-UI-Liste).
@@ -16,11 +16,13 @@ import { createWrite } from "./handlers/create.write";
16
16
  import { invitationsQuery } from "./handlers/invitations.query";
17
17
  import { listQuery } from "./handlers/list.query";
18
18
  import { meQuery } from "./handlers/me.query";
19
+ import { memberDirectoryQuery } from "./handlers/member-directory.query";
19
20
  import { membersQuery } from "./handlers/members.query";
20
21
  import { membershipsQuery } from "./handlers/memberships.query";
21
22
  import { removeMemberWrite } from "./handlers/remove-member.write";
22
23
  import { resolveUserIdsQuery } from "./handlers/resolve-user-ids.query";
23
24
  import { teamListQuery } from "./handlers/team-list.query";
25
+ import { tenantDirectoryQuery } from "./handlers/tenant-directory.query";
24
26
  import { disableWrite, enableWrite } from "./handlers/toggle-enabled.write";
25
27
  import { updateWrite } from "./handlers/update.write";
26
28
  import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
@@ -132,6 +134,8 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
132
134
  list: r.queryHandler(listQuery),
133
135
  memberships: r.queryHandler(membershipsQuery),
134
136
  members: r.queryHandler(membersQuery),
137
+ memberDirectory: r.queryHandler(memberDirectoryQuery),
138
+ tenantDirectory: r.queryHandler(tenantDirectoryQuery),
135
139
  activeTenantIds: r.queryHandler(activeTenantIdsQuery),
136
140
  resolveUserIds: r.queryHandler(resolveUserIdsQuery),
137
141
  invitations: r.queryHandler(invitationsQuery),
@@ -0,0 +1,5 @@
1
+ import { access, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export function isSystemAdmin(user: Pick<SessionUser, "roles">): boolean {
4
+ return access.systemAdmin.some((role) => user.roles.includes(role));
5
+ }
@@ -0,0 +1,74 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ access,
5
+ definePagedQueryHandler,
6
+ MAX_LIST_LIMIT,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
9
+ import { z } from "zod";
10
+ import { decryptStoredPii, mapWithConcurrency } from "../../shared";
11
+ import { userTable } from "../../user";
12
+ import { tenantMembershipsTable } from "../membership-table";
13
+ import { isSystemAdmin } from "./is-system-admin";
14
+
15
+ // Shares the KMS adapter's small dedicated pool, same rationale as
16
+ // members.query.ts.
17
+ const KMS_POOL_CONCURRENCY = 4;
18
+
19
+ async function loadMemberUserIds(db: TenantDb, tenantId: string): Promise<readonly string[]> {
20
+ const memberships = await selectMany(db, tenantMembershipsTable, { tenantId });
21
+ return [...new Set(memberships.map((row) => String(row["userId"])))];
22
+ }
23
+
24
+ // Label source behind every `user:user` reference column (fw#3107). The
25
+ // entity-convention lookup, user:query:user:list, is a SystemAdmin
26
+ // cross-tenant roster, so a TenantAdmin got a 403 and every actor cell fell
27
+ // back to the raw UUID. Deliberately narrower than tenant:query:members:
28
+ // id + display name only, no email, so the screens that only need a name
29
+ // never decrypt an address.
30
+ //
31
+ // A SystemAdmin keeps the global scope the roster gave them: operators act in
32
+ // tenants they are no member of, and their own audit entries and sessions must
33
+ // still resolve. A TenantAdmin only ever sees their own tenant's members.
34
+ // ponytail: capped at `limit` (200 list lookup, 50 combobox) like every
35
+ // reference lookup; beyond that the cell falls back to the raw id.
36
+ export const memberDirectoryQuery = definePagedQueryHandler({
37
+ name: "memberDirectory",
38
+ schema: z.object({
39
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
40
+ }),
41
+ access: { roles: access.admin },
42
+ description:
43
+ "Lists users as id/label pairs for resolving user references to display names: the caller's own tenant members for an admin, every user for a SystemAdmin; carries no email or role data.",
44
+ agent: { expose: false },
45
+ handler: async (query, ctx) => {
46
+ if (!ctx.systemDb) {
47
+ throw new InternalError({
48
+ message:
49
+ "tenant:query:member-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
50
+ });
51
+ }
52
+ const { limit } = query.payload;
53
+ let users: readonly { id: unknown; displayName?: unknown }[];
54
+ if (isSystemAdmin(query.user)) {
55
+ const db = ctx.systemDb.acknowledgeCrossTenant(
56
+ "SystemAdmin reference labels span every tenant, as user:query:user:list did",
57
+ );
58
+ users = await selectMany(db, userTable, undefined, { limit });
59
+ } else {
60
+ const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
61
+ const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, limit);
62
+ users = userIds.length > 0 ? await selectMany(db, userTable, { id: [...userIds] }) : [];
63
+ }
64
+ const rows = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
65
+ const id = String(user.id);
66
+ const label =
67
+ typeof user.displayName === "string"
68
+ ? await decryptStoredPii(user.displayName, "displayName", "tenant:member-directory")
69
+ : null;
70
+ return { id, label: label ?? id };
71
+ });
72
+ return { rows, nextCursor: null };
73
+ },
74
+ });