@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
@@ -203,12 +203,10 @@ describe("createCustomFieldsFeature access-options", () => {
203
203
  expect(writeAccess(feature, "delete-system-field")).toEqual(["SystemAdmin"]);
204
204
  });
205
205
 
206
- test("fieldDefinitionWriteRoles feeds into the list-default (a definer can load what they defined)", () => {
206
+ test("fieldDefinitionWriteRoles feeds into list roles without keeping default TenantAdmin", () => {
207
207
  const feature = createCustomFieldsFeature({ fieldDefinitionWriteRoles: ["Admin", "Editor"] });
208
208
  const roles = listAccess(feature);
209
- expect(roles).toContain("Admin");
210
- expect(roles).toContain("Editor");
211
- expect(roles).toContain("TenantAdmin");
209
+ expect(roles).toEqual(["Admin", "Editor"]);
212
210
  });
213
211
 
214
212
  test("fieldDefinitionWriteRoles and valueWriteRoles are independent — setting one leaves the other's default", () => {
@@ -219,23 +217,23 @@ describe("createCustomFieldsFeature access-options", () => {
219
217
  });
220
218
 
221
219
  describe("resolveFieldDefinitionListRoles", () => {
222
- test("nichts gesetzt → reiner Default", () => {
220
+ test("nothing set → default only", () => {
223
221
  expect(resolveFieldDefinitionListRoles({})).toEqual(["TenantAdmin"]);
224
222
  });
225
223
 
226
- test("valueWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
224
+ test("valueWriteRoles set, list unset → union with default, deduped", () => {
227
225
  expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["Admin", "Editor"] })).toEqual([
228
226
  "Admin",
229
227
  "Editor",
230
228
  "TenantAdmin",
231
229
  ]);
232
- // TenantAdmin schon in valueWriteRoles → keine Dublette.
230
+ // TenantAdmin already in valueWriteRoles → no duplicate.
233
231
  expect(resolveFieldDefinitionListRoles({ valueWriteRoles: ["TenantAdmin", "Editor"] })).toEqual(
234
232
  ["TenantAdmin", "Editor"],
235
233
  );
236
234
  });
237
235
 
238
- test("explizite list-Rollen gewinnen immer (auch über valueWriteRoles)", () => {
236
+ test("explicit list roles always win (even over valueWriteRoles)", () => {
239
237
  expect(
240
238
  resolveFieldDefinitionListRoles({
241
239
  valueWriteRoles: ["Admin"],
@@ -244,18 +242,18 @@ describe("resolveFieldDefinitionListRoles", () => {
244
242
  ).toEqual(["Viewer"]);
245
243
  });
246
244
 
247
- test("fieldDefinitionWriteRoles gesetzt, list ungesetzt → Union mit Default, dedupliziert", () => {
245
+ test("fieldDefinitionWriteRoles set, list unset → write roles only (no default TenantAdmin)", () => {
248
246
  expect(
249
247
  resolveFieldDefinitionListRoles({ fieldDefinitionWriteRoles: ["Admin", "Editor"] }),
250
- ).toEqual(["Admin", "Editor", "TenantAdmin"]);
248
+ ).toEqual(["Admin", "Editor"]);
251
249
  });
252
250
 
253
- test("valueWriteRoles UND fieldDefinitionWriteRoles gesetzt → Union aus beiden plus Default", () => {
251
+ test("valueWriteRoles AND fieldDefinitionWriteRoles → union of both, no default", () => {
254
252
  expect(
255
253
  resolveFieldDefinitionListRoles({
256
254
  valueWriteRoles: ["Member"],
257
255
  fieldDefinitionWriteRoles: ["Admin"],
258
256
  }),
259
- ).toEqual(["Member", "Admin", "TenantAdmin"]);
257
+ ).toEqual(["Member", "Admin"]);
260
258
  });
261
259
  });
@@ -23,9 +23,10 @@ export const CustomFieldsQueries = {
23
23
  fieldDefinitionList: "custom-fields:query:field-definition:list",
24
24
  } as const;
25
25
 
26
- // Name unter dem die web-component im ExtensionSectionsProvider
27
- // registriert wird — Apps referenzieren ihn im Screen-Schema via
28
- // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
26
+ // Name the web component registers under in ExtensionSectionsProvider —
27
+ // apps reference it in the screen schema via:
28
+ // { kind: "extension", title, contributesToFormSubmit: true,
29
+ // component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } } }
29
30
  export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
30
31
 
31
32
  // Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
@@ -206,7 +206,7 @@ export type CustomFieldsFeatureOptions = {
206
206
  * before #2296. Apps with their own role vocabulary (e.g. ["Admin","Editor"])
207
207
  * MUST set this, else field-definition management is access_denied for every
208
208
  * app-user without a "TenantAdmin" role. Only covers the tenant-scope triad —
209
- * define-/delete-system-field stay hard-wired to ["TenantAdmin"], since
209
+ * define-/delete-system-field stay hard-wired to ["SystemAdmin"], since
210
210
  * system-scope is a separate trust boundary that must not become reachable
211
211
  * through a tenant role vocabulary. Also feeds into
212
212
  * resolveFieldDefinitionListRoles (below) so a definer without an explicit
@@ -225,13 +225,12 @@ export function resolveFieldDefinitionListRoles(
225
225
  if (opts.valueWriteRoles === undefined && opts.fieldDefinitionWriteRoles === undefined) {
226
226
  return DEFAULT_FIELD_DEFINITION_LIST_ROLES;
227
227
  }
228
- return [
229
- ...new Set([
230
- ...(opts.valueWriteRoles ?? []),
231
- ...(opts.fieldDefinitionWriteRoles ?? []),
232
- ...DEFAULT_FIELD_DEFINITION_LIST_ROLES,
233
- ]),
234
- ];
228
+ // Write-roles override replaces the default role vocab — don't keep unioning
229
+ // TenantAdmin onto list when the app opted into its own write roles.
230
+ if (opts.fieldDefinitionWriteRoles !== undefined) {
231
+ return [...new Set([...(opts.valueWriteRoles ?? []), ...opts.fieldDefinitionWriteRoles])];
232
+ }
233
+ return [...new Set([...(opts.valueWriteRoles ?? []), ...DEFAULT_FIELD_DEFINITION_LIST_ROLES])];
235
234
  }
236
235
 
237
236
  // Backwards-compat-wrapper. Bestehende Caller (z.B. integration-tests,
@@ -17,6 +17,7 @@ import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
17
17
 
18
18
  type LogRow = {
19
19
  readonly id: string;
20
+ readonly tenantId?: string;
20
21
  readonly type: string;
21
22
  readonly channel: string;
22
23
  readonly recipient: string | null;
@@ -25,7 +26,9 @@ type LogRow = {
25
26
 
26
27
  let stack: TestStack;
27
28
  const tenantId = testTenantId(701);
29
+ const otherTenantId = testTenantId(702);
28
30
  const admin = createTestUser({ id: 701, roles: ["TenantAdmin"], tenantId });
31
+ const systemAdmin = createTestUser({ id: 709, roles: ["SystemAdmin"], tenantId });
29
32
 
30
33
  beforeAll(async () => {
31
34
  stack = await setupTestStack({ features: [createDeliveryFeature()] });
@@ -55,6 +58,14 @@ beforeAll(async () => {
55
58
  recipientAddress: null,
56
59
  status: "queued",
57
60
  },
61
+ {
62
+ id: crypto.randomUUID(),
63
+ tenantId: otherTenantId,
64
+ notificationType: "logtest:other",
65
+ channel: "email",
66
+ recipientAddress: "other@example.com",
67
+ status: "sent",
68
+ },
58
69
  ]);
59
70
  });
60
71
 
@@ -115,6 +126,23 @@ describe("delivery:query:log — sort", () => {
115
126
  );
116
127
  expect(withInvalidSort.rows.map((r) => r.id)).toEqual(withNoSort.rows.map((r) => r.id));
117
128
  });
129
+
130
+ test("sort=tenantId is whitelisted and reverses for SystemAdmin cross-tenant", async () => {
131
+ const asc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
132
+ DeliveryQueries.log,
133
+ { sort: "tenantId", sortDirection: "asc" },
134
+ systemAdmin,
135
+ );
136
+ const desc = await stack.http.queryOk<{ rows: readonly LogRow[] }>(
137
+ DeliveryQueries.log,
138
+ { sort: "tenantId", sortDirection: "desc" },
139
+ systemAdmin,
140
+ );
141
+ const ascTenants = [...new Set(asc.rows.map((r) => r.tenantId))];
142
+ const descTenants = [...new Set(desc.rows.map((r) => r.tenantId))];
143
+ expect(new Set(ascTenants)).toEqual(new Set([tenantId, otherTenantId]));
144
+ expect(descTenants).toEqual([...ascTenants].reverse());
145
+ });
118
146
  });
119
147
 
120
148
  describe("delivery:query:log — field mapping", () => {
@@ -169,7 +169,7 @@ describe("delivery log SystemAdmin cross-tenant scope", () => {
169
169
  status: string;
170
170
  error: string | null;
171
171
  }[];
172
- }>(DeliveryQueries.log, {}, systemAdmin);
172
+ }>(DeliveryQueries.log, { limit: 100 }, systemAdmin);
173
173
 
174
174
  const byId = new Map(res.rows.map((r) => [r.id, r]));
175
175
  expect(byId.get(systemConfirmationId)?.tenantId).toBe(SYSTEM_TENANT_ID);
@@ -189,6 +189,97 @@ describe("delivery log SystemAdmin cross-tenant scope", () => {
189
189
  );
190
190
  });
191
191
 
192
+ test("SystemAdmin cursor-paginates the unfiltered cross-tenant result set, seeing every row exactly once", async () => {
193
+ const pagingTenant = testTenantId(53);
194
+ const seededRows = Array.from({ length: 7 }, (_, i) => ({
195
+ id: crypto.randomUUID(),
196
+ tenantId: i % 2 === 0 ? SYSTEM_TENANT_ID : pagingTenant,
197
+ // unique per row so we can sort on it — the default sort (createdAt)
198
+ // ties across rows inserted in the same insertMany statement, and the
199
+ // cursor WHERE only excludes strictly-equal sort values (see
200
+ // log.query.ts), so equal timestamps would silently drop rows.
201
+ notificationType: `paging-test:${i}`,
202
+ channel: "email",
203
+ recipientAddress: null,
204
+ status: "sent",
205
+ }));
206
+ await insertMany(stack.db, deliveryAttemptsTable, seededRows);
207
+
208
+ const systemAdmin = createTestUser({
209
+ id: 52,
210
+ roles: ["SystemAdmin"],
211
+ tenantId: pagingTenant,
212
+ });
213
+
214
+ const seenCounts = new Map<string, number>();
215
+ let cursor: string | undefined;
216
+ let pageCount = 0;
217
+ do {
218
+ const page = await stack.http.queryOk<{
219
+ rows: readonly { id: string }[];
220
+ nextCursor: string | null;
221
+ }>(
222
+ DeliveryQueries.log,
223
+ { limit: 3, sort: "type", sortDirection: "asc", cursor },
224
+ systemAdmin,
225
+ );
226
+ for (const row of page.rows) {
227
+ seenCounts.set(row.id, (seenCounts.get(row.id) ?? 0) + 1);
228
+ }
229
+ cursor = page.nextCursor ?? undefined;
230
+ pageCount += 1;
231
+ expect(pageCount).toBeLessThan(50);
232
+ } while (cursor);
233
+
234
+ expect(pageCount).toBeGreaterThan(1);
235
+ for (const row of seededRows) {
236
+ expect(seenCounts.get(row.id)).toBe(1);
237
+ }
238
+ expect([...seenCounts.values()].every((count) => count === 1)).toBe(true);
239
+ });
240
+
241
+ test("SystemAdmin with no demo-tenant session (session tenantId is SYSTEM_TENANT_ID itself) still sees cross-tenant rows", async () => {
242
+ const otherTenant = testTenantId(54);
243
+ const systemScopedId = crypto.randomUUID();
244
+ const otherTenantRowId = crypto.randomUUID();
245
+
246
+ await insertMany(stack.db, deliveryAttemptsTable, [
247
+ {
248
+ id: systemScopedId,
249
+ tenantId: SYSTEM_TENANT_ID,
250
+ notificationType: "no-session-tenant-test:system",
251
+ channel: "email",
252
+ recipientAddress: null,
253
+ status: "sent",
254
+ },
255
+ {
256
+ id: otherTenantRowId,
257
+ tenantId: otherTenant,
258
+ notificationType: "no-session-tenant-test:other",
259
+ channel: "email",
260
+ recipientAddress: null,
261
+ status: "sent",
262
+ },
263
+ ]);
264
+
265
+ // Mirrors a platform-level caller (e.g. an API key) that has no demo
266
+ // tenant on its session at all, as opposed to the other SystemAdmin
267
+ // tests above which browse from a demo tenant panel.
268
+ const systemAdmin = createTestUser({
269
+ id: 53,
270
+ roles: ["SystemAdmin"],
271
+ tenantId: SYSTEM_TENANT_ID,
272
+ });
273
+
274
+ const res = await stack.http.queryOk<{
275
+ rows: readonly { id: string; tenantId: string }[];
276
+ }>(DeliveryQueries.log, { limit: 100 }, systemAdmin);
277
+
278
+ const byId = new Map(res.rows.map((r) => [r.id, r]));
279
+ expect(byId.get(systemScopedId)?.tenantId).toBe(SYSTEM_TENANT_ID);
280
+ expect(byId.get(otherTenantRowId)?.tenantId).toBe(otherTenant);
281
+ });
282
+
192
283
  test("TenantAdmin cannot read SYSTEM_TENANT_ID or foreign-tenant delivery attempts", async () => {
193
284
  const demoTenant = testTenantId(61);
194
285
  const otherTenant = testTenantId(62);
@@ -1,4 +1,4 @@
1
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
@@ -15,7 +15,8 @@ export async function selectNotificationPreferences(
15
15
  notificationType: string,
16
16
  channelName: string,
17
17
  ): Promise<readonly NotificationPreferenceRow[]> {
18
- return asRawClient(db).unsafe<NotificationPreferenceRow>(
18
+ return unsafeReadRetrying<NotificationPreferenceRow>(
19
+ db,
19
20
  `SELECT notification_type AS "notificationType", channel, enabled
20
21
  FROM read_notification_preferences
21
22
  WHERE tenant_id = $1
@@ -34,6 +34,7 @@ type DeliveryLogRow = {
34
34
  // application code, so there is no column to ORDER BY in SQL for it.
35
35
  const DELIVERY_LOG_SORT_COLUMNS = {
36
36
  createdAt: "createdAt",
37
+ tenantId: "tenantId",
37
38
  type: "notificationType",
38
39
  channel: "channel",
39
40
  status: "status",
@@ -568,7 +568,7 @@ describe("feature-toggles queries + audit automation", () => {
568
568
  test("registered query reports metadata + override + effective for every feature", async () => {
569
569
  runtime.apply("widget", false);
570
570
  const data = await stack.http.queryOk<{
571
- items: Array<{
571
+ rows: Array<{
572
572
  name: string;
573
573
  toggleable: boolean;
574
574
  default: boolean | null;
@@ -577,7 +577,7 @@ describe("feature-toggles queries + audit automation", () => {
577
577
  effective: boolean | null;
578
578
  }>;
579
579
  }>("feature-toggles:query:registered", {}, admin);
580
- const byName = new Map(data.items.map((i) => [i.name, i]));
580
+ const byName = new Map(data.rows.map((i) => [i.name, i]));
581
581
 
582
582
  expect(byName.get("widget")).toMatchObject({
583
583
  toggleable: true,
@@ -11,11 +11,19 @@ describe("feature-toggles screen + handler access alignment", () => {
11
11
  expect(() => validateBoot(features)).not.toThrow();
12
12
  });
13
13
 
14
- test("toggle-admin screen is SystemAdmin-gated", () => {
14
+ test("toggle-admin screen is projectionList and SystemAdmin-gated", () => {
15
15
  const toggles = createFeatureTogglesFeature();
16
16
  const screen = toggles.screens[TOGGLE_ADMIN_SCREEN_ID];
17
- expect(screen?.type).toBe("custom");
18
- if (screen && "access" in screen && screen.access && "roles" in screen.access) {
17
+ expect(screen?.type).toBe("projectionList");
18
+ if (screen?.type !== "projectionList") throw new Error("expected projectionList");
19
+ expect(screen.query).toBe(FeatureToggleQueries.registered);
20
+ const toggleAction = screen.rowActions?.find((a) => a.id === "toggle");
21
+ if (toggleAction?.kind !== "writeHandler") throw new Error("expected a writeHandler rowAction");
22
+ expect(toggleAction.handler).toBe(FeatureToggleHandlers.set);
23
+ expect(toggleAction.payload).toEqual({
24
+ map: { featureName: "name", enabled: "nextEnabled" },
25
+ });
26
+ if ("access" in screen && screen.access && "roles" in screen.access) {
19
27
  expect(screen.access.roles).toEqual(["SystemAdmin"]);
20
28
  }
21
29
  });
@@ -36,6 +36,7 @@ describe("feature-toggles access matrix", () => {
36
36
  ]);
37
37
  const feature = createFeatureTogglesFeature();
38
38
  const screen = feature.screens[TOGGLE_ADMIN_SCREEN_ID];
39
+ expect(screen?.type).toBe("projectionList");
39
40
  if (screen && "access" in screen && screen.access && "roles" in screen.access) {
40
41
  expect(screen.access.roles).toEqual(["SystemAdmin"]);
41
42
  }
@@ -49,12 +50,12 @@ describe("feature-toggles HTTP access", () => {
49
50
  { items?: readonly unknown[] } | { rows?: readonly unknown[] }
50
51
  >(FeatureToggleQueries.list, {}, admin);
51
52
  expect(typeof list).toBe("object");
52
- const registered = await stack.http.queryOk<{ items: readonly unknown[] }>(
53
+ const registered = await stack.http.queryOk<{ rows: readonly unknown[] }>(
53
54
  FeatureToggleQueries.registered,
54
55
  {},
55
56
  admin,
56
57
  );
57
- expect(Array.isArray(registered.items)).toBe(true);
58
+ expect(Array.isArray(registered.rows)).toBe(true);
58
59
  });
59
60
 
60
61
  test("TenantAdmin gets 403 on list, registered, and set", async () => {
@@ -1,5 +1,10 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { FEATURE_TOGGLE_SET_EVENT_NAME, TOGGLE_ADMIN_SCREEN_ID } from "./constants";
2
+ import {
3
+ FEATURE_TOGGLE_SET_EVENT_NAME,
4
+ FeatureToggleHandlers,
5
+ FeatureToggleQueries,
6
+ TOGGLE_ADMIN_SCREEN_ID,
7
+ } from "./constants";
3
8
  import { featureToggleSetSchema } from "./events";
4
9
  import { globalFeatureStateTableMeta } from "./global-feature-state-table";
5
10
  import { listQuery } from "./handlers/list.query";
@@ -67,11 +72,26 @@ export function createFeatureTogglesFeature(
67
72
  registered: r.queryHandler(registeredQuery),
68
73
  };
69
74
 
70
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
71
75
  r.screen({
72
76
  id: TOGGLE_ADMIN_SCREEN_ID,
73
- type: "custom",
74
- renderer: { react: { __component: "ToggleAdminScreen" } },
77
+ type: "projectionList",
78
+ query: FeatureToggleQueries.registered,
79
+ columns: [
80
+ { field: "name", label: "feature-toggles.admin.col.feature" },
81
+ { field: "defaultLabel", label: "feature-toggles.admin.col.default" },
82
+ { field: "overrideLabel", label: "feature-toggles.admin.col.override" },
83
+ { field: "effectiveLabel", label: "feature-toggles.admin.col.effective" },
84
+ ],
85
+ rowActions: [
86
+ {
87
+ kind: "writeHandler",
88
+ id: "toggle",
89
+ label: "feature-toggles.admin.toggle",
90
+ handler: FeatureToggleHandlers.set,
91
+ payload: { map: { featureName: "name", enabled: "nextEnabled" } },
92
+ visible: { field: "toggleable", eq: true },
93
+ },
94
+ ],
75
95
  access: { roles: ["SystemAdmin"] },
76
96
  });
77
97
  r.nav({
@@ -12,6 +12,12 @@ import { globalFeatureStateTable } from "../global-feature-state-table";
12
12
  // Design: registry introspection (toggleable/default/requires) + a single
13
13
  // DB read of overrides. No per-feature DB calls. Scales to however many
14
14
  // features an app registers — currently tens, never thousands.
15
+
16
+ function formatFlag(value: boolean | null): string {
17
+ if (value === null) return "inherit";
18
+ return value ? "on" : "off";
19
+ }
20
+
15
21
  export const registeredQuery = defineQueryHandler({
16
22
  name: "registered",
17
23
  schema: z.object({}),
@@ -38,38 +44,43 @@ export const registeredQuery = defineQueryHandler({
38
44
  // dokumentiert in DispatcherOptions.effectiveFeatures.
39
45
  const effective = ctx.effectiveFeatures?.(SYSTEM_TENANT_ID);
40
46
 
41
- const items: Array<{
47
+ const rows: Array<{
48
+ id: string;
42
49
  name: string;
43
50
  toggleable: boolean;
44
51
  default: boolean | null;
45
52
  override: boolean | null;
46
53
  requires: readonly string[];
47
54
  effective: boolean | null;
55
+ defaultLabel: string;
56
+ overrideLabel: string;
57
+ effectiveLabel: string;
58
+ nextEnabled: boolean;
48
59
  }> = [];
60
+
49
61
  for (const feature of ctx.registry.features.values()) {
50
62
  const toggleable = feature.toggleableDefault !== undefined;
51
63
  const override = overrides.get(feature.name);
52
- items.push({
64
+ const defaultValue = feature.toggleableDefault ?? null;
65
+ const effectiveValue = effective ? effective.has(feature.name) : null;
66
+ const effectiveBool = effectiveValue ?? defaultValue ?? false;
67
+
68
+ rows.push({
69
+ id: feature.name,
53
70
  name: feature.name,
54
71
  toggleable,
55
- // `default` is null when non-toggleable; the UI must render
56
- // non-toggleable features as "always on" without an enable/disable
57
- // control (flipping them would be rejected by the set-handler).
58
- default: feature.toggleableDefault ?? null,
59
- // `override` is null when no explicit row exists. That's distinct
60
- // from "override says on" or "override says off" so the UI can show
61
- // an "inherits default" indicator.
72
+ // Raw booleans for API consumers; label fields for projectionList columns.
73
+ default: defaultValue,
62
74
  override: override ?? null,
63
75
  requires: feature.requires,
64
- // Effective = what the dispatcher-gate actually uses right now,
65
- // after cascade. When the feature-toggles runtime isn't wired
66
- // (dev setup without the feature loaded), we surface null so the
67
- // UI knows the runtime isn't available rather than defaulting
68
- // to "everything on".
69
- effective: effective ? effective.has(feature.name) : null,
76
+ effective: effectiveValue,
77
+ defaultLabel: formatFlag(defaultValue),
78
+ overrideLabel: formatFlag(override ?? null),
79
+ effectiveLabel: formatFlag(effectiveValue),
80
+ nextEnabled: !effectiveBool,
70
81
  });
71
82
  }
72
83
 
73
- return { items };
84
+ return { rows, nextCursor: null };
74
85
  },
75
86
  });
@@ -3,4 +3,9 @@ type LocalizedString = { readonly en: string };
3
3
  export const FEATURE_TOGGLES_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:toggle-admin.title": { en: "Feature toggles" },
5
5
  "feature-toggles:nav.toggleAdmin": { en: "Feature toggles" },
6
+ "feature-toggles.admin.col.feature": { en: "Feature" },
7
+ "feature-toggles.admin.col.default": { en: "Default" },
8
+ "feature-toggles.admin.col.override": { en: "Override" },
9
+ "feature-toggles.admin.col.effective": { en: "Effective" },
10
+ "feature-toggles.admin.toggle": { en: "Toggle" },
6
11
  };
@@ -1,9 +1,8 @@
1
1
  // @runtime client
2
2
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { FEATURE_TOGGLES_FEATURE, TOGGLE_ADMIN_SCREEN_ID } from "../constants";
4
+ import { FEATURE_TOGGLES_FEATURE } from "../constants";
5
5
  import { defaultTranslations } from "./i18n";
6
- import { ToggleAdminScreen } from "./toggle-admin-screen";
7
6
 
8
7
  export type FeatureTogglesClientOptions = {
9
8
  readonly translations?: TranslationsByLocale;
@@ -15,8 +14,5 @@ export function featureTogglesClient(
15
14
  return {
16
15
  name: FEATURE_TOGGLES_FEATURE,
17
16
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
18
- components: {
19
- [TOGGLE_ADMIN_SCREEN_ID]: ToggleAdminScreen,
20
- },
21
17
  };
22
18
  }
@@ -3,4 +3,3 @@ export {
3
3
  type FeatureTogglesClientOptions,
4
4
  featureTogglesClient,
5
5
  } from "./client-plugin";
6
- export { ToggleAdminScreen } from "./toggle-admin-screen";
@@ -8,7 +8,7 @@
8
8
  // fetchOne(), throwing a Postgres 22P02 (malformed uuid literal) that
9
9
  // poisons the pooled Bun.SQL connection, an unauth DoS primitive.
10
10
 
11
- import { describe, expect, test } from "bun:test";
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
12
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
13
13
  import {
14
14
  createFilesFeature,
@@ -21,8 +21,10 @@ import { createFileDerivativesFeature } from "../feature";
21
21
  import { PUBLIC_VARIANT_QN } from "../handlers/public-variant.query";
22
22
 
23
23
  describe("file-derivatives :: publicVariant query fileRefId validation", () => {
24
- test("non-UUID fileRefId via /api/query → 400 validation_error, never reaches the DB", async () => {
25
- const stack: TestStack = await setupTestStack({
24
+ let stack: TestStack;
25
+
26
+ beforeAll(async () => {
27
+ stack = await setupTestStack({
26
28
  features: [
27
29
  createConfigFeature(),
28
30
  fileFoundationFeature,
@@ -32,44 +34,31 @@ describe("file-derivatives :: publicVariant query fileRefId validation", () => {
32
34
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
33
35
  files: { storageProvider: createInMemoryFileProvider() },
34
36
  });
37
+ });
38
+
39
+ afterAll(async () => {
40
+ await stack.cleanup();
41
+ });
35
42
 
36
- try {
37
- const res = await stack.http.raw("POST", "/api/query", {
38
- type: PUBLIC_VARIANT_QN,
39
- payload: { fileRefId: "not-a-uuid; DROP TABLE file_refs;--", variant: "thumb" },
40
- });
43
+ test("non-UUID fileRefId via /api/query → 400 validation_error, never reaches the DB", async () => {
44
+ const res = await stack.http.raw("POST", "/api/query", {
45
+ type: PUBLIC_VARIANT_QN,
46
+ payload: { fileRefId: "not-a-uuid; DROP TABLE file_refs;--", variant: "thumb" },
47
+ });
41
48
 
42
- expect(res.status).toBe(400);
43
- const body = (await res.json()) as { error?: { code?: string } };
44
- expect(body.error?.code).toBe("validation_error");
45
- } finally {
46
- await stack.cleanup();
47
- }
49
+ expect(res.status).toBe(400);
50
+ const body = (await res.json()) as { error?: { code?: string } };
51
+ expect(body.error?.code).toBe("validation_error");
48
52
  });
49
53
 
50
54
  test("well-formed but unknown UUID fileRefId via /api/query → 200 with null (schema passes, DB lookup just finds nothing)", async () => {
51
- const stack: TestStack = await setupTestStack({
52
- features: [
53
- createConfigFeature(),
54
- fileFoundationFeature,
55
- createFilesFeature(),
56
- createFileDerivativesFeature({ resolveApexTenant: () => SYSTEM_TENANT_ID }),
57
- ],
58
- anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
59
- files: { storageProvider: createInMemoryFileProvider() },
55
+ const res = await stack.http.raw("POST", "/api/query", {
56
+ type: PUBLIC_VARIANT_QN,
57
+ payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
60
58
  });
61
59
 
62
- try {
63
- const res = await stack.http.raw("POST", "/api/query", {
64
- type: PUBLIC_VARIANT_QN,
65
- payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
66
- });
67
-
68
- expect(res.status).toBe(200);
69
- const body = (await res.json()) as { data: unknown };
70
- expect(body.data).toBeNull();
71
- } finally {
72
- await stack.cleanup();
73
- }
60
+ expect(res.status).toBe(200);
61
+ const body = (await res.json()) as { data: unknown };
62
+ expect(body.data).toBeNull();
74
63
  });
75
64
  });
@@ -279,8 +279,8 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
279
279
  test("a preset name the field doesn't declare in its variants → 404", async () => {
280
280
  const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
281
281
 
282
- // widgetEntity's "img" field declares only thumb/full — "card" and
283
- // "hero" aren't in its variants map.
282
+ // widgetEntity's "img" field declares thumb/full/plan/heroWide/raw —
283
+ // "card" and "hero" aren't in its variants map.
284
284
  const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/card`);
285
285
 
286
286
  expect(res.status).toBe(404);