@cosmicdrift/kumiko-framework 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 (73) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/store-table.integration.test.ts +2 -1
  3. package/src/__tests__/upgrade-cli.test.ts +81 -12
  4. package/src/api/__tests__/request-locale.integration.test.ts +2 -2
  5. package/src/api/api-constants.ts +10 -0
  6. package/src/api/index.ts +1 -0
  7. package/src/api/server.ts +17 -1
  8. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  9. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  10. package/src/bun-db/index.ts +1 -0
  11. package/src/bun-db/query.ts +30 -14
  12. package/src/crypto/index.ts +1 -0
  13. package/src/crypto/is-self-pii-field.ts +8 -0
  14. package/src/crypto/subject-resolver.ts +4 -3
  15. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  16. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  17. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  18. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  19. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  20. package/src/db/blind-index-cleanup.ts +36 -19
  21. package/src/db/event-store-executor-context.ts +2 -2
  22. package/src/db/event-store-executor-read.ts +7 -6
  23. package/src/db/event-store-executor-write.ts +103 -49
  24. package/src/db/index.ts +2 -0
  25. package/src/db/migrate-generator.ts +14 -0
  26. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  27. package/src/db/queries/backfill-pii.ts +13 -10
  28. package/src/db/queries/raw-sql.ts +14 -2
  29. package/src/db/queries/seed-context.ts +8 -4
  30. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  31. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  32. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  33. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  34. package/src/engine/__tests__/schema-builder.test.ts +3 -3
  35. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  36. package/src/engine/boot-validator/entity-handler.ts +5 -0
  37. package/src/engine/boot-validator/pii-retention.ts +16 -4
  38. package/src/engine/boot-validator/screens.ts +9 -2
  39. package/src/engine/embedded-derived.ts +11 -10
  40. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  41. package/src/engine/feature-ast/patch.ts +9 -0
  42. package/src/engine/role-assignment.ts +36 -18
  43. package/src/errors/__tests__/classes.test.ts +5 -0
  44. package/src/errors/__tests__/write-failures.test.ts +3 -3
  45. package/src/errors/kumiko-error.ts +11 -11
  46. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  47. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  48. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  49. package/src/event-store/event-store.ts +7 -0
  50. package/src/event-store/index.ts +1 -0
  51. package/src/files/__tests__/files.integration.test.ts +181 -2
  52. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  53. package/src/files/file-routes.ts +53 -6
  54. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  55. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  56. package/src/i18n/index.ts +7 -1
  57. package/src/i18n/mail-registry.ts +10 -0
  58. package/src/i18n/request-locale.ts +11 -2
  59. package/src/i18n/required-surface-keys.ts +3 -1
  60. package/src/lifecycle/signal-handlers.ts +2 -0
  61. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  62. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +26 -42
  63. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +8 -6
  64. package/src/pipeline/distributed-lock.ts +3 -0
  65. package/src/schema-cli.ts +9 -4
  66. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  67. package/src/scripts/codemod/pii-personal-migration.ts +30 -14
  68. package/src/search/purge-subject.ts +4 -3
  69. package/src/search/reindex-entity.ts +2 -2
  70. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  71. package/src/stack/test-stack.ts +3 -0
  72. package/src/ui-types/index.ts +1 -0
  73. package/src/upgrade-cli.ts +100 -12
@@ -2264,6 +2264,8 @@ describe("boot-validator", () => {
2264
2264
  readonly fields: readonly string[];
2265
2265
  }>;
2266
2266
  readonly configKeys?: Readonly<Record<string, string>>;
2267
+ readonly mode?: "single" | "wizard";
2268
+ readonly draft?: boolean;
2267
2269
  };
2268
2270
 
2269
2271
  function makeFeature(override: ConfigEditOverride = {}) {
@@ -2291,7 +2293,11 @@ describe("boot-validator", () => {
2291
2293
  scope: "tenant",
2292
2294
  configKeys,
2293
2295
  fields: fields as never,
2294
- layout: { sections: sections as never },
2296
+ layout: {
2297
+ sections: sections as never,
2298
+ ...(override.mode !== undefined ? { mode: override.mode } : {}),
2299
+ ...(override.draft !== undefined ? { draft: override.draft } : {}),
2300
+ },
2295
2301
  });
2296
2302
  });
2297
2303
  }
@@ -2383,6 +2389,26 @@ describe("boot-validator", () => {
2383
2389
  const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
2384
2390
  expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
2385
2391
  });
2392
+ test("mode: wizard mit nur 1 Section → Throw", () => {
2393
+ expect(() =>
2394
+ validateBoot([
2395
+ makeFeature({
2396
+ mode: "wizard",
2397
+ sections: [{ title: "Basics", fields: ["siteName"] }],
2398
+ }),
2399
+ ]),
2400
+ ).toThrow(/\(configEdit\).*mode: "wizard" but only 1 section\(s\)/);
2401
+ });
2402
+
2403
+ test("draft: true ohne gemountetes form-draft-Feature → Throw", () => {
2404
+ const sections = [
2405
+ { title: "Step 1", fields: ["siteName"] },
2406
+ { title: "Step 2", fields: ["maxUploadMb"] },
2407
+ ];
2408
+ expect(() => validateBoot([makeFeature({ mode: "wizard", sections, draft: true })])).toThrow(
2409
+ /"form-draft" is not mounted/,
2410
+ );
2411
+ });
2386
2412
  });
2387
2413
 
2388
2414
  // --- entityEdit extension section ---
@@ -3245,7 +3271,7 @@ describe("boot-validator", () => {
3245
3271
  label: "actions.view",
3246
3272
  screen: "product-detail",
3247
3273
  entity: "product",
3248
- },
3274
+ } as never,
3249
3275
  ],
3250
3276
  });
3251
3277
  r.screen({
@@ -3268,7 +3294,7 @@ describe("boot-validator", () => {
3268
3294
  type: "entityList",
3269
3295
  entity: "product",
3270
3296
  columns: ["name"],
3271
- rowActions: [{ kind: "navigate", id: "view", label: "actions.view" }],
3297
+ rowActions: [{ kind: "navigate", id: "view", label: "actions.view" } as never],
3272
3298
  });
3273
3299
  });
3274
3300
  expect(() => validateBoot([feature])).toThrow(
@@ -1,35 +1,39 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { findForbiddenRoleAssignment } from "../role-assignment";
3
3
 
4
+ // Mirror DEFAULT_INVITE_ROLE_OPTIONS — framework must not import bundled-features
5
+ // (tsc pulls source into framework's rootDir and fails the package build).
6
+ const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
7
+
4
8
  describe("role assignment guard", () => {
5
9
  test("rejects roles above the actor's highest role", () => {
6
- expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"])).toBe("TenantAdmin");
7
- expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"])).toBe("SystemAdmin");
10
+ expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"], [])).toBe("TenantAdmin");
11
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"], [])).toBe("SystemAdmin");
8
12
  });
9
13
 
10
14
  test("allows equal or lower roles, including self-updates", () => {
11
- expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"])).toBeUndefined();
12
- expect(findForbiddenRoleAssignment(["Admin"], ["Editor"])).toBeUndefined();
13
- expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"])).toBeUndefined();
14
- expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"])).toBeUndefined();
15
- expect(findForbiddenRoleAssignment(["Admin"], ["Admin"])).toBeUndefined();
16
- expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"])).toBeUndefined();
17
- expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"])).toBeUndefined();
15
+ expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"], [])).toBeUndefined();
16
+ expect(findForbiddenRoleAssignment(["Admin"], ["Editor"], [])).toBeUndefined();
17
+ expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"], [])).toBeUndefined();
18
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"], [])).toBeUndefined();
19
+ expect(findForbiddenRoleAssignment(["Admin"], ["Admin"], [])).toBeUndefined();
20
+ expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"], [])).toBeUndefined();
21
+ expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"], [])).toBeUndefined();
18
22
  });
19
23
 
20
24
  test("rejects Editor above User, Admin above Editor", () => {
21
- expect(findForbiddenRoleAssignment(["User"], ["Editor"])).toBe("Editor");
22
- expect(findForbiddenRoleAssignment(["Editor"], ["Admin"])).toBe("Admin");
25
+ expect(findForbiddenRoleAssignment(["User"], ["Editor"], [])).toBe("Editor");
26
+ expect(findForbiddenRoleAssignment(["Editor"], ["Admin"], [])).toBe("Admin");
23
27
  });
24
28
 
25
29
  test("rejects unknown roles (fail-closed)", () => {
26
- expect(findForbiddenRoleAssignment(["User"], ["Custom"])).toBe("Custom");
27
- expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"])).toBe("Custom");
30
+ expect(findForbiddenRoleAssignment(["User"], ["Custom"], [])).toBe("Custom");
31
+ expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"], [])).toBe("Custom");
28
32
  });
29
33
 
30
34
  test("rejects assignment if actor has no roles or only unknown roles", () => {
31
- expect(findForbiddenRoleAssignment([], ["User"])).toBe("User");
32
- expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"])).toBe("User");
35
+ expect(findForbiddenRoleAssignment([], ["User"], [])).toBe("User");
36
+ expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"], [])).toBe("User");
33
37
  });
34
38
 
35
39
  test("rejects modifying target user with higher existing role", () => {
@@ -41,7 +45,27 @@ describe("role assignment guard", () => {
41
45
  // Editor is ranked (invite options); use a true app-defined role here.
42
46
  expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User"], ["Billing"])).toBeUndefined();
43
47
  expect(findForbiddenRoleAssignment(["Admin"], ["User"], ["Billing", "User"])).toBeUndefined();
44
- // Still cannot assign the unranked role on the write path (fail-closed).
45
- expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], ["Billing"])).toBe("Billing");
48
+ // Cannot introduce a *new* unranked role (fail-closed).
49
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], [])).toBe("Billing");
50
+ // Round-trip: strip then re-assign an unranked role the target already had.
51
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], ["Billing"])).toBeUndefined();
52
+ expect(
53
+ findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Billing"], ["Billing"]),
54
+ ).toBeUndefined();
55
+ });
56
+
57
+ test("prototype-polluting role names do not bypass the guard", () => {
58
+ expect(findForbiddenRoleAssignment(["constructor"], ["SystemAdmin"], [])).toBe("SystemAdmin");
59
+ expect(findForbiddenRoleAssignment(["SystemAdmin"], ["toString"], [])).toBe("toString");
60
+ });
61
+
62
+ test("empty-string role is forbidden (not truthiness-skipped)", () => {
63
+ expect(findForbiddenRoleAssignment(["Admin"], [""], [])).toBe("");
64
+ });
65
+
66
+ test("TenantAdmin can assign every default invite role", () => {
67
+ for (const role of DEFAULT_INVITE_ROLE_OPTIONS) {
68
+ expect(findForbiddenRoleAssignment(["TenantAdmin"], [role], [])).toBeUndefined();
69
+ }
46
70
  });
47
71
  });
@@ -1019,7 +1019,7 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
1019
1019
  const schema = buildInsertSchema(entity);
1020
1020
  // Each source is in-scale for its own scale-3 field, but the sum
1021
1021
  // (0.333) has 3 decimal digits — over-scale for the scale-2 target
1022
- // without the rounding this PR added.
1022
+ // without the rounding from #1867 (roundDerivedCellValue).
1023
1023
  const result = schema.safeParse({ lines: [{ a: 0.111, b: 0.111, c: 0.111 }] });
1024
1024
  expect(result.success).toBe(true);
1025
1025
  if (result.success) {
@@ -1043,8 +1043,8 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
1043
1043
  },
1044
1044
  });
1045
1045
  const schema = buildInsertSchema(entity);
1046
- // 2400.58 - 93 = 2307.58 minor units — not representable by money's
1047
- // integer constraint without rounding.
1046
+ // 2400.58 - 93 = 2307.58 major units — not representable by money's
1047
+ // integer minor-unit constraint without rounding (#1867).
1048
1048
  const result = schema.safeParse({ lines: [{ gross: 2400.58, refund: 93 }] });
1049
1049
  expect(result.success).toBe(true);
1050
1050
  if (result.success) {
@@ -1,8 +1,28 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { buildConfigFeatureSchema } from "../../build-config-feature-schema";
2
3
  import { access, createTenantConfig } from "../../config-helpers";
3
4
  import { defineFeature } from "../../define-feature";
5
+ import { createRegistry } from "../../registry";
6
+ import { isFieldsEditSection } from "../../screen-helpers";
7
+ import type { ConfigEditScreenDefinition } from "../../types";
4
8
  import { validateBoot } from "../index";
5
9
 
10
+ // Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
11
+ // labels every masked config key's generated hub requires regardless of
12
+ // which feature owns the key or which scope its keys are visible at
13
+ // (requiredKeysFromGeneratedConfigHub adds all four unconditionally once
14
+ // any masked key exists, see i18n-keys.ts:35-38).
15
+ const configHub = defineFeature("config", (r) => {
16
+ r.translations({
17
+ keys: {
18
+ "config.settings.title": { en: "Settings" },
19
+ "config.settings.system": { en: "Platform" },
20
+ "config.settings.tenant": { en: "Tenant" },
21
+ "config.settings.user": { en: "Personal" },
22
+ },
23
+ });
24
+ });
25
+
6
26
  // fw#2260: the Settings-Hub generator (buildConfigFeatureSchema) labels a
7
27
  // masked config key's generated nav entry / configEdit section with the
8
28
  // dot-form key `${feature}.settings` — never colon-form. isI18nKey's
@@ -10,20 +30,6 @@ import { validateBoot } from "../index";
10
30
  // feature could ship a generated Settings screen whose label was never
11
31
  // translated and boot validation stayed silent.
12
32
  describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#2260)", () => {
13
- // Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
14
- // labels every masked config key's generated hub requires regardless of
15
- // which feature owns the key.
16
- const configHub = defineFeature("config", (r) => {
17
- r.translations({
18
- keys: {
19
- "config.settings.title": { en: "Settings" },
20
- "config.settings.system": { en: "Platform" },
21
- "config.settings.tenant": { en: "Organization" },
22
- "config.settings.user": { en: "Personal" },
23
- },
24
- });
25
- });
26
-
27
33
  function billingFeature(translationKeys: Record<string, { readonly en: string }>) {
28
34
  return defineFeature("billing", (r) => {
29
35
  r.config({
@@ -61,4 +67,131 @@ describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#
61
67
  });
62
68
  expect(() => validateBoot([configHub, billing])).not.toThrow();
63
69
  });
70
+
71
+ test("group namespace ≠ feature name: blank translation key satisfies hub label", () => {
72
+ const billing = defineFeature("billing", (r) => {
73
+ r.config({
74
+ keys: {
75
+ apiKey: createTenantConfig("text", {
76
+ write: access.roles("TenantAdmin"),
77
+ group: "tenant-settings",
78
+ mask: { title: "billing.api-key" },
79
+ }),
80
+ },
81
+ });
82
+ r.translations({
83
+ keys: {
84
+ "billing.api-key": { en: "API Key" },
85
+ "screen:tenant-settings-tenant.title": { en: "Tenant Settings" },
86
+ "tenant-settings.settings": { en: "Tenant Settings" },
87
+ },
88
+ });
89
+ });
90
+ expect(() => validateBoot([configHub, billing])).not.toThrow();
91
+ });
92
+ });
93
+
94
+ // PR #2314 idx2: buildConfigFeatureSchema varies the generated dot-form label
95
+ // along two more paths that fw#2260's tests above never exercise — a tenant
96
+ // key with an elevated (SystemAdmin) write role surfaces the same feature
97
+ // under a second, broader scope (build-config-feature-schema.ts:142-158), and
98
+ // a feature can opt into a configEdit section description
99
+ // (build-config-feature-schema.ts:246-249). The `group`-namespace path is
100
+ // deliberately left untested here — fw#2314 idx1 tracks it as broken
101
+ // (treatDotFormAsKey requires an unsatisfiable `${group}.settings` key for a
102
+ // namespace that has no matching feature), and a test would either fail on
103
+ // the open bug or paper over it; it belongs with the idx1 fix, not this batch.
104
+ describe("validateI18nSurfaceKeys — scoped label fallback for elevated write roles (PR #2314 idx2)", () => {
105
+ function opsFeature(translationKeys: Record<string, { readonly en: string }>) {
106
+ return defineFeature("ops", (r) => {
107
+ r.config({
108
+ keys: {
109
+ // Home scope tenant + an elevated SystemAdmin write role surfaces
110
+ // "ops" under BOTH the tenant audience (home, full write set) and
111
+ // the system audience (cascade, write set ∩ ELEVATED_ROLES.system)
112
+ // — two navs/screens for one feature.
113
+ maintenanceMode: createTenantConfig("boolean", {
114
+ write: access.roles("TenantAdmin", "SystemAdmin"),
115
+ mask: { title: "ops.maintenance-mode" },
116
+ }),
117
+ },
118
+ });
119
+ if (Object.keys(translationKeys).length > 0) {
120
+ r.translations({ keys: translationKeys });
121
+ }
122
+ });
123
+ }
124
+
125
+ test("elevated write role surfaces the feature at two scopes; the scoped override labels the system nav while the tenant nav falls back to the plain key", () => {
126
+ const translations = {
127
+ "ops.maintenance-mode": { en: "Maintenance Mode" },
128
+ "screen:ops-tenant.title": { en: "Ops Settings" },
129
+ "screen:ops-system.title": { en: "Ops Settings (Platform)" },
130
+ // Required unconditionally: both screens' section title is always
131
+ // `${feature}.settings` (build-config-feature-schema.ts:248).
132
+ "ops.settings": { en: "Ops" },
133
+ // Opt-in scoped override — only the system (cascade) nav uses it.
134
+ "ops.settings.system": { en: "Ops (Platform Cascade)" },
135
+ };
136
+ const ops = opsFeature(translations);
137
+
138
+ const schema = buildConfigFeatureSchema(createRegistry([configHub, ops]));
139
+ expect(schema.navs.find((n) => n.id === "ops-system")?.label).toBe("ops.settings.system");
140
+ expect(schema.navs.find((n) => n.id === "ops-tenant")?.label).toBe("ops.settings");
141
+
142
+ expect(() => validateBoot([configHub, ops])).not.toThrow();
143
+ });
144
+ });
145
+
146
+ // isI18nKeys.ts's requiredKeysFromScreen never reads EditFieldsSection.description
147
+ // (only title + field labels, see required-surface-keys.ts:210-224), so this
148
+ // gate is invisible to validateBoot either way — a validateBoot(...).not.toThrow()
149
+ // assertion alone would pass identically with build-config-feature-schema.ts:249
150
+ // deleted outright. Assert on the generated schema itself instead, the only
151
+ // artifact the gate actually changes.
152
+ describe("validateI18nSurfaceKeys — gated configEdit section description (PR #2314 idx2)", () => {
153
+ function reportingFeature(translationKeys: Record<string, { readonly en: string }>) {
154
+ return defineFeature("reporting", (r) => {
155
+ r.config({
156
+ keys: {
157
+ retentionDays: createTenantConfig("number", {
158
+ write: access.roles("TenantAdmin"),
159
+ mask: { title: "reporting.retention-days" },
160
+ }),
161
+ },
162
+ });
163
+ r.translations({ keys: translationKeys });
164
+ });
165
+ }
166
+
167
+ function reportingSection(translationKeys: Record<string, { readonly en: string }>) {
168
+ const reporting = reportingFeature(translationKeys);
169
+ const schema = buildConfigFeatureSchema(createRegistry([configHub, reporting]));
170
+ const screen = schema.screens.find(
171
+ (s): s is ConfigEditScreenDefinition => s.type === "configEdit",
172
+ );
173
+ if (screen === undefined) throw new Error("expected a generated configEdit screen");
174
+ const section = screen.layout.sections[0];
175
+ if (section === undefined || !isFieldsEditSection(section)) {
176
+ throw new Error("expected a fields section");
177
+ }
178
+ return section;
179
+ }
180
+
181
+ test("declaring 'reporting.settings.description' adds it to the generated section; omitting it leaves the section without one", () => {
182
+ const withDescription = reportingSection({
183
+ "reporting.retention-days": { en: "Retention (days)" },
184
+ "screen:reporting-tenant.title": { en: "Reporting Settings" },
185
+ "reporting.settings": { en: "Reporting" },
186
+ "reporting.settings.description": { en: "Configure how long reports are kept." },
187
+ });
188
+ expect(withDescription.description).toBe("reporting.settings.description");
189
+
190
+ const withoutDescription = reportingSection({
191
+ "reporting.retention-days": { en: "Retention (days)" },
192
+ "screen:reporting-tenant.title": { en: "Reporting Settings" },
193
+ "reporting.settings": { en: "Reporting" },
194
+ });
195
+ expect(withoutDescription.description).toBeUndefined();
196
+ });
64
197
  });
@@ -587,6 +587,11 @@ function validateEmbeddedDerivedCells(
587
587
  `Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`,
588
588
  );
589
589
  }
590
+ if (sourceName in field.derived) {
591
+ throw new Error(
592
+ `Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}".`,
593
+ );
594
+ }
590
595
  }
591
596
  }
592
597
  }
@@ -218,20 +218,32 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
218
218
  }
219
219
 
220
220
  if (retention.strategy === "blockDelete") {
221
- // blockDelete on an entity with no subject field is the correct
222
- // "never auto-delete" choice; User-Forget never reaches those rows (#1622).
223
- const hasSubjectField = Object.values(fieldsByName).some(
221
+ // blockDelete with no field that can carry `anonymize` is fine;
222
+ // subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
223
+ // instead (#1622, #2336).
224
+ const entityHasAnonymizableSubjectField = Object.values(fieldsByName).some(
224
225
  (f) => hasAnonymizableSubjectField(f as ResolvedPiiFlags), // @cast-boundary schema-walk
225
226
  );
226
227
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
227
228
  const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
228
229
  return Boolean(a.anonymize);
229
230
  });
230
- if (hasSubjectField && !hasAnonymize) {
231
+ if (entityHasAnonymizableSubjectField && !hasAnonymize) {
231
232
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
232
233
  console.warn(
233
234
  `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
234
235
  );
236
+ } else if (!entityHasAnonymizableSubjectField) {
237
+ const hasSubjectRef = Object.values(fieldsByName).some((f) => {
238
+ const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
239
+ return Boolean(a.subjectRef);
240
+ });
241
+ if (hasSubjectRef) {
242
+ // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
243
+ console.warn(
244
+ `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" with subjectRef-only PII relies on the EXT_USER_DATA delete hook for Art.17 — make sure it is not a no-op.`,
245
+ );
246
+ }
235
247
  }
236
248
  }
237
249
  }
@@ -20,7 +20,7 @@ import type {
20
20
  EditLayout,
21
21
  FieldCondition,
22
22
  RowAction,
23
- RowActionNavigate,
23
+ RowActionNavigateBase,
24
24
  RowFieldExtractor,
25
25
  ScreenDefinition,
26
26
  ToolbarAction,
@@ -345,12 +345,19 @@ function validateToolbarDrawerAction(
345
345
  // call sites so the mutual-exclusivity check and the entity→detailFor
346
346
  // resolution don't drift between them (same drift risk
347
347
  // validateRowActionNavigateParams above is already shared to avoid).
348
+ // Runtime-loose shape: boot still rejects both/neither for untyped schemas;
349
+ // authors get the exclusive union via RowActionNavigate (#2303).
350
+ type RowActionNavigateRuntime = RowActionNavigateBase & {
351
+ readonly screen?: string;
352
+ readonly entity?: string;
353
+ };
354
+
348
355
  function resolveRowActionNavigateTarget(
349
356
  featureName: string,
350
357
  screenId: string,
351
358
  screenType: "entityList" | "projectionList" | "projectionDetail",
352
359
  actionLabel: "rowAction" | "action",
353
- action: RowActionNavigate,
360
+ action: RowActionNavigateRuntime,
354
361
  allScreenQns: ReadonlySet<string>,
355
362
  navTargetShortIds: ReadonlySet<string>,
356
363
  screensByShortId: ReadonlyMap<
@@ -1,13 +1,11 @@
1
1
  import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "./types";
2
2
 
3
3
  /** Computes a derived cell from its source values. Missing/non-numeric
4
- * sources are treated as 0 for "sum"/"subtract"; "multiply" with any
5
- * missing source returns undefined (an incomplete product isn't a
6
- * meaningful partial value). Money cells are minor-unit integers — this
7
- * function is unit-agnostic, it just does arithmetic on whatever numbers
8
- * it's given (caller passes minor units for money, not major/float).
9
- * `withDerivedCells` rounds the result to the target sub-field's declared
10
- * precision afterward. */
4
+ * sources: "multiply" with any missing source → undefined; "sum"/"subtract"
5
+ * treat missing as 0 only when at least one source is present — if every
6
+ * source is missing, return undefined (do not invent 0). Money cells are
7
+ * minor-unit integers unit-agnostic arithmetic; `withDerivedCells` rounds
8
+ * to the target sub-field's declared precision afterward. */
11
9
  export function computeDerivedCellValue(
12
10
  op: EmbeddedDerivedCellDef["op"],
13
11
  values: readonly (number | undefined)[],
@@ -16,6 +14,7 @@ export function computeDerivedCellValue(
16
14
  if (values.some((value) => value === undefined)) return undefined;
17
15
  return (values as readonly number[]).reduce((product, value) => product * value, 1);
18
16
  }
17
+ if (values.every((value) => value === undefined)) return undefined;
19
18
  const numeric = values.map((value) => value ?? 0);
20
19
  if (op === "sum") return numeric.reduce((sum, value) => sum + value, 0);
21
20
  // subtract: first value minus every subsequent value.
@@ -60,8 +59,8 @@ function roundHalfAwayFromZero(value: number, decimals: number): number {
60
59
  /** Recomputes every derived cell of an embedded-list row from its raw
61
60
  * values, overwriting whatever the client sent instead of merely checking
62
61
  * it — the server is the authority for derived cells. Reads source values
63
- * from the original row (never from an already-recomputed derived cell),
64
- * so the iteration order of `derived` never matters. A row that isn't a
62
+ * from the working copy so already-recomputed sources feed later cells;
63
+ * boot-validator rejects derived-from-derived chains. A row that isn't a
65
64
  * plain object (already invalid, or not this field's shape) passes through
66
65
  * untouched — validation downstream rejects it. The computed value is
67
66
  * rounded to the target sub-field's declared precision (`schema`) before
@@ -77,11 +76,13 @@ export function withDerivedCells(
77
76
  const copy: Record<string, unknown> = { ...source };
78
77
  for (const [cellName, def] of Object.entries(derived)) {
79
78
  const sourceValues = def.from.map((sourceField) => {
80
- const value = source[sourceField];
79
+ const value = copy[sourceField];
81
80
  return typeof value === "number" ? value : undefined;
82
81
  });
83
82
  const computed = computeDerivedCellValue(def.op, sourceValues);
84
83
  if (computed === undefined) {
84
+ // Always clear — keeping a client value would let callers spoof required
85
+ // derived cells when sources are missing (server must stay authoritative).
85
86
  delete copy[cellName];
86
87
  } else {
87
88
  const target = schema[cellName];
@@ -391,6 +391,16 @@ describe("addPattern — error paths", () => {
391
391
  const sf = makeSourceFile("export const x = 1;");
392
392
  expect(() => addPattern(sf, newSecretPattern)).toThrow(/no defineFeature/);
393
393
  });
394
+
395
+ test("throws for ai.* kinds (must target a workflow steps array)", () => {
396
+ const sf = makeSourceFile(`
397
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
398
+ defineFeature("ai-host", (r) => {});
399
+ `);
400
+ expect(() =>
401
+ addPattern(sf, { kind: "ai.generate", stepKey: "generate" } as FeaturePattern),
402
+ ).toThrow(/ai\.generate steps must be inserted into a defineWorkflow/);
403
+ });
394
404
  });
395
405
 
396
406
  describe("callMatchesId — positional and object-form lookups", () => {
@@ -147,6 +147,15 @@ export function applyChanges(sourceFile: SourceFile, changes: readonly PatternCh
147
147
  * biome-stable formatting that matches the renderFeatureFile output.
148
148
  */
149
149
  export function addPattern(sourceFile: SourceFile, pattern: FeaturePattern): void {
150
+ if (
151
+ pattern.kind === "ai.generate" ||
152
+ pattern.kind === "ai.extract" ||
153
+ pattern.kind === "ai.classify"
154
+ ) {
155
+ throw new Error(
156
+ `addPattern: ${pattern.kind} steps must be inserted into a defineWorkflow steps array, not the setup body`,
157
+ );
158
+ }
150
159
  const setup = findSetupCallback(sourceFile);
151
160
  if (!setup) {
152
161
  throw new Error("addPattern: no defineFeature(name, (r) => { ... }) call found");
@@ -1,45 +1,63 @@
1
- const ROLE_RANKS: Readonly<Record<string, number>> = {
2
- User: 0,
1
+ // Prototype-free rank table Object literals leak Object.prototype
2
+ // (`constructor`, `toString`, …) into `ROLE_RANKS[role]` lookups and turn
3
+ // Math.max into NaN, which fails every `rank > actorRank` check open.
4
+ const ROLE_RANKS = new Map<string, number>([
5
+ ["User", 0],
3
6
  // Matches DEFAULT_INVITE_ROLE_OPTIONS — must stay ranked or invite UI fails closed.
4
- Editor: 1,
5
- Admin: 2,
6
- TenantAdmin: 3,
7
- SystemAdmin: 4,
8
- system: 5,
9
- };
7
+ ["Editor", 1],
8
+ ["Admin", 2],
9
+ ["TenantAdmin", 3],
10
+ ["SystemAdmin", 4],
11
+ ["system", 5],
12
+ ]);
13
+
14
+ // Unknown roles: +∞ on the assigned path (cannot grant what we don't know),
15
+ // -1 on the actor path (cannot elevate via an unrecognized self-role).
16
+ function roleRankOr(role: string, unknownRank: number): number {
17
+ return ROLE_RANKS.get(role) ?? unknownRank;
18
+ }
10
19
 
11
20
  function getRoleRank(role: string): number {
12
- return ROLE_RANKS[role] ?? Number.POSITIVE_INFINITY;
21
+ return roleRankOr(role, Number.POSITIVE_INFINITY);
13
22
  }
14
23
 
15
24
  function maxRoleRank(roles: readonly string[]): number {
16
25
  if (roles.length === 0) return -1;
17
- return Math.max(...roles.map((role) => ROLE_RANKS[role] ?? -1));
26
+ return Math.max(...roles.map((role) => roleRankOr(role, -1)));
18
27
  }
19
28
 
20
29
  /** Known built-in rank only — unranked app roles (Billing, …) are not privilege tiers. */
21
30
  function knownRoleRank(role: string): number | undefined {
22
- return ROLE_RANKS[role];
31
+ return ROLE_RANKS.get(role);
23
32
  }
24
33
 
25
34
  export function findForbiddenRoleAssignment(
26
35
  actorRoles: readonly string[],
27
36
  assignedRoles: readonly string[],
28
- targetCurrentRoles: readonly string[] = [],
37
+ // Required so callers cannot accidentally disable the downgrade/
38
+ // takeover guard by omitting the third argument (new users: pass []).
39
+ targetCurrentRoles: readonly string[],
29
40
  ): string | undefined {
30
41
  const actorRank = maxRoleRank(actorRoles);
31
- // Assign path: fail-closed on unknown / above-actor roles.
32
- const forbiddenAssigned = assignedRoles.find((role) => getRoleRank(role) > actorRank);
33
- if (forbiddenAssigned) return forbiddenAssigned;
42
+ // Assign path: fail-closed on unknown / above-actor roles — except unranked
43
+ // app roles the target already holds (round-trip restore after strip).
44
+ const forbiddenAssigned = assignedRoles.find((role) => {
45
+ if (getRoleRank(role) <= actorRank) return false;
46
+ if (knownRoleRank(role) === undefined && targetCurrentRoles.includes(role)) return false;
47
+ return true;
48
+ });
49
+ // Empty string is unknown (rank +∞) but falsy — must not use truthiness.
50
+ if (forbiddenAssigned !== undefined) return forbiddenAssigned;
34
51
 
35
52
  // Target path: only ranked roles above the actor block (can't touch a
36
- // SystemAdmin). Unranked app roles must not block demotion/updates invite
37
- // can assign them; treating them as rank ∞ on the target freezes those members.
53
+ // SystemAdmin). Unranked app roles are ignored here so members with only
54
+ // those roles don't freeze on demotion/updates the assign path still
55
+ // rejects *new* unranked roles fail-closed.
38
56
  const forbiddenTarget = targetCurrentRoles.find((role) => {
39
57
  const rank = knownRoleRank(role);
40
58
  return rank !== undefined && rank > actorRank;
41
59
  });
42
- if (forbiddenTarget) return forbiddenTarget;
60
+ if (forbiddenTarget !== undefined) return forbiddenTarget;
43
61
 
44
62
  return undefined;
45
63
  }
@@ -65,6 +65,11 @@ describe("KumikoError: abstract base", () => {
65
65
  const body = serializeError(err);
66
66
  expect(body.error.docsUrl).toBe("https://docs.kumiko.rocks/errors/stale_state");
67
67
  });
68
+
69
+ test("falls back to code when reason slug is not URL-safe", () => {
70
+ const err = new ConflictError({ details: { reason: "stale state/../x" } });
71
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/conflict");
72
+ });
68
73
  });
69
74
  });
70
75
 
@@ -12,20 +12,20 @@ describe("failNotFound", () => {
12
12
  });
13
13
 
14
14
  describe("failUnprocessable", () => {
15
- test("baut WriteFailure mit reason + custom-details", () => {
15
+ test("builds WriteFailure with reason + custom details", () => {
16
16
  const f = failUnprocessable("custom_business_rule", { extra: 42 });
17
17
  expect(f.error.httpStatus).toBe(422);
18
18
  expect(f.error.details).toMatchObject({ reason: "custom_business_rule", extra: 42 });
19
19
  });
20
20
 
21
- test("reason-Argument überlebt ein details.reason aus dem Aufruf", () => {
21
+ test("positional reason survives a conflicting details.reason from the caller", () => {
22
22
  const f = failUnprocessable("custom_business_rule", { reason: "raw cause text", extra: 42 });
23
23
  expect(f.error.details).toEqual({ reason: "custom_business_rule", extra: 42 });
24
24
  });
25
25
  });
26
26
 
27
27
  describe("failTransition", () => {
28
- test("baut WriteFailure mit reason=invalid_transition + from/to/allowed", () => {
28
+ test("builds WriteFailure with reason=invalid_transition + from/to/allowed", () => {
29
29
  const f = failTransition("draft", "paid", ["sent"]);
30
30
  expect(f.isSuccess).toBe(false);
31
31
  expect(f.error.code).toBe("unprocessable");