@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.242.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 (72) hide show
  1. package/package.json +9 -9
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
  3. package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
  4. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
  5. package/src/admin-shell/changes.json +6 -0
  6. package/src/admin-shell/feature.ts +60 -6
  7. package/src/admin-shell/i18n.ts +0 -7
  8. package/src/admin-shell/web/client-plugin.tsx +1 -11
  9. package/src/agent-tools/__tests__/agent-doc-lint.test.ts +40 -0
  10. package/src/agent-tools/__tests__/agent-manifest.test.ts +36 -0
  11. package/src/agent-tools/__tests__/tool-catalog.test.ts +51 -0
  12. package/src/agent-tools/agent-doc-lint.ts +14 -3
  13. package/src/agent-tools/agent-manifest.ts +16 -3
  14. package/src/agent-tools/feature.ts +1 -1
  15. package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
  16. package/src/audit/changes.json +7 -0
  17. package/src/audit/feature.ts +57 -7
  18. package/src/audit/handlers/list.query.ts +50 -14
  19. package/src/audit/i18n.ts +3 -18
  20. package/src/audit/web/client-plugin.tsx +1 -7
  21. package/src/audit/web/index.ts +0 -2
  22. package/src/config/__tests__/config.integration.test.ts +31 -0
  23. package/src/config/handlers/readiness.query.ts +8 -1
  24. package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
  25. package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
  26. package/src/jobs/changes.json +7 -0
  27. package/src/jobs/feature.ts +101 -9
  28. package/src/jobs/handlers/list.query.ts +21 -4
  29. package/src/jobs/handlers/trigger.write.ts +16 -3
  30. package/src/jobs/i18n.ts +3 -25
  31. package/src/jobs/index.ts +1 -0
  32. package/src/jobs/web/client-plugin.tsx +1 -7
  33. package/src/jobs/web/index.ts +0 -2
  34. package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
  35. package/src/tenant/handlers/list.query.ts +1 -0
  36. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  37. package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
  38. package/src/tier-engine/changes.json +9 -1
  39. package/src/tier-engine/constants.ts +5 -5
  40. package/src/tier-engine/feature.ts +36 -10
  41. package/src/tier-engine/i18n.ts +19 -22
  42. package/src/tier-engine/web/client-plugin.tsx +5 -11
  43. package/src/tier-engine/web/index.ts +0 -1
  44. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
  45. package/src/user-data-rights/changes.json +7 -0
  46. package/src/user-data-rights/constants.ts +20 -6
  47. package/src/user-data-rights/feature.ts +113 -14
  48. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
  49. package/src/user-data-rights/web/client-plugin.tsx +19 -21
  50. package/src/user-data-rights/web/i18n.ts +6 -11
  51. package/src/user-data-rights/web/index.ts +1 -1
  52. package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
  53. package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
  54. package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
  55. package/src/user-profile/changes.json +7 -0
  56. package/src/user-profile/constants.ts +15 -7
  57. package/src/user-profile/feature.ts +96 -9
  58. package/src/user-profile/i18n.ts +8 -12
  59. package/src/user-profile/web/client-plugin.ts +22 -5
  60. package/src/user-profile/web/index.ts +1 -1
  61. package/src/user-profile/web/profile-screen.tsx +122 -265
  62. package/src/admin-shell/web/overview-layout.tsx +0 -69
  63. package/src/admin-shell/web/overview-query.ts +0 -15
  64. package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
  65. package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
  66. package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
  67. package/src/audit/web/audit-log-detail-screen.tsx +0 -169
  68. package/src/audit/web/audit-log-screen.tsx +0 -290
  69. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
  70. package/src/jobs/web/job-run-detail-screen.tsx +0 -181
  71. package/src/jobs/web/job-runs-screen.tsx +0 -290
  72. package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
@@ -17,7 +17,7 @@ export const triggerWrite = defineWriteHandler({
17
17
  "Starts one manually-triggerable job with the given payload after validating it against the job's schema; use it to run a maintenance or import job on demand.",
18
18
  schema: z.object({
19
19
  jobName: z.string(),
20
- payload: z.record(z.string(), z.unknown()).optional(),
20
+ payload: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
21
21
  }),
22
22
  access: { roles: ["SystemAdmin"] },
23
23
  handler: async (event, ctx) => {
@@ -43,8 +43,21 @@ export const triggerWrite = defineWriteHandler({
43
43
  );
44
44
  }
45
45
 
46
- const rawPayload = event.payload.payload ?? {};
47
- let payload: DbRow = rawPayload as DbRow;
46
+ let rawPayload: DbRow = {};
47
+ if (typeof event.payload.payload === "string" && event.payload.payload.trim() !== "") {
48
+ try {
49
+ const parsed: unknown = JSON.parse(event.payload.payload);
50
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
51
+ return writeFailure(new UnprocessableError(JobErrors.notManual));
52
+ }
53
+ rawPayload = parsed as DbRow;
54
+ } catch {
55
+ return writeFailure(new UnprocessableError(JobErrors.notManual));
56
+ }
57
+ } else if (event.payload.payload !== undefined) {
58
+ rawPayload = event.payload.payload as DbRow;
59
+ }
60
+ let payload: DbRow = rawPayload;
48
61
  if (jobDef.schema !== undefined) {
49
62
  const parsed = jobDef.schema.safeParse(rawPayload);
50
63
  if (!parsed.success) {
package/src/jobs/i18n.ts CHANGED
@@ -6,13 +6,12 @@ type LocalizedString = { readonly en: string };
6
6
  export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
7
7
  "screen:job-runs.title": { en: "Job runs" },
8
8
  "screen:job-run-detail.title": { en: "Job run" },
9
+ "screen:job-trigger.title": { en: "Trigger job" },
10
+ "jobs:entity:__action-form__:field:jobName": { en: "Job name" },
11
+ "jobs:entity:__action-form__:field:payload": { en: "JSON payload" },
9
12
  "jobs:nav.jobRuns": { en: "Jobs" },
10
- "jobs.runs.title": { en: "Job runs" },
11
- "jobs.runs.loading": { en: "Loading runs…" },
12
- "jobs.runs.empty": { en: "No job runs." },
13
13
  "jobs.runs.open": { en: "Details" },
14
14
  "jobs.runs.filter.status": { en: "Status" },
15
- "jobs.runs.filter.all": { en: "All" },
16
15
  "jobs.runs.filter.completed": { en: "Completed" },
17
16
  "jobs.runs.filter.failed": { en: "Failed" },
18
17
  "jobs.runs.filter.running": { en: "Running" },
@@ -21,8 +20,6 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
21
20
  "jobs.runs.col.status": { en: "Status" },
22
21
  "jobs.runs.col.started": { en: "Started" },
23
22
  "jobs.runs.col.duration": { en: "Duration (ms)" },
24
- "jobs.detail.loading": { en: "Loading details…" },
25
- "jobs.detail.missing": { en: "Run not found." },
26
23
  "jobs.detail.field.job": { en: "Job" },
27
24
  "jobs.detail.field.status": { en: "Status" },
28
25
  "jobs.detail.field.id": { en: "Run ID" },
@@ -31,28 +28,9 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
31
28
  "jobs.detail.field.duration": { en: "Duration (ms)" },
32
29
  "jobs.detail.field.error": { en: "Error" },
33
30
  "jobs.detail.logs": { en: "Logs" },
34
- "jobs.detail.logs.empty": { en: "No log lines." },
35
31
  "jobs.detail.retry": { en: "Retry" },
36
- "jobs.detail.retrying": { en: "Retrying…" },
37
32
  "jobs.trigger.title": { en: "Run a job" },
38
- "jobs.trigger.explainer": {
39
- en: "Only manually triggerable jobs. After starting, the run appears in the history below.",
40
- },
41
- "jobs.trigger.job": { en: "Job" },
42
- "jobs.trigger.payload": { en: "Payload (JSON)" },
43
- "jobs.trigger.payload.hint": { en: "Schema hint" },
44
- "jobs.trigger.payload.invalidJson": {
45
- en: "Payload is not a valid JSON object.",
46
- },
47
33
  "jobs.trigger.submit": { en: "Run" },
48
- "jobs.trigger.submitting": { en: "Starting…" },
49
- "jobs.trigger.success": { en: "Job started." },
50
- "jobs.trigger.empty": {
51
- en: "No manually triggerable jobs are registered.",
52
- },
53
- "jobs.trigger.perTenant": {
54
- en: "Runs once per active tenant.",
55
- },
56
34
  "jobs.errors.unknownJob": { en: "Unknown job." },
57
35
  "jobs.errors.notManual": {
58
36
  en: "This job cannot be triggered manually.",
package/src/jobs/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { JOBS_FEATURE, JobErrors, JobHandlers, JobQueries } from "./constants";
1
2
  export { createJobsFeature, type JobsFeatureOptions } from "./feature";
2
3
  export type { JobRunLoggerCallbacks } from "./job-run-logger";
3
4
  export { createJobRunLogger } from "./job-run-logger";
@@ -1,10 +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 { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID, JOBS_FEATURE } from "../constants";
4
+ import { JOBS_FEATURE } from "../constants";
5
5
  import { defaultTranslations } from "./i18n";
6
- import { JobRunDetailScreen } from "./job-run-detail-screen";
7
- import { JobRunsScreen } from "./job-runs-screen";
8
6
 
9
7
  export type JobsClientOptions = {
10
8
  readonly translations?: TranslationsByLocale;
@@ -14,9 +12,5 @@ export function jobsClient(options?: JobsClientOptions): ClientFeatureDefinition
14
12
  return {
15
13
  name: JOBS_FEATURE,
16
14
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
17
- components: {
18
- [JOB_RUNS_SCREEN_ID]: JobRunsScreen,
19
- [JOB_RUN_DETAIL_SCREEN_ID]: JobRunDetailScreen,
20
- },
21
15
  };
22
16
  }
@@ -1,4 +1,2 @@
1
1
  // @runtime client
2
2
  export { type JobsClientOptions, jobsClient } from "./client-plugin";
3
- export { JobRunDetailScreen } from "./job-run-detail-screen";
4
- export { JobRunsScreen } from "./job-runs-screen";
@@ -190,6 +190,38 @@ describe("scenario 5: tenant.list", () => {
190
190
  "SystemAdmin",
191
191
  ]);
192
192
  });
193
+
194
+ test("without totalCount the shape carries no total field (fw#2312)", async () => {
195
+ const result = await stack.http.queryOk<{
196
+ rows: Record<string, unknown>[];
197
+ nextCursor: string | null;
198
+ total?: number;
199
+ }>(TenantQueries.list, {}, systemAdmin);
200
+ expect(result.total).toBeUndefined();
201
+ });
202
+
203
+ test("total reflects the full match count, not capped by limit (fw#2312)", async () => {
204
+ await stack.http.writeOk(
205
+ TenantHandlers.create,
206
+ { key: "gamma", name: "Gamma LLC" },
207
+ systemAdmin,
208
+ );
209
+
210
+ const unpaged = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
211
+ TenantQueries.list,
212
+ {},
213
+ systemAdmin,
214
+ );
215
+ const expectedTotal = unpaged.rows.length;
216
+ expect(expectedTotal).toBeGreaterThanOrEqual(3);
217
+
218
+ const paged = await stack.http.queryOk<{
219
+ rows: Record<string, unknown>[];
220
+ total?: number;
221
+ }>(TenantQueries.list, { limit: 1, totalCount: true }, systemAdmin);
222
+ expect(paged.rows.length).toBe(1);
223
+ expect(paged.total).toBe(expectedTotal);
224
+ });
193
225
  });
194
226
 
195
227
  // --- Scenario 6: Config integration ---
@@ -12,6 +12,7 @@ export const listQuery = defineQueryHandler({
12
12
  cursor: z.string().optional(),
13
13
  limit: z.number().optional(),
14
14
  search: z.string().optional(),
15
+ totalCount: z.boolean().optional(),
15
16
  }),
16
17
  access: { roles: ["SystemAdmin"] },
17
18
  description:
@@ -18,7 +18,7 @@ describe("tier-engine drift pins", () => {
18
18
  expect(TierEngineHandlers.update).toBe("tier-engine:write:tier-assignment:update");
19
19
  expect(TierEngineQueries.list).toBe("tier-engine:query:tier-assignment:list");
20
20
  expect(TierEngineQueries.getActiveTier).toBe("tier-engine:query:get-active-tier");
21
- // Screen↔Handler-Contract: der TierAdminScreen dispatcht exakt diese QNs.
21
+ // Screen↔Handler-Contract: the tier-admin actionForm dispatches exactly these QNs.
22
22
  expect(TierEngineHandlers.setTenantTier).toBe("tier-engine:write:set-tenant-tier");
23
23
  expect(TierEngineQueries.getTenantTier).toBe("tier-engine:query:get-tenant-tier");
24
24
  expect(TierEngineQueries.tierOptions).toBe("tier-engine:query:tier-options");
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
+ import { createConfigFeature } from "../../config";
5
+ import { createTenantFeature } from "../../tenant/feature";
6
+ import type { TierMap } from "../compose-app";
7
+ import { TIER_ADMIN_SCREEN_ID } from "../constants";
8
+ import { createTierEngineFeature } from "../feature";
9
+
10
+ const SYSTEM_ADMIN_ROLES = ["SystemAdmin"] as const;
11
+
12
+ type TestCaps = { readonly maxItems: number };
13
+ const TEST_TIER_MAP: TierMap<TestCaps> = {
14
+ free: { features: [], caps: { maxItems: 1 } },
15
+ pro: { features: ["feat-pro"], caps: { maxItems: 5 } },
16
+ };
17
+
18
+ describe("tier-engine tier-admin screen + handler access alignment", () => {
19
+ test("boot-validates with the tier-admin actionForm registered (tierMap configured)", () => {
20
+ const features = [
21
+ createConfigFeature(),
22
+ createTenantFeature(),
23
+ createTierEngineFeature({ tierMap: TEST_TIER_MAP }),
24
+ ];
25
+ expect(() => validateBoot(features)).not.toThrow();
26
+ });
27
+
28
+ test("boot-validates in storage-only mode with tier-admin screen showing the no-tiers message (no tierMap)", () => {
29
+ const features = [createConfigFeature(), createTenantFeature(), createTierEngineFeature()];
30
+ expect(() => validateBoot(features)).not.toThrow();
31
+ const tierEngine = createTierEngineFeature();
32
+ const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
33
+ expect(screen).toBeDefined();
34
+ if (screen && "layout" in screen) {
35
+ expect(screen.layout.sections[0]).toMatchObject({ description: "tier-admin.error.noTiers" });
36
+ }
37
+ if (screen && "fields" in screen) {
38
+ expect(screen.fields["tier"]).toEqual({ type: "select", options: [], required: true });
39
+ }
40
+ });
41
+
42
+ test("tier-admin is a SystemAdmin-gated actionForm dispatching set-tenant-tier", () => {
43
+ const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
44
+ const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
45
+ expect(screen?.type).toBe("actionForm");
46
+ if (screen && "handler" in screen) {
47
+ expect(screen.handler).toBe("tier-engine:write:set-tenant-tier");
48
+ }
49
+ if (screen && "access" in screen && screen.access && "roles" in screen.access) {
50
+ expect(screen.access.roles).toEqual(SYSTEM_ADMIN_ROLES);
51
+ }
52
+ if (screen && "layout" in screen) {
53
+ expect(screen.layout.sections[0]).toMatchObject({ description: "tier-admin.explainer" });
54
+ }
55
+ });
56
+
57
+ test("tier-admin's tier field options come from the tierMap", () => {
58
+ const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
59
+ const screen = tierEngine.screens[TIER_ADMIN_SCREEN_ID];
60
+ if (screen && "fields" in screen) {
61
+ expect(screen.fields["tier"]).toEqual({
62
+ type: "select",
63
+ options: ["free", "pro"],
64
+ required: true,
65
+ });
66
+ expect(screen.fields["tenantId"]).toEqual({
67
+ type: "reference",
68
+ entity: "tenant:tenant",
69
+ labelField: "name",
70
+ required: true,
71
+ });
72
+ }
73
+ });
74
+
75
+ test("set-tenant-tier write + get-tenant-tier/tier-options reads stay SystemAdmin-only", () => {
76
+ const tierEngine = createTierEngineFeature({ tierMap: TEST_TIER_MAP });
77
+ const roles = [...SYSTEM_ADMIN_ROLES];
78
+ expect(rolesOf(tierEngine.writeHandlers["set-tenant-tier"]?.access)).toEqual(roles);
79
+ expect(rolesOf(tierEngine.queryHandlers["get-tenant-tier"]?.access)).toEqual(roles);
80
+ expect(rolesOf(tierEngine.queryHandlers["tier-options"]?.access)).toEqual(roles);
81
+ });
82
+ });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.241.0",
4
+ "type": "breaking",
5
+ "title": "TierAdminScreen removed; tier-admin is now a declarative actionForm (fw#2312).",
6
+ "detail": "`tier-engine:screen:tier-admin` is now a declarative `actionForm` (a `reference` field for the tenant, a `select` field for the tier) instead of a custom React component: it dispatches `set-tenant-tier` directly, so the renderer's generic form handles tenant lookup, validation and submit. Two behaviors are intentionally not carried over: the current tier of the selected tenant is no longer shown before submit (declarative forms have no dependent-query support), and the success state no longer names the newly assigned tier (a generic actionForm success doesn't surface write-response data). Both are visible again after a page reload / re-navigation — the assignment itself is unchanged.",
7
+ "migration": "Breaking only if you imported `TierAdminScreen` directly (no shipped consumer app did — all reference the screen by its qualified id `tier-engine:screen:tier-admin`). Remove `import { TierAdminScreen } from \"@cosmicdrift/kumiko-bundled-features/tier-engine/web\"` and any `components: { TierAdminScreen: ... }` entry in your renderer setup — the bundled feature registers the `tier-admin` screen itself now, nothing left for an app to wire."
8
+ }
9
+ ]
@@ -1,9 +1,9 @@
1
1
  // @runtime client
2
- // Pure string-literal QNs/ids — von Server (feature.ts) UND Client
3
- // (web/tier-admin-screen, client-plugin) importiert. Als `client` markiert,
4
- // die im kumiko-Isolation-Modell permissivste Kategorie (runtime darf
5
- // client importieren, client nur client) — sonst wirft der Runtime-
6
- // Isolation-Guard auf den Client-Imports. Muster wie text-content/constants.
2
+ // Pure string-literal QNs/ids — imported by both server (feature.ts) and
3
+ // client (web/client-plugin.tsx). Marked `client`, the most permissive
4
+ // category in the kumiko isolation model (runtime may import client, client
5
+ // may only import client) — otherwise the runtime isolation guard throws on
6
+ // the client imports. Same pattern as text-content/constants.
7
7
  // Feature name
8
8
  export const TIER_ENGINE_FEATURE = "tier-engine" as const;
9
9
 
@@ -69,7 +69,7 @@ import { z } from "zod";
69
69
  import { tenantTable } from "../tenant";
70
70
  import { tierAssignmentAggregateId } from "./aggregate-id";
71
71
  import type { TierMap } from "./compose-app";
72
- import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "./constants";
72
+ import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE, TierEngineHandlers } from "./constants";
73
73
  import { tierAssignmentEntity } from "./entity";
74
74
  import { getActiveTierQuery } from "./handlers/active-tier.query";
75
75
  import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
@@ -187,7 +187,7 @@ export function createTierEngineFeature<
187
187
  >(opts: CreateTierEngineOptions<TCaps> = {}): FeatureDefinition {
188
188
  return defineFeature(TIER_ENGINE_FEATURE, (r) => {
189
189
  r.describe(
190
- 'Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen.',
190
+ 'Stores a `tier-assignment` entity per tenant (which pricing tier is active) and, when configured with a `TierMap`, registers itself as the `tenantTierResolver` extension so the dispatcher automatically gates `r.toggleable()` features per tenant based on their assigned tier. Call `createTierEngineFeature({ defaultTier, tierMap })` to get full tier composition \u2014 including an `inTransaction` entity hook that atomically writes the default tier when a new tenant is created \u2014 or use `createTierEngineFeature()` without options for storage-only mode when you manage tier assignment yourself via `composeApp`. A SystemAdmin-only `set-tenant-tier` write plus `get-tenant-tier`/`tier-options` reads let an operator assign a tier to ANY tenant manually \u2014 without a billing purchase \u2014 stamping `source: "manual"` so a future Stripe\u2192tier sync won\'t overwrite the grant. Apps surface this via the `tier-admin` screen, which is always registered — in storage-only mode (no `tierMap`) it shows an honest "no tiers configured" message instead of a tier dropdown.',
191
191
  );
192
192
  r.uiHints({
193
193
  displayLabel: "Tier Engine \u00b7 Plan Composition",
@@ -252,18 +252,44 @@ export function createTierEngineFeature<
252
252
  }),
253
253
  );
254
254
 
255
- // Custom React-Screen für den manuellen Grant. SystemAdmin-only fest
256
- // verdrahtet (Platform-Admin-Hoheit, nicht App-konfigurierbar). App
257
- // platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
258
- // Komponente liefert tierEngineClient() aus dem ./web-subpath.
255
+ // Declarative form for the manual grant. SystemAdmin-only, hard-wired
256
+ // (platform-admin authority, not app-configurable). Apps place it only
257
+ // via r.nav("tier-engine:screen:tier-admin"); tier options are built
258
+ // statically from the tierMap closure. Always registered — even in
259
+ // storage-only mode (no tierMap) — so the screen shows an honest
260
+ // "no tiers configured" message instead of vanishing (samples like
261
+ // use-all-bundled deliberately mount storage-only to screenshot that
262
+ // empty state).
259
263
  r.translations({ keys: TIER_ENGINE_I18N });
260
- // kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
261
264
  r.screen({
262
265
  id: TIER_ADMIN_SCREEN_ID,
263
- type: "custom",
264
- renderer: { react: { __component: "TierAdminScreen" } },
266
+ type: "actionForm",
267
+ handler: TierEngineHandlers.setTenantTier,
268
+ fields: {
269
+ tenantId: {
270
+ type: "reference",
271
+ entity: "tenant:tenant",
272
+ labelField: "name",
273
+ required: true,
274
+ },
275
+ tier: {
276
+ type: "select",
277
+ options: opts.tierMap ? Object.keys(opts.tierMap) : [],
278
+ required: true,
279
+ },
280
+ },
281
+ layout: {
282
+ sections: [
283
+ {
284
+ fields: ["tenantId", "tier"],
285
+ description: opts.tierMap ? "tier-admin.explainer" : "tier-admin.error.noTiers",
286
+ },
287
+ ],
288
+ },
289
+ submitLabel: "tier-admin.submit",
290
+ cancelTarget: false,
265
291
  description:
266
- "Operator form that picks a tenant, shows its current tier and assigns a new one as a manual grant without a billing purchase.",
292
+ "Operator form that assigns a tier to any tenant as a manual grant without a billing purchase.",
267
293
  access: { roles: ["SystemAdmin"] },
268
294
  });
269
295
 
@@ -1,31 +1,28 @@
1
1
  // @runtime client
2
- // Default-Bundles für den TierAdminScreen. Werden vom tierEngineClient()
3
- // als Fallback-Bundle in den LocaleProvider gehängt — Apps überschreiben
4
- // einzelne Keys via `tierEngineClient({ translations })`.
2
+ // Server + client i18n for the tier-admin actionForm. Single source of
3
+ // truth: r.translations({ keys: TIER_ENGINE_I18N }) registers these
4
+ // server-side, defaultTranslations derives the client-side locale bundle
5
+ // from the same map so the two can't drift.
5
6
 
6
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
+ import {
8
+ type TranslationsByLocale,
9
+ translationsByLocaleFromKeys,
10
+ } from "@cosmicdrift/kumiko-renderer";
7
11
 
8
12
  type LocalizedString = { readonly en: string };
9
13
 
10
14
  export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
11
15
  "screen:tier-admin.title": { en: "Assign tier manually" },
12
- };
13
-
14
- export const defaultTranslations: TranslationsByLocale = {
15
- en: {
16
- "screen:tier-admin.title": "Assign tier manually",
17
- "tier-admin.title": "Assign tier manually",
18
- "tier-admin.explainer":
19
- "Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
20
- "tier-admin.tenant.label": "Tenant",
21
- "tier-admin.current.label": "Current tier",
22
- "tier-admin.current.none": "— none yet —",
23
- "tier-admin.tier.label": "New tier",
24
- "tier-admin.submit": "Assign tier",
25
- "tier-admin.success": "Assigned tier “{tier}”.",
26
- "tier-admin.error.generic": "Could not assign the tier.",
27
- "tier-admin.error.load": "Failed to load tenants.",
28
- "tier-admin.error.noTiers":
29
- "This app has no TierMap configured — there are no assignable tiers.",
16
+ "tier-admin.explainer": {
17
+ en: "Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",
30
18
  },
19
+ "tier-admin.error.noTiers": {
20
+ en: "This app has no TierMap configured — there are no assignable tiers.",
21
+ },
22
+ "tier-engine:entity:__action-form__:field:tenantId": { en: "Tenant" },
23
+ "tier-engine:entity:__action-form__:field:tier": { en: "New tier" },
24
+ "tier-admin.submit": { en: "Assign tier" },
31
25
  };
26
+
27
+ export const defaultTranslations: TranslationsByLocale =
28
+ translationsByLocaleFromKeys(TIER_ENGINE_I18N);
@@ -1,18 +1,15 @@
1
1
  // @runtime client
2
- // Client-Feature-Factory für tier-engine. Liefert den TierAdminScreen
3
- // (gemappt auf die Screen-id "tier-admin") + Default-Translations. Apps
4
- // hängen es in createKumikoApp({ clientFeatures: [tierEngineClient()] }) ein;
5
- // der Screen selbst wird server-seitig vom Feature als custom-Screen
6
- // registriert (r.screen), die App platziert ihn nur via r.nav.
2
+ // Client-feature factory for tier-engine. Supplies the default translations
3
+ // for the declarative tier-admin actionForm (registered server-side via
4
+ // r.screen in the feature); the app places the screen only via r.nav.
7
5
 
8
6
  import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
9
7
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
10
- import { TIER_ADMIN_SCREEN_ID, TIER_ENGINE_FEATURE } from "../constants";
8
+ import { TIER_ENGINE_FEATURE } from "../constants";
11
9
  import { defaultTranslations } from "../i18n";
12
- import { TierAdminScreen } from "./tier-admin-screen";
13
10
 
14
11
  export type TierEngineClientOptions = {
15
- /** Key-weise Overrides über die Default-Bundles (de/en). */
12
+ /** Per-key overrides over the default bundles (de/en). */
16
13
  readonly translations?: TranslationsByLocale;
17
14
  };
18
15
 
@@ -20,8 +17,5 @@ export function tierEngineClient(options?: TierEngineClientOptions): ClientFeatu
20
17
  return {
21
18
  name: TIER_ENGINE_FEATURE,
22
19
  translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
23
- components: {
24
- [TIER_ADMIN_SCREEN_ID]: TierAdminScreen,
25
- },
26
20
  };
27
21
  }
@@ -5,4 +5,3 @@
5
5
  // `@cosmicdrift/kumiko-bundled-features/tier-engine` und hat keine React-Deps.
6
6
 
7
7
  export { type TierEngineClientOptions, tierEngineClient } from "./client-plugin";
8
- export { TierAdminScreen } from "./tier-admin-screen";
@@ -77,3 +77,102 @@ describe("user-data-rights read-only inspector screens", () => {
77
77
  );
78
78
  });
79
79
  });
80
+
81
+ // fw#2312 — privacy-center converted from a custom screen to a declarative
82
+ // projectionDetail (Restriction/Deletion fields+actions, Export stays a
83
+ // custom extension section). `validateBoot` above already proves the
84
+ // screen clears every boot-validator rule for projectionDetail extension
85
+ // sections (entityName set, contributesToFormSubmit not true) — these
86
+ // tests pin the GDPR-relevant shape the type system can't check itself:
87
+ // which handler each action dispatches, the confirm-dialog description,
88
+ // and the visibility conditions carried over from the pre-#2312 custom
89
+ // screen (restrict.tsx/deletion.tsx RestrictionSection/DeletionSection).
90
+ describe("privacy-center screen (fw#2312 projectionDetail conversion)", () => {
91
+ test("is a projectionDetail bound to the user's own `me` query", () => {
92
+ const f = createUserDataRightsFeature();
93
+ const screen = f.screens["privacy-center"];
94
+ expect(screen?.type).toBe("projectionDetail");
95
+ if (screen?.type === "projectionDetail") {
96
+ expect(screen.query).toBe("user:query:user:me");
97
+ expect(screen.access).toEqual({ openToAll: true });
98
+ }
99
+ });
100
+
101
+ test("Export section is a self-persisting extension scoped to export-job", () => {
102
+ const f = createUserDataRightsFeature();
103
+ const screen = f.screens["privacy-center"];
104
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
105
+ const exportSection = screen.layout.sections.find((s) => s.kind === "extension");
106
+ expect(exportSection?.entityName).toBe("export-job");
107
+ expect(exportSection?.contributesToFormSubmit).not.toBe(true);
108
+ });
109
+
110
+ test("`status` renders exactly once across the screen, with a translated enum label", () => {
111
+ const f = createUserDataRightsFeature();
112
+ const screen = f.screens["privacy-center"];
113
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
114
+ const statusFields = screen.layout.sections
115
+ .flatMap((s) => ("fields" in s ? s.fields : []))
116
+ .filter((field) =>
117
+ typeof field === "string" ? field === "status" : field.field === "status",
118
+ );
119
+ expect(statusFields).toHaveLength(1);
120
+ const statusField = statusFields[0];
121
+ expect(typeof statusField === "object" ? statusField.renderer : undefined).toEqual({
122
+ format: "enumOption",
123
+ keyPrefix: "userDataRights.privacyCenter.field.status.option.",
124
+ });
125
+ });
126
+
127
+ test("gracePeriodEnd is hidden unless a deletion is actually pending", () => {
128
+ const f = createUserDataRightsFeature();
129
+ const screen = f.screens["privacy-center"];
130
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
131
+ const graceField = screen.layout.sections
132
+ .flatMap((s) => ("fields" in s ? s.fields : []))
133
+ .find((field) => typeof field === "object" && field.field === "gracePeriodEnd");
134
+ expect(typeof graceField === "object" ? graceField.visible : undefined).toEqual({
135
+ field: "status",
136
+ eq: "deletionRequested",
137
+ });
138
+ });
139
+
140
+ test("Restriction/Deletion actions dispatch the right handlers with the original confirm+visibility rules", () => {
141
+ const f = createUserDataRightsFeature();
142
+ const screen = f.screens["privacy-center"];
143
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
144
+ const byId = Object.fromEntries((screen.actions ?? []).map((a) => [a.id, a]));
145
+
146
+ expect(byId["restrict"]).toMatchObject({
147
+ handler: "user-data-rights:write:restrict-account",
148
+ visible: { field: "status", ne: "restricted" },
149
+ });
150
+ expect(byId["restrict"]?.kind === "navigate" ? undefined : byId["restrict"]?.confirm).toBe(
151
+ "userDataRights.privacyCenter.restriction.dialogDescription",
152
+ );
153
+
154
+ expect(byId["request-deletion"]).toMatchObject({
155
+ handler: "user-data-rights:write:request-deletion",
156
+ visible: { field: "status", ne: "deletionRequested" },
157
+ });
158
+ // Cancel has no confirm — the pre-#2312 custom screen executed it directly.
159
+ expect(byId["cancel-deletion"]).toMatchObject({
160
+ handler: "user-data-rights:write:cancel-deletion",
161
+ visible: { field: "status", eq: "deletionRequested" },
162
+ });
163
+ expect((byId["cancel-deletion"] as { readonly confirm?: string }).confirm).toBeUndefined();
164
+ });
165
+
166
+ test("privacyCenterShowDeletion: false drops the deletion section + its actions", () => {
167
+ const f = createUserDataRightsFeature({ privacyCenterShowDeletion: false });
168
+ const screen = f.screens["privacy-center"];
169
+ if (screen?.type !== "projectionDetail") throw new Error("expected projectionDetail");
170
+ expect(
171
+ screen.layout.sections.some((s) => s.title === "userDataRights.privacyCenter.deletion.title"),
172
+ ).toBe(false);
173
+ const actionIds = (screen.actions ?? []).map((a) => a.id);
174
+ expect(actionIds).not.toContain("request-deletion");
175
+ expect(actionIds).not.toContain("cancel-deletion");
176
+ expect(actionIds).toContain("restrict");
177
+ });
178
+ });
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.241.0",
4
+ "type": "breaking",
5
+ "title": "privacy-center is now a declarative screen; userDataRightsClient's privacyCenter.showDeletion option is removed (fw#2312).",
6
+ "detail": "`privacy-center` is now a declarative `projectionDetail` screen instead of a custom React component: the Restriction and Deletion sections render through the generic renderer (`EditFieldsSection` + `actions: RowAction[]`), preserving the original confirmation dialogs (`RowActionWriteHandler.confirm`) and visibility rules (`visible: {field, eq/ne}`) 1:1. The Export section (Art. 20) stays a custom `EditExtensionSection` — it needs polling + a signed-URL download — registered via the new `ClientFeatureDefinition.extensionSectionComponents` under `EXPORT_SECTION_EXTENSION_NAME`. `PrivacyCenterScreen` is a dead export (the renderer selects the screen by `screen.type`, not the client component registry). The single `status` field now renders through the `enumOption` format instead of the raw enum string; `gracePeriodEnd` is hidden when no deletion is pending instead of showing an empty date. Known UI regression, accepted for this pass: the confirm-dialog title is now always the action's `label` (the renderer hardcodes this) — the original's distinct `dialogTitle` copy and its dynamic composed banner sentence (e.g. \"Your account will be deleted on {date}\") are gone, replaced by the translated `status` label plus a separately labeled `gracePeriodEnd` date.",
7
+ "migration": "A declarative screen is registered once and can no longer be toggled per-app on the client: `userDataRightsClient(options)`'s `privacyCenter: { showDeletion }` option is removed. Move the flag server-side instead — replace `createUserDataRightsFeature({})` with `createUserDataRightsFeature({ privacyCenterShowDeletion: false })` (default `true`) — it conditionally omits the Deletion section and its `request-deletion`/`cancel-deletion` actions from the screen definition. Then drop the now-unused `privacyCenter` option from the matching `userDataRightsClient({ privacyCenter: { showDeletion: false } })` call. Known affected consumer: `money-horse` (`src/app/client-features.tsx:74`)."
8
+ },
2
9
  {
3
10
  "version": "0.165.1",
4
11
  "type": "fix",
@@ -1,15 +1,29 @@
1
1
  // @runtime client
2
- // Reine String-Konstanten — client-markiert, damit der PrivacyCenterScreen
3
- // (web/) sie importieren darf, ohne das runtime-Barrel des Features (und
4
- // damit dessen Server-/DOM-freien Code) zu ziehen. Runtime-Code
5
- // (feature.ts) darf client-Dateien ohnehin importieren.
2
+ // Reine String-Konstanten — client-markiert, damit web/ (z.B. ExportSection)
3
+ // sie importieren darf, ohne das runtime-Barrel des Features (und damit
4
+ // dessen Server-/DOM-freien Code) zu ziehen. Runtime-Code (feature.ts) darf
5
+ // client-Dateien ohnehin importieren.
6
6
 
7
7
  export const USER_DATA_RIGHTS_FEATURE = "user-data-rights" as const;
8
8
 
9
- // Dormant registriert (kein r.nav im Feature); Apps platzieren ihn via
10
- // r.nav. Qualifiziert: `user-data-rights:screen:privacy-center`.
9
+ // Registered without r.nav — apps place it via their own r.nav. Qualified:
10
+ // `user-data-rights:screen:privacy-center`.
11
11
  export const PRIVACY_CENTER_SCREEN_ID = "privacy-center" as const;
12
12
 
13
+ // Extension-section component name for the Export (Art. 20) section of the
14
+ // privacy-center screen — shared between feature.ts (screen def's
15
+ // `component: { react: { __component } }`) and web/client-plugin.tsx
16
+ // (extensionSectionComponents registration), same pattern as
17
+ // compliance-profiles' COMPLIANCE_PROFILE_CATALOG_EXTENSION_NAME.
18
+ export const EXPORT_SECTION_EXTENSION_NAME = "UserDataRightsExportSection" as const;
19
+
20
+ // enumOption keyPrefix for the privacy-center screen's `status` field
21
+ // (fw#2315 pattern) — resolves the raw user-lifecycle enum value
22
+ // (active/restricted/deletionRequested/deleted) to a translated label
23
+ // instead of showing the raw string.
24
+ export const STATUS_OPTION_KEY_PREFIX =
25
+ "userDataRights.privacyCenter.field.status.option." as const;
26
+
13
27
  export const UserDataRightsQueries = {
14
28
  exportStatus: "user-data-rights:query:export-status",
15
29
  myAuditLog: "user-data-rights:query:my-audit-log",