@cosmicdrift/kumiko-framework 0.220.1 → 0.222.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 (110) hide show
  1. package/package.json +7 -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__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  5. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  7. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  8. package/src/api/api-constants.ts +10 -0
  9. package/src/api/auth-routes.ts +3 -0
  10. package/src/api/index.ts +1 -0
  11. package/src/api/pii-leak-guard.ts +4 -5
  12. package/src/api/server.ts +17 -1
  13. package/src/arg-parser.ts +1 -1
  14. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  15. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  16. package/src/bun-db/index.ts +1 -0
  17. package/src/bun-db/query.ts +30 -14
  18. package/src/crypto/index.ts +1 -0
  19. package/src/crypto/is-self-pii-field.ts +8 -0
  20. package/src/crypto/subject-resolver.ts +4 -3
  21. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  22. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  23. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  24. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  25. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  26. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  27. package/src/db/blind-index-cleanup.ts +36 -19
  28. package/src/db/entity-table-meta.ts +6 -1
  29. package/src/db/event-store-executor-context.ts +2 -2
  30. package/src/db/event-store-executor-read.ts +7 -6
  31. package/src/db/event-store-executor-write.ts +103 -49
  32. package/src/db/index.ts +2 -0
  33. package/src/db/migrate-generator.ts +14 -0
  34. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  35. package/src/db/queries/backfill-pii.ts +13 -10
  36. package/src/db/queries/raw-sql.ts +14 -2
  37. package/src/db/queries/seed-context.ts +8 -4
  38. package/src/db/table-builder.ts +10 -3
  39. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  40. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  41. package/src/derivatives/derivatives-context.ts +4 -0
  42. package/src/derivatives/index.ts +9 -1
  43. package/src/derivatives/variant-key.ts +68 -0
  44. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  45. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  46. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  47. package/src/engine/__tests__/schema-builder.test.ts +7 -7
  48. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  49. package/src/engine/boot-validator/entity-handler.ts +5 -0
  50. package/src/engine/boot-validator/pii-retention.ts +16 -4
  51. package/src/engine/boot-validator/screens.ts +9 -2
  52. package/src/engine/embedded-derived.ts +11 -10
  53. package/src/engine/extensions/storage-provider.ts +28 -0
  54. package/src/engine/extensions/user-data.ts +4 -0
  55. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  56. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  57. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  58. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  59. package/src/engine/feature-ast/extractors/index.ts +2 -0
  60. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  61. package/src/engine/feature-ast/parse.ts +10 -25
  62. package/src/engine/feature-ast/patch.ts +23 -16
  63. package/src/engine/feature-ast/render.ts +3 -3
  64. package/src/engine/field-helpers.ts +1 -1
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  67. package/src/engine/role-assignment.ts +36 -18
  68. package/src/engine/schema-builder.ts +14 -2
  69. package/src/errors/__tests__/classes.test.ts +21 -2
  70. package/src/errors/__tests__/write-failures.test.ts +13 -3
  71. package/src/errors/classes.ts +14 -13
  72. package/src/errors/kumiko-error.ts +11 -11
  73. package/src/errors/write-error-info.ts +1 -1
  74. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  75. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  76. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  77. package/src/event-store/event-store.ts +7 -0
  78. package/src/event-store/index.ts +1 -0
  79. package/src/files/__tests__/files.integration.test.ts +181 -2
  80. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  81. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  82. package/src/files/file-routes.ts +53 -6
  83. package/src/files/in-memory-provider.ts +4 -0
  84. package/src/files/local-provider.ts +22 -1
  85. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  86. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  87. package/src/i18n/index.ts +7 -1
  88. package/src/i18n/mail-registry.ts +10 -0
  89. package/src/i18n/request-locale.ts +11 -2
  90. package/src/i18n/required-surface-keys.ts +3 -1
  91. package/src/jobs/job-runner.ts +22 -10
  92. package/src/lifecycle/signal-handlers.ts +2 -0
  93. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  94. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  95. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  96. package/src/pipeline/dispatch-shared.ts +39 -2
  97. package/src/pipeline/dispatch-write.ts +48 -0
  98. package/src/pipeline/dispatcher.ts +5 -0
  99. package/src/pipeline/distributed-lock.ts +3 -0
  100. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  101. package/src/schema-cli.ts +39 -48
  102. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  103. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  104. package/src/search/purge-subject.ts +4 -3
  105. package/src/search/reindex-entity.ts +2 -2
  106. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  107. package/src/stack/__tests__/request-helper.test.ts +2 -2
  108. package/src/testing/file-provider-contract.ts +19 -0
  109. package/src/ui-types/index.ts +1 -0
  110. package/src/upgrade-cli.ts +112 -15
@@ -1,5 +1,5 @@
1
1
  import type { AnyDb } from "../query";
2
- import { asRawClient } from "../query";
2
+ import { unsafeReadRetrying } from "../query";
3
3
 
4
4
  export type SeedUserRow = {
5
5
  readonly id: string;
@@ -21,7 +21,8 @@ export type SeedTenantDbRow = {
21
21
  };
22
22
 
23
23
  export async function selectUserByEmail(db: AnyDb, email: string): Promise<SeedUserRow | null> {
24
- const rows = (await asRawClient(db).unsafe(
24
+ const rows = (await unsafeReadRetrying(
25
+ db,
25
26
  `SELECT id::text AS id, email, tenant_id::text AS tenant_id
26
27
  FROM read_users
27
28
  WHERE email = $1
@@ -37,7 +38,8 @@ export async function selectMembershipsOfUser(
37
38
  db: AnyDb,
38
39
  userId: string,
39
40
  ): Promise<readonly SeedMembershipDbRow[]> {
40
- return (await asRawClient(db).unsafe(
41
+ return (await unsafeReadRetrying(
42
+ db,
41
43
  `SELECT m.user_id::text AS user_id,
42
44
  m.tenant_id::text AS tenant_id,
43
45
  e.tenant_id::text AS stream_tenant_id,
@@ -50,9 +52,11 @@ export async function selectMembershipsOfUser(
50
52
  }
51
53
 
52
54
  export async function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]> {
53
- return (await asRawClient(db).unsafe(
55
+ return (await unsafeReadRetrying(
56
+ db,
54
57
  `SELECT id::text AS id, name, key AS tenant_key
55
58
  FROM read_tenants
56
59
  ORDER BY inserted_at`,
60
+ [],
57
61
  )) as readonly SeedTenantDbRow[];
58
62
  }
@@ -583,10 +583,17 @@ export function buildEntityTable<E extends EntityDefinition>(
583
583
  .map((c) => tHandle[c])
584
584
  .filter((col): col is ColumnHandle => col !== undefined);
585
585
  if (bidxCols.length === bidxFieldNames.length) {
586
- const whereText = bidxFieldNames
586
+ const notNullParts = bidxFieldNames
587
587
  .filter((c, i) => c !== def.columns[i])
588
- .map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
589
- .join(" AND ");
588
+ .map((c) => `"${toSnakeCase(c)}" IS NOT NULL`);
589
+ // Soft-deleted rows keep their bidx hash — without this, a
590
+ // restored row can't reuse a value a soft-deleted sibling still
591
+ // holds (framework#2464).
592
+ const whereText = (
593
+ entity.softDelete === true
594
+ ? [...notNullParts, `"is_deleted" = false`]
595
+ : notNullParts
596
+ ).join(" AND ");
590
597
  const partialWhere: SqlExpression = {
591
598
  kind: "sql-expr",
592
599
  text: whereText,
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { specHash, variantSuffix } from "../variant-key";
2
+ import { deriveKey } from "../../files/file-handle";
3
+ import { buildStorageKey } from "../../files/types";
4
+ import {
5
+ derivativeListPrefix,
6
+ isDerivativeKeyOf,
7
+ parseDerivativeKey,
8
+ specHash,
9
+ variantSuffix,
10
+ } from "../variant-key";
3
11
 
4
12
  describe("specHash — key stability", () => {
5
13
  test("key order doesn't matter", () => {
@@ -59,3 +67,117 @@ describe("variantSuffix", () => {
59
67
  expect(() => variantSuffix("Hero", {})).not.toThrow();
60
68
  });
61
69
  });
70
+
71
+ // These pin the grammar `isDerivativeKeyOf`/`derivativeListPrefix` use to
72
+ // recognize forget/tenant-destroy erasure targets — real deriveKey() output,
73
+ // not a hand-rolled key shape, so a drift in deriveKey's own splitting logic
74
+ // would show up here too.
75
+ describe("derivativeListPrefix + isDerivativeKeyOf — real deriveKey() output", () => {
76
+ const original = "tenant/photo.jpg";
77
+ const suffix = variantSuffix("thumb", { maxEdge: 512 });
78
+ const derived = deriveKey(original, suffix);
79
+
80
+ test("deriveKey's own output matches isDerivativeKeyOf for its original", () => {
81
+ expect(isDerivativeKeyOf(original, derived)).toBe(true);
82
+ });
83
+
84
+ test("derivativeListPrefix is a prefix of every derivative deriveKey() produces", () => {
85
+ expect(derived.startsWith(derivativeListPrefix(original))).toBe(true);
86
+ });
87
+
88
+ test("the original key is not its own derivative", () => {
89
+ expect(isDerivativeKeyOf(original, original)).toBe(false);
90
+ });
91
+
92
+ test("a same-directory sibling original with a DIFFERENT extension is not a derivative", () => {
93
+ // Same base ("tenant/photo"), so it shares derivativeListPrefix — the
94
+ // extension anchor is what must reject it, or a forget/tenant-destroy
95
+ // prefix-delete would sweep up a different file (possibly another
96
+ // user's) alongside the intended derivatives.
97
+ expect(isDerivativeKeyOf(original, "tenant/photo.png")).toBe(false);
98
+ expect(isDerivativeKeyOf(original, "tenant/photo.other-1234567890abcdef.png")).toBe(false);
99
+ });
100
+
101
+ test("an unrelated key under the same directory is not a derivative", () => {
102
+ expect(isDerivativeKeyOf(original, "tenant/other-file.jpg")).toBe(false);
103
+ });
104
+
105
+ test("a key with the right prefix/ext but no valid <name>-<hash> middle is not a derivative", () => {
106
+ expect(isDerivativeKeyOf(original, "tenant/photo.old.jpg")).toBe(false);
107
+ expect(isDerivativeKeyOf(original, "tenant/photo.jpg")).toBe(false);
108
+ });
109
+
110
+ test("extension-less original: derivatives have no trailing extension either", () => {
111
+ const noExtOriginal = "tenant/document";
112
+ const noExtSuffix = variantSuffix("preview", { maxEdge: 256 });
113
+ const noExtDerived = deriveKey(noExtOriginal, noExtSuffix);
114
+ expect(isDerivativeKeyOf(noExtOriginal, noExtDerived)).toBe(true);
115
+ expect(derivativeListPrefix(noExtOriginal)).toBe("tenant/document.");
116
+ });
117
+
118
+ test("multiple variants of the same original all match", () => {
119
+ const cardSuffix = variantSuffix("card", { maxEdge: 1024 });
120
+ const cardDerived = deriveKey(original, cardSuffix);
121
+ expect(isDerivativeKeyOf(original, cardDerived)).toBe(true);
122
+ expect(isDerivativeKeyOf(original, derived)).toBe(true);
123
+ });
124
+ });
125
+
126
+ // parseDerivativeKey powers the orphaned-derivative backfill/GC sweep
127
+ // (#2474), which has no audit trail of past forgets to work from — it can
128
+ // only trust the deterministic key grammar itself. Real buildStorageKey() +
129
+ // deriveKey() output, not a hand-rolled key shape.
130
+ describe("parseDerivativeKey — reverse of deriveKey", () => {
131
+ const original = buildStorageKey(
132
+ "tenant-1" as never,
133
+ "fileRef",
134
+ "42",
135
+ "attachment",
136
+ "photo.jpg",
137
+ "uid123",
138
+ );
139
+ const suffix = variantSuffix("thumb", { maxEdge: 512 });
140
+ const derived = deriveKey(original, suffix);
141
+
142
+ test("reconstructs the exact original a real derivative was built from", () => {
143
+ expect(parseDerivativeKey(derived)).toEqual({ originalKey: original });
144
+ });
145
+
146
+ test("a plain original (no derivative suffix) is not derivative-shaped", () => {
147
+ expect(parseDerivativeKey(original)).toBeNull();
148
+ });
149
+
150
+ test("a key with no dot at all is not derivative-shaped", () => {
151
+ expect(parseDerivativeKey("tenant-1/fileRef/42/attachment/uid123")).toBeNull();
152
+ });
153
+
154
+ test("a multi-dot ORIGINAL filename still round-trips", () => {
155
+ const multiDotOriginal = buildStorageKey(
156
+ "tenant-1" as never,
157
+ "fileRef",
158
+ "42",
159
+ "attachment",
160
+ "my.file.name.jpg",
161
+ "uid456",
162
+ );
163
+ const multiDotDerived = deriveKey(multiDotOriginal, suffix);
164
+ expect(parseDerivativeKey(multiDotDerived)).toEqual({ originalKey: multiDotOriginal });
165
+ });
166
+
167
+ // Blind spot the reverse-parse must guard against on its own (no DB
168
+ // cross-check happens inside parseDerivativeKey itself): a lookalike key
169
+ // that was never written by buildStorageKey — e.g. a GDPR export bundle —
170
+ // can still have a last segment shaped like "<name>-<16hex>.<ext>" if its
171
+ // filename happens to contain a hyphen+16-hex-char run. Anchoring the
172
+ // reconstructed original to buildStorageKey's own 5-segment/single-dot
173
+ // shape rejects it even though the suffix pattern alone would match.
174
+ test("a non-buildStorageKey lookalike with a derivative-shaped suffix is rejected", () => {
175
+ const lookalike = "tenant-1/gdpr-export-bundle.snapshot-0123456789abcdef.zip";
176
+ expect(parseDerivativeKey(lookalike)).toBeNull();
177
+ });
178
+
179
+ test("a suffix that doesn't match <name>-<16hex> is rejected", () => {
180
+ const notAHash = deriveKey(original, "thumb-notahexvalue");
181
+ expect(parseDerivativeKey(notAHash)).toBeNull();
182
+ });
183
+ });
@@ -103,6 +103,9 @@ describe("GET /api/files/:id/variant/:name", () => {
103
103
  expect(res.headers.get("Content-Type")).toBe("image/webp");
104
104
  expect(res.headers.get("Cache-Control")).toBe("private, max-age=31536000, immutable");
105
105
  expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
106
+ // fw#2442 — variant responses never derive from fileRef.fileName, so
107
+ // there is no Content-Disposition here for a field-encrypted name to leak into.
108
+ expect(res.headers.get("Content-Disposition")).toBeNull();
106
109
  });
107
110
 
108
111
  // setupTestStack doesn't wrap with the app-wide security-headers default, so a pass proves the route sets its own.
@@ -155,6 +155,10 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
155
155
  assertSafeStorageKey(target.key);
156
156
  const mimeType = outputMimeType(spec, row.mimeType);
157
157
 
158
+ // ponytail: exists→render→write is a TOCTOU window under concurrent
159
+ // requests for the same variant (duplicate render + write). Ceiling:
160
+ // non-atomic providers can briefly expose a half-written object as a
161
+ // cache hit. Upgrade: write to a temp key then atomic rename/copy.
158
162
  if (await target.exists()) {
159
163
  return {
160
164
  storageKey: target.key,
@@ -1,4 +1,12 @@
1
1
  export type { DerivativesContextDeps } from "./derivatives-context";
2
2
  export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
3
3
  export { resolveFieldVariant } from "./field-variants";
4
- export { canonicalJson, specHash, VARIANT_NAME_PATTERN, variantSuffix } from "./variant-key";
4
+ export {
5
+ canonicalJson,
6
+ derivativeListPrefix,
7
+ isDerivativeKeyOf,
8
+ parseDerivativeKey,
9
+ specHash,
10
+ VARIANT_NAME_PATTERN,
11
+ variantSuffix,
12
+ } from "./variant-key";
@@ -38,3 +38,71 @@ export function variantSuffix(name: string, spec: VariantSpec): string {
38
38
  }
39
39
  return `${name}-${specHash(spec)}`;
40
40
  }
41
+
42
+ // A full derivative-suffix segment is `<name>-<16 hex chars>` — mirrors
43
+ // VARIANT_NAME_PATTERN (name grammar) + specHash's fixed 16-char slice.
44
+ // Keep in sync with both if either changes.
45
+ const DERIVATIVE_SUFFIX_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}-[0-9a-f]{16}$/i;
46
+
47
+ // Mirrors deriveKey's own split so callers get the exact same base/ext this
48
+ // key's derivatives were built from.
49
+ function splitKey(key: string): { readonly base: string; readonly ext: string } {
50
+ const lastSlash = key.lastIndexOf("/");
51
+ const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
52
+ const lastDot = lastSegment.lastIndexOf(".");
53
+ if (lastDot === -1) return { base: key, ext: "" };
54
+ const base = key.slice(0, key.length - lastSegment.length + lastDot);
55
+ return { base, ext: lastSegment.slice(lastDot) };
56
+ }
57
+
58
+ // List-prefix that covers every derivative deriveKey() can produce for
59
+ // `originalKey` — pass to FileStorageProvider.list() to enumerate candidates,
60
+ // then filter with isDerivativeKeyOf before deleting any of them.
61
+ export function derivativeListPrefix(originalKey: string): string {
62
+ return `${splitKey(originalKey).base}.`;
63
+ }
64
+
65
+ // True when `candidateKey` is a derivative deriveKey() could have produced
66
+ // for `originalKey` — anchored to originalKey's own basename AND extension.
67
+ // Without the extension anchor, a same-directory sibling original with a
68
+ // different extension (a different file, possibly another user's) would
69
+ // match on prefix alone and get swept into a forget/tenant-destroy delete.
70
+ export function isDerivativeKeyOf(originalKey: string, candidateKey: string): boolean {
71
+ const { base, ext } = splitKey(originalKey);
72
+ const prefix = `${base}.`;
73
+ if (!candidateKey.startsWith(prefix) || !candidateKey.endsWith(ext)) return false;
74
+ const middle = candidateKey.slice(prefix.length, candidateKey.length - ext.length);
75
+ return DERIVATIVE_SUFFIX_PATTERN.test(middle);
76
+ }
77
+
78
+ // buildStorageKey (files/types.ts) always produces exactly 5 "/"-segments
79
+ // ending in "<uniqueId>.<ext>" — a single dot, non-empty on both sides.
80
+ // Anchoring parseDerivativeKey's reconstructed candidate to this shape
81
+ // rejects lookalikes that were never written by buildStorageKey (a GDPR
82
+ // export bundle, a local-provider `*.tmp` write-in-progress file) even when
83
+ // their last segment happens to contain a "name-16hex"-shaped middle token.
84
+ const BUILT_STORAGE_KEY_LAST_SEGMENT_PATTERN = /^[^./]+\.[^./]+$/;
85
+
86
+ function looksLikeBuiltStorageKey(key: string): boolean {
87
+ const segments = key.split("/");
88
+ if (segments.length !== 5) return false;
89
+ return BUILT_STORAGE_KEY_LAST_SEGMENT_PATTERN.test(segments[4] ?? "");
90
+ }
91
+
92
+ // Reverse of deriveKey: given a key found on a full storage listing, guesses
93
+ // the original key it would be a derivative of. Used by the orphaned-
94
+ // derivative backfill/GC sweep, which has no audit trail of past forgets to
95
+ // work from — only the deterministic key grammar itself. Returns null for
96
+ // anything that isn't derivative-shaped (including a plain original, which
97
+ // splits with no second dot) or whose reconstructed original doesn't match
98
+ // buildStorageKey's own shape.
99
+ export function parseDerivativeKey(candidateKey: string): { readonly originalKey: string } | null {
100
+ const { base: withSuffix, ext } = splitKey(candidateKey);
101
+ const { base: outerBase, ext: suffixExt } = splitKey(withSuffix);
102
+ if (suffixExt === "") return null;
103
+ const suffixCandidate = suffixExt.slice(1);
104
+ if (!DERIVATIVE_SUFFIX_PATTERN.test(suffixCandidate)) return null;
105
+ const originalKey = `${outerBase}${ext}`;
106
+ if (!looksLikeBuiltStorageKey(originalKey)) return null;
107
+ return { originalKey };
108
+ }
@@ -700,7 +700,7 @@ describe("validateBoot — retention", () => {
700
700
  expect(matchingWarn).toBeUndefined();
701
701
  });
702
702
 
703
- test("blockDelete with only a subjectRef-only field and no anonymize stays silent (#1645, narrowed by #2336)", () => {
703
+ test("blockDelete with only a subjectRef-only field warns about EXT_USER_DATA delete hook (#2338)", () => {
704
704
  const feature = defineFeature("test", (r) => {
705
705
  r.entity(
706
706
  "lease",
@@ -715,36 +715,42 @@ describe("validateBoot — retention", () => {
715
715
  );
716
716
  });
717
717
  validateBoot([feature]);
718
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
718
+ const anonymizeWarn = warnSpy.mock.calls.find((args: unknown[]) =>
719
719
  String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
720
720
  );
721
- expect(matchingWarn).toBeUndefined();
721
+ expect(anonymizeWarn).toBeUndefined();
722
+ const extWarn = warnSpy.mock.calls.find((args: unknown[]) =>
723
+ String(args[0]).includes("EXT_USER_DATA delete hook for Art.17"),
724
+ );
725
+ expect(extWarn).toBeDefined();
722
726
  });
723
727
 
724
- test("blockDelete with a subjectRef field plus an anonymizable subject field and no anonymize still warns (#2336)", () => {
725
- const feature = defineFeature("test", (r) => {
726
- r.entity(
727
- "lease",
728
- createEntity({
729
- fields: {
730
- authorId: createTextField({
731
- personal: "ref",
732
- }),
733
- customerName: createTextField({
734
- personal: "self",
735
- find: "none",
736
- }),
737
- },
738
- retention: { keepFor: "10y", strategy: "blockDelete" },
739
- }),
728
+ test.each([
729
+ ["self", { personal: "self" as const, find: "none" as const }],
730
+ ["userOwned", { personal: { of: "authorId" } as const, find: "none" as const }],
731
+ ["tenantOwned", { personal: "tenant" as const, find: "none" as const }],
732
+ ])(
733
+ "blockDelete warns when subjectRef coexists with anonymizable %s field (#2336)",
734
+ (_label, personal) => {
735
+ const feature = defineFeature("test", (r) => {
736
+ r.entity(
737
+ "lease",
738
+ createEntity({
739
+ fields: {
740
+ authorId: createTextField({ personal: "ref" }),
741
+ subjectField: createTextField(personal),
742
+ },
743
+ retention: { keepFor: "10y", strategy: "blockDelete" },
744
+ }),
745
+ );
746
+ });
747
+ validateBoot([feature]);
748
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
749
+ String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
740
750
  );
741
- });
742
- validateBoot([feature]);
743
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
744
- String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
745
- );
746
- expect(matchingWarn).toBeDefined();
747
- });
751
+ expect(matchingWarn).toBeDefined();
752
+ },
753
+ );
748
754
 
749
755
  test('retention.keepFor with invalid format "30days" warns', () => {
750
756
  const feature = defineFeature("test", (r) => {
@@ -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
  });
@@ -719,9 +719,9 @@ describe("totalsMatch (fw#1839)", () => {
719
719
  }
720
720
  });
721
721
 
722
- test("update payload omitting the embedded list is not checked (nothing to sum)", () => {
722
+ test("update payload omitting the embedded list is rejected (totalsMatch pair incomplete)", () => {
723
723
  const schema = buildUpdateSchema(invoiceEntity());
724
- expect(schema.safeParse({ total: { amount: 30, currency: "EUR" } }).success).toBe(true);
724
+ expect(schema.safeParse({ total: { amount: 30, currency: "EUR" } }).success).toBe(false);
725
725
  });
726
726
 
727
727
  // kumiko-framework#1972: a round total (30 EUR = 3000 minor) still passes
@@ -753,9 +753,9 @@ describe("totalsMatch (fw#1839)", () => {
753
753
  }
754
754
  });
755
755
 
756
- test("update payload omitting the sibling total is not checked (nothing to compare against)", () => {
756
+ test("update payload omitting the sibling total is rejected (totalsMatch pair incomplete)", () => {
757
757
  const schema = buildUpdateSchema(invoiceEntity());
758
- expect(schema.safeParse({ lines: [{ amount: 1000 }, { amount: 1500 }] }).success).toBe(true);
758
+ expect(schema.safeParse({ lines: [{ amount: 1000 }, { amount: 1500 }] }).success).toBe(false);
759
759
  });
760
760
 
761
761
  test("rejects a sibling total tagged with a currency other than the entity's default, even when the raw minor-unit amounts match", () => {
@@ -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) {