@cosmicdrift/kumiko-framework 0.105.1 → 0.108.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 (92) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/jwt.test.ts +27 -0
  8. package/src/api/__tests__/pat-scope.test.ts +36 -0
  9. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  10. package/src/api/auth-middleware.ts +65 -1
  11. package/src/api/auth-routes.ts +11 -0
  12. package/src/api/index.ts +3 -1
  13. package/src/api/jwt.ts +5 -5
  14. package/src/api/pat-scope.ts +14 -0
  15. package/src/api/request-context.ts +3 -0
  16. package/src/api/request-id-middleware.ts +2 -0
  17. package/src/api/routes.ts +22 -0
  18. package/src/api/server.ts +29 -1
  19. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  20. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  21. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  22. package/src/bun-db/query.ts +40 -9
  23. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  24. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  25. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  26. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  27. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  28. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  29. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  31. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  32. package/src/db/apply-entity-event.ts +19 -8
  33. package/src/db/event-store-executor.ts +91 -8
  34. package/src/db/queries/shadow-swap.ts +1 -1
  35. package/src/db/query.ts +1 -0
  36. package/src/db/table-builder.ts +23 -1
  37. package/src/db/tenant-db.ts +6 -0
  38. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  39. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  40. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  41. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  42. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  43. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  44. package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
  45. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  46. package/src/engine/boot-validator/entity-handler.ts +45 -0
  47. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  48. package/src/engine/boot-validator/index.ts +14 -1
  49. package/src/engine/boot-validator/screens-nav.ts +90 -6
  50. package/src/engine/build-app-schema.ts +15 -7
  51. package/src/engine/define-feature.ts +17 -0
  52. package/src/engine/define-handler.ts +16 -2
  53. package/src/engine/entity-handlers.ts +32 -13
  54. package/src/engine/extensions/user-data.ts +6 -0
  55. package/src/engine/index.ts +6 -1
  56. package/src/engine/projection-helpers.ts +8 -5
  57. package/src/engine/registry.ts +47 -2
  58. package/src/engine/schema-builder.ts +3 -1
  59. package/src/engine/soft-delete-cleanup.ts +41 -4
  60. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  61. package/src/engine/tier-resolver-extension.ts +11 -0
  62. package/src/engine/types/feature.ts +29 -21
  63. package/src/engine/types/fields.ts +12 -0
  64. package/src/engine/types/handlers.ts +13 -0
  65. package/src/engine/types/index.ts +2 -0
  66. package/src/engine/types/projection.ts +33 -5
  67. package/src/event-store/index.ts +8 -0
  68. package/src/event-store/rebuild-dead-letter.ts +111 -0
  69. package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
  70. package/src/files/file-routes.ts +1 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
  72. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  73. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
  74. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  75. package/src/pipeline/dispatcher.ts +4 -10
  76. package/src/pipeline/msp-rebuild.ts +36 -3
  77. package/src/pipeline/projection-rebuild.ts +55 -3
  78. package/src/pipeline/projections-runner.ts +1 -1
  79. package/src/schema-cli.ts +24 -15
  80. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  81. package/src/secrets/types.ts +8 -1
  82. package/src/testing/db-cleanup.ts +4 -1
  83. package/src/testing/index.ts +1 -0
  84. package/src/testing/seed.ts +50 -0
  85. package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
  86. package/src/time/__tests__/geo-tz.test.ts +9 -3
  87. package/src/time/__tests__/iana.test.ts +9 -0
  88. package/src/time/boot-tz-warning.ts +5 -1
  89. package/src/time/iana.ts +17 -15
  90. package/src/time/tz-context.ts +6 -1
  91. package/src/utils/__tests__/serialization.test.ts +6 -0
  92. package/src/utils/serialization.ts +10 -3
@@ -109,7 +109,7 @@ function scalarDefault(field: FieldDefinition): unknown {
109
109
  // to entity creates/updates/etc should reference this helper instead of
110
110
  // hardcoding the string — a future rename in the executor then surfaces
111
111
  // as a type error at every call site rather than a silent miss.
112
- export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored";
112
+ export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
113
113
 
114
114
  export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
115
115
  return `${entityName}.${verb}`;
@@ -187,6 +187,16 @@ export type EventStoreExecutor = {
187
187
  db: TenantDb,
188
188
  ) => Promise<WriteResult<DeleteContext>>;
189
189
 
190
+ // Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
191
+ // which hard-deletes the row even for softDelete entities — and, being an
192
+ // auto-verb replayed by the implicit projection, the erasure survives a
193
+ // rebuild (created → forgotten → row gone). Reaches soft-deleted rows too.
194
+ forget: (
195
+ payload: { id: EntityId },
196
+ user: SessionUser,
197
+ db: TenantDb,
198
+ ) => Promise<WriteResult<DeleteContext>>;
199
+
190
200
  restore: (
191
201
  payload: { id: EntityId },
192
202
  user: SessionUser,
@@ -645,7 +655,10 @@ export function createEventStoreExecutor(
645
655
  // you'd have to snapshot-and-diff on every apply, and replays would
646
656
  // break. Storage cost is acceptable (rows are bounded), correctness is
647
657
  // not negotiable. Sensitive fields are stripped from BOTH halves so
648
- // they never reach the immutable event log.
658
+ // they never reach the immutable event log. `previous` came from
659
+ // loadById(), which decrypts — re-encrypt it before it's persisted so
660
+ // an `encrypted` field's plaintext doesn't land in the immutable log
661
+ // (flatChanges is already ciphertext from encryptForStorage above).
649
662
  const event = await append(db.raw, {
650
663
  aggregateId: String(payload.id),
651
664
  aggregateType: entityName,
@@ -654,7 +667,7 @@ export function createEventStoreExecutor(
654
667
  type: entityEventName(entityName, "updated"),
655
668
  payload: {
656
669
  changes: stripSensitive(flatChanges),
657
- previous: stripSensitive(previous),
670
+ previous: stripSensitive(encryptForStorage(previous)),
658
671
  },
659
672
  metadata: buildEventMetadata(user),
660
673
  });
@@ -755,13 +768,16 @@ export function createEventStoreExecutor(
755
768
  // projections and downstream consumers need to reverse any aggregates —
756
769
  // a `{}`-payload delete would make cross-aggregate projections impossible
757
770
  // to rebuild from the event log alone. Sensitive fields are stripped.
771
+ // `existing` came from loadById(), which decrypts — re-encrypt before
772
+ // persisting so an `encrypted` field's plaintext doesn't land in the
773
+ // immutable log.
758
774
  const event = await append(db.raw, {
759
775
  aggregateId: String(payload.id),
760
776
  aggregateType: entityName,
761
777
  tenantId: streamTenantFor(user),
762
778
  expectedVersion: currentVersion,
763
779
  type: entityEventName(entityName, "deleted"),
764
- payload: { previous: stripSensitive(existing) },
780
+ payload: { previous: stripSensitive(encryptForStorage(existing)) },
765
781
  metadata: buildEventMetadata(user),
766
782
  });
767
783
 
@@ -787,6 +803,64 @@ export function createEventStoreExecutor(
787
803
  };
788
804
  },
789
805
 
806
+ // Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
807
+ // hard-deletes the row regardless of softDelete — and, being an auto-verb,
808
+ // the erasure replays on rebuild (created → forgotten → row gone). Loads
809
+ // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
810
+ async forget(payload, user, db) {
811
+ const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
812
+ if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
813
+ const existing = decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
814
+
815
+ if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
816
+ return writeFailure(
817
+ new UnprocessableError("ownership_denied", {
818
+ i18nKey: "errors.ownershipDenied",
819
+ details: {
820
+ scope: "entity",
821
+ entityName,
822
+ action: "delete",
823
+ userId: user.id,
824
+ entityId: payload.id,
825
+ },
826
+ }),
827
+ );
828
+ }
829
+
830
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
831
+ const currentVersion = await getStreamVersion(
832
+ db.raw,
833
+ String(payload.id),
834
+ streamTenantFor(user),
835
+ );
836
+
837
+ const event = await append(db.raw, {
838
+ aggregateId: String(payload.id),
839
+ aggregateType: entityName,
840
+ tenantId: streamTenantFor(user),
841
+ expectedVersion: currentVersion,
842
+ type: entityEventName(entityName, "forgotten"),
843
+ payload: { previous: stripSensitive(existing) },
844
+ metadata: buildEventMetadata(user),
845
+ });
846
+
847
+ const forgetResult = await applyEntityEvent(event, table, entity, db.raw);
848
+ if (forgetResult.kind !== "applied") {
849
+ return writeFailure(
850
+ new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
851
+ );
852
+ }
853
+
854
+ if (entityCache && entityName) {
855
+ await entityCache.del(user.tenantId, entityName, payload.id);
856
+ }
857
+
858
+ return {
859
+ isSuccess: true,
860
+ data: { kind: "delete", id: payload.id, data: existing, entityName, event },
861
+ };
862
+ },
863
+
790
864
  async restore(payload, user, db) {
791
865
  if (!softDelete) {
792
866
  return writeFailure(
@@ -859,7 +933,13 @@ export function createEventStoreExecutor(
859
933
  }
860
934
 
861
935
  // Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
862
- const restoredHydrated = rehydrateCompoundTypes(restored as DbRow, entity) as DbRow;
936
+ // decryptForRead matches create/update/list/detail: the caller-facing
937
+ // row and `previous` snapshot must be plaintext for `encrypted` fields,
938
+ // same as every other executor method — `data`/`restored` are raw rows
939
+ // (selectMany / applyEntityEvent), never decrypted before this point.
940
+ const restoredHydrated = decryptForRead(
941
+ rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
942
+ );
863
943
 
864
944
  return {
865
945
  isSuccess: true,
@@ -868,7 +948,7 @@ export function createEventStoreExecutor(
868
948
  id: payload.id,
869
949
  data: restoredHydrated,
870
950
  changes: { isDeleted: false },
871
- previous: data,
951
+ previous: decryptForRead(data),
872
952
  isNew: false,
873
953
  entityName,
874
954
  event,
@@ -1073,7 +1153,10 @@ export function createEventStoreExecutor(
1073
1153
  const checkRows = await loadWithOwnership(db, idWhere, ownership);
1074
1154
  if (checkRows.length === 0) return null;
1075
1155
  }
1076
- return withStreamVersion(cached);
1156
+ // Cached rows are stored re-encrypted (see the `set` below) so an
1157
+ // `encrypted` field's plaintext never sits in a second at-rest
1158
+ // store (Redis) the field-encryption feature doesn't cover.
1159
+ return withStreamVersion(decryptForRead(cached));
1077
1160
  }
1078
1161
  }
1079
1162
 
@@ -1085,7 +1168,7 @@ export function createEventStoreExecutor(
1085
1168
  const coerced = coerceRow(row, rowInfo);
1086
1169
 
1087
1170
  if (entityCache && entityName) {
1088
- await entityCache.set(user.tenantId, entityName, payload.id, coerced);
1171
+ await entityCache.set(user.tenantId, entityName, payload.id, encryptForStorage(coerced));
1089
1172
  }
1090
1173
 
1091
1174
  return withStreamVersion(coerced);
@@ -104,7 +104,7 @@ export async function fenceLiveTable(
104
104
  // Postgres treats lock_timeout = 0 as "no timeout" (wait forever) — the
105
105
  // opposite of fail-fast. Reject it so a 0/negative value can't silently
106
106
  // turn the fence into an unbounded wait.
107
- if (lockTimeoutMs <= 0) {
107
+ if (Math.trunc(lockTimeoutMs) <= 0) {
108
108
  throw new Error(`fenceLockTimeoutMs must be > 0, got ${lockTimeoutMs}`);
109
109
  }
110
110
  const raw = asRawClient(tx);
package/src/db/query.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  insertMany,
18
18
  insertOne,
19
19
  type OrderByClause,
20
+ runInSavepoint,
20
21
  type SelectOptions,
21
22
  selectMany,
22
23
  type TableInfo,
@@ -365,12 +365,34 @@ type SoftDeleteColumnsType = {
365
365
  readonly deletedById: NullCol<string>;
366
366
  };
367
367
 
368
+ // ── ES-write brand ──────────────────────────────────────────────────────
369
+ // A managed projection is only writable through the executor (event →
370
+ // rebuild-safe). To make a direct write a *compile* error rather than a
371
+ // convention, EntityTable carries a phantom `unique symbol` prop; the public
372
+ // write helpers reject anything that has it (see NotExecutorOnly + query.ts).
373
+ // The symbol key dodges SchemaTable's `[field: string]: unknown` index
374
+ // signature (string index sigs don't cover symbol keys), so the brand
375
+ // survives the type-erasure that would swallow a plain marker prop — and the
376
+ // executor seam (applyEntityEvent) erases `table` to TableColumns<any>, which
377
+ // carries no such prop, so the one legitimate writer stays green.
378
+ declare const EXECUTOR_ONLY: unique symbol;
379
+ export interface ExecutorOnly {
380
+ readonly [EXECUTOR_ONLY]: true;
381
+ }
382
+ // Negative brand for write-helper params: a branded EntityTable is NOT
383
+ // assignable (its `true` clashes with `never`), while unmanaged EntityTableMeta
384
+ // and erased SchemaTable pass (they lack the prop, so `?: never` is satisfied).
385
+ export type NotExecutorOnly = {
386
+ readonly [EXECUTOR_ONLY]?: never;
387
+ };
388
+
368
389
  export type EntityTable<E extends EntityDefinition = EntityDefinition> =
369
390
  TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
370
391
  any> &
371
392
  BaseColumnsType<E> &
372
393
  SoftDeleteColumnsType &
373
- ColumnsForEntity<E["fields"]>;
394
+ ColumnsForEntity<E["fields"]> &
395
+ ExecutorOnly;
374
396
 
375
397
  export function buildBaseColumns(softDelete: boolean, idType: "serial" | "uuid" = "uuid") {
376
398
  const idColumn =
@@ -53,6 +53,12 @@ export type TenantDb = {
53
53
  set: Record<string, unknown>,
54
54
  where: WhereObject,
55
55
  ): Promise<readonly T[]>;
56
+ // Method-form writes (ctx.db.insertOne/updateMany/deleteMany) keep the erased
57
+ // Table param: the executor-only brand is enforced on the free-function
58
+ // helpers (the reflexive path all production writes take), while method-form
59
+ // on a branded table is covered by the guard-direct-entity-writes AST guard
60
+ // (P5) — branding it here would force every push+write test into casts for
61
+ // zero production benefit (no prod code writes a projection via method-form).
56
62
  deleteMany(table: Table, where: WhereObject): Promise<void>;
57
63
  };
58
64
 
@@ -10,6 +10,7 @@ import {
10
10
  validateGdprStoragePersistence,
11
11
  } from "../boot-validator/gdpr-storage";
12
12
  import { defineFeature } from "../define-feature";
13
+ import { EXT_USER_DATA } from "../extension-names";
13
14
 
14
15
  const udr = () => defineFeature("user-data-rights", () => {});
15
16
  const fileProvider = (name: string) =>
@@ -94,7 +95,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
94
95
 
95
96
  test("export + delete hooks → no warn", () => {
96
97
  const f = defineFeature("my-feature", (r) => {
97
- r.useExtension("userData", "myEntity", { export: exportFn, delete: deleteFn });
98
+ r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn, delete: deleteFn });
98
99
  });
99
100
  validateGdprHookCompleteness([f]);
100
101
  expect(warnSpy).not.toHaveBeenCalled();
@@ -102,7 +103,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
102
103
 
103
104
  test("export hook without delete hook → Art.17 warn", () => {
104
105
  const f = defineFeature("my-feature", (r) => {
105
- r.useExtension("userData", "myEntity", { export: exportFn });
106
+ r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn });
106
107
  });
107
108
  validateGdprHookCompleteness([f]);
108
109
  expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -114,7 +115,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
114
115
 
115
116
  test("delete hook only (no export) → no warn", () => {
116
117
  const f = defineFeature("my-feature", (r) => {
117
- r.useExtension("userData", "myEntity", { delete: deleteFn });
118
+ r.useExtension(EXT_USER_DATA, "myEntity", { delete: deleteFn });
118
119
  });
119
120
  validateGdprHookCompleteness([f]);
120
121
  expect(warnSpy).not.toHaveBeenCalled();
@@ -130,10 +131,10 @@ describe("validateGdprHookCompleteness (V2)", () => {
130
131
 
131
132
  test("multiple features, one missing delete → one warn per missing hook", () => {
132
133
  const good = defineFeature("good", (r) => {
133
- r.useExtension("userData", "entityA", { export: exportFn, delete: deleteFn });
134
+ r.useExtension(EXT_USER_DATA, "entityA", { export: exportFn, delete: deleteFn });
134
135
  });
135
136
  const bad = defineFeature("bad", (r) => {
136
- r.useExtension("userData", "entityB", { export: exportFn });
137
+ r.useExtension(EXT_USER_DATA, "entityB", { export: exportFn });
137
138
  });
138
139
  validateGdprHookCompleteness([good, bad]);
139
140
  expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -5,6 +5,7 @@ import { table, text } from "../../db/dialect";
5
5
  import { validateBoot } from "../boot-validator";
6
6
  import { createSystemConfig, createTenantConfig } from "../config-helpers";
7
7
  import {
8
+ createDerivedField,
8
9
  createEntity,
9
10
  createMultiSelectField,
10
11
  createTextField,
@@ -168,6 +169,56 @@ describe("boot-validator", () => {
168
169
  expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
169
170
  });
170
171
 
172
+ test("rejects encrypted text field that is also filterable", () => {
173
+ // AES-GCM draws a random IV per call, so the ciphertext is non-
174
+ // deterministic — an equality filter on it would never match, even
175
+ // against the identical plaintext.
176
+ process.env["ENCRYPTION_KEY"] = "test-key";
177
+ try {
178
+ const features = [
179
+ defineFeature("a", (r) => {
180
+ r.entity(
181
+ "doc",
182
+ createEntity({
183
+ table: "Docs",
184
+ fields: {
185
+ secret: { type: "text", encrypted: true, filterable: true },
186
+ },
187
+ }),
188
+ );
189
+ }),
190
+ ];
191
+ expect(() => validateBoot(features)).toThrow(/secret.*encrypted and filterable/i);
192
+ } finally {
193
+ delete process.env["ENCRYPTION_KEY"];
194
+ }
195
+ });
196
+
197
+ test("rejects a unique index on an encrypted column", () => {
198
+ // Same non-determinism as the filterable case: a unique index can never
199
+ // detect a real duplicate plaintext behind two different ciphertexts.
200
+ process.env["ENCRYPTION_KEY"] = "test-key";
201
+ try {
202
+ const features = [
203
+ defineFeature("a", (r) => {
204
+ r.entity(
205
+ "doc",
206
+ createEntity({
207
+ table: "Docs",
208
+ fields: {
209
+ secret: { type: "text", encrypted: true },
210
+ },
211
+ indexes: [{ columns: ["secret"], unique: true }],
212
+ }),
213
+ );
214
+ }),
215
+ ];
216
+ expect(() => validateBoot(features)).toThrow(/secret.*encrypted.*unique index/i);
217
+ } finally {
218
+ delete process.env["ENCRYPTION_KEY"];
219
+ }
220
+ });
221
+
171
222
  // --- index-validator longText block ---
172
223
 
173
224
  test("rejects longText field in entity.indexes (longText is not indexable)", () => {
@@ -309,6 +360,46 @@ describe("boot-validator", () => {
309
360
  );
310
361
  });
311
362
 
363
+ // --- derivedFields / fields collision ---
364
+
365
+ test("throws when a derivedFields key conflicts with a stored field", () => {
366
+ const features = [
367
+ defineFeature("a", (r) => {
368
+ r.entity(
369
+ "item",
370
+ createEntity({
371
+ table: "Items",
372
+ fields: { status: createTextField() },
373
+ derivedFields: {
374
+ status: createDerivedField({ valueType: "text", derive: () => "computed" }),
375
+ },
376
+ }),
377
+ );
378
+ }),
379
+ ];
380
+ expect(() => validateBoot(features)).toThrow(
381
+ /entity "item".*derivedFields key "status".*conflicts/i,
382
+ );
383
+ });
384
+
385
+ test("passes when derivedFields keys don't conflict with stored fields", () => {
386
+ const features = [
387
+ defineFeature("a", (r) => {
388
+ r.entity(
389
+ "item",
390
+ createEntity({
391
+ table: "Items",
392
+ fields: { status: createTextField() },
393
+ derivedFields: {
394
+ statusLabel: createDerivedField({ valueType: "text", derive: () => "computed" }),
395
+ },
396
+ }),
397
+ );
398
+ }),
399
+ ];
400
+ expect(() => validateBoot(features)).not.toThrow();
401
+ });
402
+
312
403
  // --- Config key cross-feature references ---
313
404
 
314
405
  test("throws when readsConfig references non-existent key", () => {
@@ -1195,6 +1286,13 @@ describe("boot-validator", () => {
1195
1286
  ]),
1196
1287
  ).toThrow(/tags/);
1197
1288
  });
1289
+
1290
+ test("labeled column on a non-field WITHOUT a renderer → Throw (label alone isn't enough)", () => {
1291
+ // Regression (697/1): renderer is what actually draws a virtual column —
1292
+ // a label with no renderer would push an empty, unrendered column into
1293
+ // the view model instead of a real typo-guard.
1294
+ expect(() => validateBoot([noteFeature({ field: "tags", label: "Tags" })])).toThrow(/tags/);
1295
+ });
1198
1296
  });
1199
1297
 
1200
1298
  // --- entityList: pagination + sort validation ---
@@ -1981,6 +2079,118 @@ describe("boot-validator", () => {
1981
2079
  });
1982
2080
  expect(() => validateBoot([list, consumer])).not.toThrow();
1983
2081
  });
2082
+
2083
+ test("cross-feature navigate to an entityEdit screen WITHOUT entityId → Throw", () => {
2084
+ // Regression: the renderer's same-feature row["id"] fallback never
2085
+ // fires for a cross-feature target, so the edit screen would silently
2086
+ // open with no entity context at runtime. The validator must catch it
2087
+ // at boot instead.
2088
+ const list = defineFeature("shop", (r) => {
2089
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2090
+ r.screen({
2091
+ id: "product-list",
2092
+ type: "entityList",
2093
+ entity: "product",
2094
+ columns: ["name"],
2095
+ rowActions: [
2096
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "invoice-edit" },
2097
+ ],
2098
+ });
2099
+ });
2100
+ const consumer = defineFeature("billing", (r) => {
2101
+ r.entity("invoice", createEntity({ fields: { name: createTextField() } }));
2102
+ r.screen({
2103
+ id: "invoice-edit",
2104
+ type: "entityEdit",
2105
+ entity: "invoice",
2106
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
2107
+ });
2108
+ });
2109
+ expect(() => validateBoot([list, consumer])).toThrow(
2110
+ /rowAction "edit".*cross-feature to entityEdit screen "invoice-edit".*without an explicit entityId/,
2111
+ );
2112
+ });
2113
+
2114
+ test("cross-feature navigate to an entityEdit screen WITH entityId → kein Throw", () => {
2115
+ const list = defineFeature("shop", (r) => {
2116
+ r.entity(
2117
+ "product",
2118
+ createEntity({ fields: { name: createTextField(), invoiceId: createTextField() } }),
2119
+ );
2120
+ r.screen({
2121
+ id: "product-list",
2122
+ type: "entityList",
2123
+ entity: "product",
2124
+ columns: ["name"],
2125
+ rowActions: [
2126
+ {
2127
+ kind: "navigate",
2128
+ id: "edit",
2129
+ label: "actions.edit",
2130
+ screen: "invoice-edit",
2131
+ entityId: "invoiceId",
2132
+ },
2133
+ ],
2134
+ });
2135
+ });
2136
+ const consumer = defineFeature("billing", (r) => {
2137
+ r.entity("invoice", createEntity({ fields: { name: createTextField() } }));
2138
+ r.screen({
2139
+ id: "invoice-edit",
2140
+ type: "entityEdit",
2141
+ entity: "invoice",
2142
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
2143
+ });
2144
+ });
2145
+ expect(() => validateBoot([list, consumer])).not.toThrow();
2146
+ });
2147
+
2148
+ test("same-feature navigate to entityEdit WITHOUT entityId → kein Throw (same-feature fallback applies)", () => {
2149
+ const feature = defineFeature("shop", (r) => {
2150
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2151
+ r.screen({
2152
+ id: "product-list",
2153
+ type: "entityList",
2154
+ entity: "product",
2155
+ columns: ["name"],
2156
+ rowActions: [
2157
+ { kind: "navigate", id: "edit", label: "actions.edit", screen: "product-edit" },
2158
+ ],
2159
+ });
2160
+ r.screen({
2161
+ id: "product-edit",
2162
+ type: "entityEdit",
2163
+ entity: "product",
2164
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
2165
+ });
2166
+ });
2167
+ expect(() => validateBoot([feature])).not.toThrow();
2168
+ });
2169
+ });
2170
+
2171
+ // --- Screen short-id collision across features ---
2172
+ describe("screen short-id collisions across features", () => {
2173
+ test("two features registering the same short screen-id → Throw", () => {
2174
+ const a = defineFeature("shop", (r) => {
2175
+ r.screen({ id: "settings", type: "custom", renderer: { react: "stub" } });
2176
+ });
2177
+ const b = defineFeature("billing", (r) => {
2178
+ r.screen({ id: "settings", type: "custom", renderer: { react: "stub" } });
2179
+ });
2180
+ expect(() => validateBoot([a, b])).toThrow(
2181
+ /Screen short-id "settings" is registered by 2 features \(shop, billing\)/,
2182
+ );
2183
+ });
2184
+
2185
+ test("distinct short-ids across features → kein Throw", () => {
2186
+ const a = defineFeature("shop", (r) => {
2187
+ r.screen({ id: "shop-settings", type: "custom", renderer: { react: "stub" } });
2188
+ });
2189
+ const b = defineFeature("billing", (r) => {
2190
+ r.screen({ id: "billing-settings", type: "custom", renderer: { react: "stub" } });
2191
+ });
2192
+ expect(() => validateBoot([a, b])).not.toThrow();
2193
+ });
1984
2194
  });
1985
2195
 
1986
2196
  // --- Tier 2.7e: rowAction rowClick (Row-Body-Klick) ---
@@ -256,6 +256,27 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
256
256
  expect(out.navs.find((n) => n.id === "audience-tenant")?.access).toEqual({ openToAll: true });
257
257
  });
258
258
 
259
+ test("openToAll collapse holds regardless of key declaration order (516/2)", () => {
260
+ // The test above only ever puts the openToAll key first, so
261
+ // unionAccessRules's short-circuit (`if ("openToAll" in rule) return`)
262
+ // never actually has to walk past a role-restricted rule to prove it —
263
+ // `gated` here comes FIRST so a hypothetical short-circuit-on-first-only
264
+ // implementation would fail this one even though it passed the other.
265
+ const mixedWriteReversed = defineFeature("mixedwritereversed", (r) => {
266
+ r.config({
267
+ keys: {
268
+ gated: createTenantConfig("text", { mask: { title: "mwr.gated" } }), // default admin write
269
+ open: createTenantConfig("text", { write: access.all, mask: { title: "mwr.open" } }),
270
+ },
271
+ });
272
+ });
273
+ const out = buildConfigFeatureSchema(createRegistry([mixedWriteReversed]));
274
+ const screen = out.screens.find((s) => s.id === "mixedwritereversed-tenant");
275
+ if (screen?.type !== "configEdit")
276
+ throw new Error('expected configEdit screen "mixedwritereversed-tenant"');
277
+ expect(screen.access).toEqual({ openToAll: true });
278
+ });
279
+
259
280
  test("returns empty (no workspace) when no key opts into the hub via mask", () => {
260
281
  const plain = defineFeature("plain", (r) => {
261
282
  r.config({ keys: { secret: createSystemConfig("text", {}) } });
@@ -29,8 +29,14 @@ describe("defineFeature — bare CRUD verb → entity mapping", () => {
29
29
  });
30
30
 
31
31
  test("feature-name match wins (the fallback is the secondary path)", () => {
32
+ // Two entities (516/1): with only "note" registered, this would also
33
+ // pass via the single-entity fallback, proving nothing about the
34
+ // name-match branch specifically. A second entity ("tag") disables the
35
+ // fallback (entityKeys.length !== 1), so success here can only come
36
+ // from entities[name] matching — the branch this test claims to cover.
32
37
  const f = defineFeature("note", (r) => {
33
38
  r.entity("note", noteEntity);
39
+ r.entity("tag", tagEntity);
34
40
  r.writeHandler(bareCreate);
35
41
  });
36
42
  expect(f.handlerEntityMappings?.["create"]).toBe("note");