@cosmicdrift/kumiko-framework 0.288.0 → 0.290.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 (75) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  6. package/src/api/request-context.ts +28 -0
  7. package/src/api/server.ts +30 -4
  8. package/src/changes.json +75 -0
  9. package/src/compliance/__tests__/sub-processors.test.ts +10 -0
  10. package/src/compliance/sub-processors.ts +14 -1
  11. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  12. package/src/crypto/__tests__/kek-source.test.ts +298 -0
  13. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  14. package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
  15. package/src/crypto/index.ts +3 -0
  16. package/src/crypto/kek-source.ts +189 -0
  17. package/src/crypto/kms-wiring.ts +20 -0
  18. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  19. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  20. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  22. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +194 -1
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  25. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  26. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
  27. package/src/db/event-store-executor-context.ts +79 -0
  28. package/src/db/event-store-executor-write.ts +42 -5
  29. package/src/db/index.ts +1 -0
  30. package/src/db/tenant-db.ts +8 -1
  31. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  33. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
  34. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
  35. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  36. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  37. package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
  38. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
  39. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  40. package/src/engine/boot-validator/action-wiring.ts +2 -1
  41. package/src/engine/boot-validator/entity-handler.ts +10 -4
  42. package/src/engine/boot-validator/screens.ts +4 -12
  43. package/src/engine/extensions/storage-provider.ts +17 -2
  44. package/src/engine/extensions/tenant-data.ts +9 -0
  45. package/src/engine/factories.ts +2 -0
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/qualified-name.ts +9 -0
  48. package/src/engine/screen-helpers.ts +17 -0
  49. package/src/errors/classes.ts +24 -0
  50. package/src/errors/index.ts +3 -0
  51. package/src/errors/member-resolution.ts +12 -0
  52. package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
  53. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  54. package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
  55. package/src/event-store/event-store.ts +23 -2
  56. package/src/event-store/provenance-append.ts +9 -1
  57. package/src/files/__tests__/file-handle.test.ts +28 -1
  58. package/src/files/file-handle.ts +26 -7
  59. package/src/files/index.ts +1 -1
  60. package/src/i18n/required-surface-keys.ts +24 -12
  61. package/src/jobs/job-runner.ts +10 -2
  62. package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
  63. package/src/pipeline/active-membership.ts +10 -4
  64. package/src/pipeline/append-event-core.ts +2 -11
  65. package/src/pipeline/dispatch-shared.ts +31 -18
  66. package/src/pipeline/dispatch-stream.ts +8 -3
  67. package/src/pipeline/dispatch-write.ts +3 -2
  68. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  69. package/src/pipeline/event-dispatcher.ts +18 -5
  70. package/src/pipeline/member-read-only-transaction.ts +2 -6
  71. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  72. package/src/stack/table-helpers.ts +6 -0
  73. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  74. package/src/testing/e2e-generator.ts +4 -3
  75. package/src/ui-types/index.ts +2 -0
@@ -40,6 +40,17 @@ import type { LongTextFieldDef, TextFieldDef } from "../types";
40
40
  const rawField = <T>(f: T) => f as unknown as TextFieldDef;
41
41
  const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
42
42
 
43
+ // The heuristics under test only fire without a personal stance; after #2810
44
+ // the factories can no longer produce that shape.
45
+ const unannotatedText: TextFieldDef = {
46
+ type: "text",
47
+ maxLength: 200,
48
+ required: false,
49
+ searchable: false,
50
+ sortable: false,
51
+ };
52
+ const unannotatedLongText: LongTextFieldDef = { type: "longText", required: false };
53
+
43
54
  // Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
44
55
  // Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
45
56
  // wenn ein Test ein reference-Feld benutzt — sonst liefert der validator
@@ -136,7 +147,7 @@ describe("validateBoot — PII annotations", () => {
136
147
  createEntity({
137
148
  fields: {
138
149
  confused: rawField({
139
- ...createTextField(),
150
+ ...unannotatedText,
140
151
  pii: true,
141
152
  tenantOwned: true,
142
153
  }),
@@ -153,7 +164,7 @@ describe("validateBoot — PII annotations", () => {
153
164
  "vault",
154
165
  createEntity({
155
166
  fields: {
156
- apiToken: createTextField({ sensitive: true }),
167
+ apiToken: { ...unannotatedText, sensitive: true },
157
168
  },
158
169
  }),
159
170
  );
@@ -188,7 +199,7 @@ describe("validateBoot — PII annotations", () => {
188
199
  "vault",
189
200
  createEntity({
190
201
  fields: {
191
- apiToken: createTextField({ sensitive: true, encrypted: true }),
202
+ apiToken: { ...unannotatedText, sensitive: true, encrypted: true },
192
203
  },
193
204
  }),
194
205
  );
@@ -228,7 +239,7 @@ describe("validateBoot — PII annotations", () => {
228
239
  personal: { of: "authorId" },
229
240
  find: "none",
230
241
  }),
231
- authorId: createTextField(),
242
+ authorId: { ...unannotatedText },
232
243
  },
233
244
  }),
234
245
  );
@@ -258,7 +269,7 @@ describe("validateBoot — PII annotations", () => {
258
269
 
259
270
  test("userOwned.ownerField referencing non-user entity warns", () => {
260
271
  const feature = defineFeature("test", (r) => {
261
- r.entity("employee", createEntity({ fields: { name: createTextField() } }));
272
+ r.entity("employee", createEntity({ fields: { name: { ...unannotatedText } } }));
262
273
  stubListHandler(r, "employee");
263
274
  r.entity(
264
275
  "personalNote",
@@ -286,7 +297,7 @@ describe("validateBoot — PII annotations", () => {
286
297
  "thing",
287
298
  createEntity({
288
299
  fields: {
289
- email: createTextField(),
300
+ email: { ...unannotatedText },
290
301
  },
291
302
  }),
292
303
  );
@@ -304,7 +315,7 @@ describe("validateBoot — PII annotations", () => {
304
315
  "thing",
305
316
  createEntity({
306
317
  fields: {
307
- body: createLongTextField(),
318
+ body: { ...unannotatedLongText },
308
319
  },
309
320
  }),
310
321
  );
@@ -322,7 +333,7 @@ describe("validateBoot — PII annotations", () => {
322
333
  "thing",
323
334
  createEntity({
324
335
  fields: {
325
- authorId: createTextField(),
336
+ authorId: { ...unannotatedText },
326
337
  },
327
338
  }),
328
339
  );
@@ -563,7 +574,7 @@ describe("validateBoot — PII annotations", () => {
563
574
  "product",
564
575
  createEntity({
565
576
  fields: {
566
- name: createTextField(),
577
+ name: { ...unannotatedText },
567
578
  },
568
579
  }),
569
580
  );
@@ -581,7 +592,7 @@ describe("validateBoot — PII annotations", () => {
581
592
  "person",
582
593
  createEntity({
583
594
  fields: {
584
- displayName: createTextField(),
595
+ displayName: { ...unannotatedText },
585
596
  },
586
597
  }),
587
598
  );
@@ -626,7 +637,7 @@ describe("validateBoot — retention", () => {
626
637
  "auditEvent",
627
638
  createEntity({
628
639
  fields: {
629
- note: createTextField(),
640
+ note: { ...unannotatedText },
630
641
  },
631
642
  retention: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
632
643
  }),
@@ -641,7 +652,7 @@ describe("validateBoot — retention", () => {
641
652
  "thing",
642
653
  createEntity({
643
654
  fields: {
644
- note: createTextField(),
655
+ note: { ...unannotatedText },
645
656
  },
646
657
  retention: { keepFor: "30d", strategy: "hardDelete", reference: "ghostField" },
647
658
  }),
@@ -852,7 +863,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
852
863
  createEntity({
853
864
  fields: {
854
865
  slug: rawField({
855
- ...createTextField(),
866
+ ...unannotatedText,
856
867
  lookupable: true,
857
868
  }),
858
869
  },
@@ -872,7 +883,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
872
883
  ...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
873
884
  lookupable: true,
874
885
  }),
875
- ownerId: createTextField({ required: true }),
886
+ ownerId: { ...unannotatedText, required: true },
876
887
  },
877
888
  }),
878
889
  );
@@ -922,7 +933,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
922
933
  createEntity({
923
934
  fields: {
924
935
  passwordHash: rawField({
925
- ...createTextField(),
936
+ ...unannotatedText,
926
937
  pii: true,
927
938
  sensitive: true,
928
939
  searchable: true,
@@ -20,8 +20,12 @@ const contactEntity = createEntity({
20
20
  // hook) writes it. secretNote's ownership rule checks authorId, so
21
21
  // create only succeeds if the hook ran BEFORE the field-ownership check
22
22
  // (kumiko-framework#1672 — see also event-store-executor-write.ts).
23
- authorId: createTextField(),
24
- secretNote: createTextField({ access: { write: { User: from("user:id", "authorId") } } }),
23
+ authorId: createTextField({ personal: false, reason: "test_fixture" }),
24
+ secretNote: createTextField({
25
+ personal: false,
26
+ reason: "test_fixture",
27
+ access: { write: { User: from("user:id", "authorId") } },
28
+ }),
25
29
  },
26
30
  });
27
31
 
@@ -41,7 +41,12 @@ describe("createLongTextField — runtime shape", () => {
41
41
  });
42
42
 
43
43
  test("encrypted + sensitive flags type-allowed", () => {
44
- const f = createLongTextField({ encrypted: true, sensitive: true });
44
+ const f = createLongTextField({
45
+ personal: false,
46
+ reason: "test_fixture",
47
+ encrypted: true,
48
+ sensitive: true,
49
+ });
45
50
  expect(f.encrypted).toBe(true);
46
51
  expect(f.sensitive).toBe(true);
47
52
  });
@@ -3,10 +3,12 @@ import {
3
3
  ACTION_FORM_ENTITY,
4
4
  CONFIG_EDIT_ENTITY,
5
5
  fieldLabelKey,
6
+ PROJECTION_DETAIL_ENTITY,
6
7
  requiredKeysFromNav,
7
8
  requiredKeysFromScreen,
8
9
  requiredKeysFromWorkspace,
9
10
  screenTitleKey,
11
+ WRITE_FORM_SECTION_ENTITY,
10
12
  } from "../../i18n/required-surface-keys";
11
13
  import { i18nKey } from "../i18n-key";
12
14
  import type {
@@ -279,3 +281,197 @@ describe("dot-form label opt-in (fw#2313)", () => {
279
281
  expect(requiredKeysFromNav(nav)).toContain("fw2313.optin.nav.beta");
280
282
  });
281
283
  });
284
+
285
+ describe("requiredKeysFromScreen reads section.groups (fw#2986)", () => {
286
+ test("entityEdit: group fields and group titles, override honored", () => {
287
+ const screen: EntityEditScreenDefinition = {
288
+ id: "component-edit",
289
+ type: "entityEdit",
290
+ entity: "component",
291
+ fieldLabels: { name: "publicstatus:override.name" },
292
+ layout: {
293
+ sections: [
294
+ {
295
+ title: "publicstatus:section.basics",
296
+ fields: [],
297
+ groups: [
298
+ { title: "publicstatus:group.identity", fields: ["name"] },
299
+ { title: "publicstatus:group.state", fields: [{ field: "status" }] },
300
+ ],
301
+ },
302
+ ],
303
+ },
304
+ };
305
+ const keys = requiredKeysFromScreen("publicstatus", screen);
306
+ expect(keys).toContain("publicstatus:section.basics");
307
+ expect(keys).toContain("publicstatus:group.identity");
308
+ expect(keys).toContain("publicstatus:group.state");
309
+ expect(keys).toContain("publicstatus:override.name");
310
+ expect(keys).not.toContain(fieldLabelKey("publicstatus", "component", "name"));
311
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
312
+ });
313
+
314
+ test("entityEdit: a section with fields AND groups yields the union of both", () => {
315
+ const screen: EntityEditScreenDefinition = {
316
+ id: "component-edit",
317
+ type: "entityEdit",
318
+ entity: "component",
319
+ layout: {
320
+ sections: [
321
+ {
322
+ fields: ["name"],
323
+ groups: [{ title: "publicstatus:group.state", fields: ["status"] }],
324
+ },
325
+ ],
326
+ },
327
+ };
328
+ const keys = requiredKeysFromScreen("publicstatus", screen);
329
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "name"));
330
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
331
+ });
332
+
333
+ test("actionForm: group fields and group titles", () => {
334
+ const keys = requiredKeysFromScreen("publicstatus", {
335
+ id: "incident-open-form",
336
+ type: "actionForm",
337
+ handler: "publicstatus:write:incident:open",
338
+ fields: { title: { type: "text" }, note: { type: "text" } },
339
+ fieldLabels: { title: "publicstatus:override.title" },
340
+ layout: {
341
+ sections: [
342
+ {
343
+ fields: [],
344
+ groups: [{ title: "publicstatus:group.body", fields: ["title", "note"] }],
345
+ },
346
+ ],
347
+ },
348
+ });
349
+ expect(keys).toContain("publicstatus:group.body");
350
+ expect(keys).toContain("publicstatus:override.title");
351
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "note"));
352
+ });
353
+
354
+ test("secretMint: group fields and group titles in mint and confirm layout", () => {
355
+ const keys = requiredKeysFromScreen("publicstatus", {
356
+ id: "token-mint",
357
+ type: "secretMint",
358
+ handler: "publicstatus:write:token:mint",
359
+ fields: { label: { type: "text" } },
360
+ layout: {
361
+ sections: [
362
+ {
363
+ fields: [],
364
+ groups: [{ title: "publicstatus:group.mint", fields: ["label"] }],
365
+ },
366
+ ],
367
+ },
368
+ reveal: { fields: [{ field: "token", label: "publicstatus:reveal.token" }] },
369
+ confirm: {
370
+ handler: "publicstatus:write:token:confirm",
371
+ fields: { ack: { type: "boolean" } },
372
+ layout: {
373
+ sections: [
374
+ {
375
+ fields: [],
376
+ groups: [{ title: "publicstatus:group.confirm", fields: ["ack"] }],
377
+ },
378
+ ],
379
+ },
380
+ },
381
+ });
382
+ expect(keys).toContain("publicstatus:group.mint");
383
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "label"));
384
+ expect(keys).toContain("publicstatus:group.confirm");
385
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "ack"));
386
+ });
387
+
388
+ test("configEdit: group fields and group titles, override honored", () => {
389
+ const screen: ConfigEditScreenDefinition = {
390
+ id: "settings-retention",
391
+ type: "configEdit",
392
+ scope: "tenant",
393
+ configKeys: {
394
+ days: "publicstatus:config:retentionDays",
395
+ mode: "publicstatus:config:mode",
396
+ },
397
+ fieldLabels: { days: "publicstatus:override.retentionDays" },
398
+ fields: { days: { type: "number" }, mode: { type: "text" } },
399
+ layout: {
400
+ sections: [
401
+ {
402
+ fields: [],
403
+ groups: [{ title: "publicstatus:group.retention", fields: ["days", "mode"] }],
404
+ },
405
+ ],
406
+ },
407
+ };
408
+ const keys = requiredKeysFromScreen("publicstatus", screen);
409
+ expect(keys).toContain("publicstatus:group.retention");
410
+ expect(keys).toContain("publicstatus:override.retentionDays");
411
+ expect(keys).toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "mode"));
412
+ });
413
+
414
+ test("configEdit: dot-form group titles need treatDotFormAsKey, like section titles", () => {
415
+ const screen: ConfigEditScreenDefinition = {
416
+ id: "settings-retention",
417
+ type: "configEdit",
418
+ scope: "tenant",
419
+ configKeys: { days: "publicstatus:config:retentionDays" },
420
+ fields: { days: { type: "number" } },
421
+ layout: {
422
+ sections: [
423
+ {
424
+ fields: [],
425
+ groups: [{ title: "publicstatus.group.retention", fields: ["days"] }],
426
+ },
427
+ ],
428
+ },
429
+ };
430
+ expect(requiredKeysFromScreen("publicstatus", screen)).not.toContain(
431
+ "publicstatus.group.retention",
432
+ );
433
+ expect(requiredKeysFromScreen("publicstatus", screen, { treatDotFormAsKey: true })).toContain(
434
+ "publicstatus.group.retention",
435
+ );
436
+ });
437
+
438
+ test("projectionDetail: group fields and group titles, override honored", () => {
439
+ const keys = requiredKeysFromScreen("publicstatus", {
440
+ id: "incident-detail",
441
+ type: "projectionDetail",
442
+ query: "publicstatus:query:incident:detail",
443
+ fieldLabels: { severity: "publicstatus:override.severity" },
444
+ layout: {
445
+ sections: [
446
+ {
447
+ fields: [],
448
+ groups: [{ title: "publicstatus:group.facts", fields: ["severity", "openedAt"] }],
449
+ },
450
+ ],
451
+ },
452
+ });
453
+ expect(keys).toContain("publicstatus:group.facts");
454
+ expect(keys).toContain("publicstatus:override.severity");
455
+ expect(keys).toContain(fieldLabelKey("publicstatus", PROJECTION_DETAIL_ENTITY, "openedAt"));
456
+ });
457
+
458
+ test("projectionDetail: writeForm sections keep the WRITE_FORM_SECTION_ENTITY namespace", () => {
459
+ const keys = requiredKeysFromScreen("publicstatus", {
460
+ id: "incident-detail",
461
+ type: "projectionDetail",
462
+ query: "publicstatus:query:incident:detail",
463
+ layout: {
464
+ sections: [
465
+ {
466
+ kind: "writeForm",
467
+ title: "publicstatus:section.comment",
468
+ handler: "publicstatus:write:incident:comment",
469
+ fieldDefs: { body: { type: "text" } },
470
+ fields: ["body"],
471
+ },
472
+ ],
473
+ },
474
+ });
475
+ expect(keys).toContain(fieldLabelKey("publicstatus", WRITE_FORM_SECTION_ENTITY, "body"));
476
+ });
477
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../define-feature";
4
+ import { validateHandlerAccess } from "../entity-handler";
5
+
6
+ describe("validateHandlerAccess — anonymous handlers require a rateLimit", () => {
7
+ test("an anonymous handler with no rateLimit throws, naming the handler", () => {
8
+ const feature = defineFeature("rl-anon", (r) => {
9
+ r.writeHandler(
10
+ "note:create",
11
+ z.object({ title: z.string() }),
12
+ async () => ({
13
+ isSuccess: true as const,
14
+ data: {},
15
+ }),
16
+ {
17
+ access: { roles: ["anonymous", "Admin"] },
18
+ },
19
+ );
20
+ });
21
+
22
+ expect(() => validateHandlerAccess(feature)).toThrow(/rl-anon:write:note:create/);
23
+ expect(() => validateHandlerAccess(feature)).toThrow(/declares no rateLimit/);
24
+ });
25
+
26
+ test("an anonymous handler with rateLimit: { disabled: true, reason } boots fine", () => {
27
+ const feature = defineFeature("rl-anon", (r) => {
28
+ r.writeHandler(
29
+ "note:create",
30
+ z.object({ title: z.string() }),
31
+ async () => ({
32
+ isSuccess: true as const,
33
+ data: {},
34
+ }),
35
+ {
36
+ access: { roles: ["anonymous", "Admin"] },
37
+ rateLimit: { disabled: true, reason: "internal replay path" },
38
+ },
39
+ );
40
+ });
41
+
42
+ expect(() => validateHandlerAccess(feature)).not.toThrow();
43
+ });
44
+
45
+ test("an anonymous handler with rateLimit: { per: 'ip', ... } boots fine", () => {
46
+ const feature = defineFeature("rl-anon", (r) => {
47
+ r.writeHandler(
48
+ "note:create",
49
+ z.object({ title: z.string() }),
50
+ async () => ({
51
+ isSuccess: true as const,
52
+ data: {},
53
+ }),
54
+ {
55
+ access: { roles: ["anonymous", "Admin"] },
56
+ rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
57
+ },
58
+ );
59
+ });
60
+
61
+ expect(() => validateHandlerAccess(feature)).not.toThrow();
62
+ });
63
+ });
@@ -7,9 +7,19 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { defineFeature } from "../../define-feature";
9
9
  import { createEntity, createTextField } from "../../factories";
10
- import type { FeatureDefinition } from "../../types";
10
+ import type { FeatureDefinition, TextFieldDef } from "../../types";
11
11
  import { validateRecordOwnedSubjects } from "../record-owned";
12
12
 
13
+ // Presence/absence of the annotation is the test variable here; after #2810
14
+ // the factory can no longer produce the unannotated shape.
15
+ const unannotatedText: TextFieldDef = {
16
+ type: "text",
17
+ maxLength: 200,
18
+ required: false,
19
+ searchable: false,
20
+ sortable: false,
21
+ };
22
+
13
23
  function featureWith(
14
24
  idType: "serial" | "uuid" | undefined,
15
25
  withRecordOwnedField: boolean,
@@ -23,7 +33,7 @@ function featureWith(
23
33
  fields: {
24
34
  body: withRecordOwnedField
25
35
  ? createTextField({ personal: { of: "id" }, find: "none" })
26
- : createTextField(),
36
+ : { ...unannotatedText },
27
37
  },
28
38
  }),
29
39
  );
@@ -3,6 +3,7 @@ import {
3
3
  isFieldsEditSection,
4
4
  normalizeEditField,
5
5
  normalizeListColumn,
6
+ sectionFieldSpecs,
6
7
  } from "../screen-helpers";
7
8
  import type {
8
9
  EditFieldSpec,
@@ -125,7 +126,7 @@ function validateEditLayoutNoFunctions(
125
126
  continue;
126
127
  }
127
128
  if (!isFieldsEditSection(section)) continue;
128
- for (const fieldSpec of section.fields) {
129
+ for (const fieldSpec of sectionFieldSpecs(section)) {
129
130
  validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
130
131
  }
131
132
  }
@@ -177,15 +177,21 @@ export function validateAnonymousRateLimit(
177
177
  access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
178
178
  rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
179
179
  ): void {
180
- // skip: handler doesn't opt into rate-limit, no user-bucket risk
181
- if (!rateLimit) return;
182
- // skip: disabled declarations carry no `.per` to bucket on
183
- if (isRateLimitDisabled(rateLimit)) return;
184
180
  // skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
185
181
  // the user-bucket footgun doesn't apply
186
182
  if (!("roles" in access)) return;
187
183
  // skip: handler doesn't list anonymous, regular role-rate-limit is fine
188
184
  if (!access.roles.includes("anonymous")) return;
185
+ // skip: disabled declarations carry no `.per` to bucket on
186
+ if (isRateLimitDisabled(rateLimit)) return;
187
+ if (!rateLimit) {
188
+ throw new Error(
189
+ `${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but declares no ` +
190
+ `rateLimit — an anonymous, internet-facing endpoint needs an upper bound. Set rateLimit: ` +
191
+ `{ per: "ip", limit: ..., windowSeconds: ... }, or the documented exception ` +
192
+ `rateLimit: { disabled: true, reason: "..." }.`,
193
+ );
194
+ }
189
195
  // skip: rate-limit is already keyed on something safe (ip / tenant)
190
196
  if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per)) return;
191
197
  throw new Error(
@@ -16,6 +16,7 @@ import {
16
16
  normalizeEditField,
17
17
  normalizeListColumn,
18
18
  resolveNavParentScreen,
19
+ sectionFieldSpecs,
19
20
  } from "../screen-helpers";
20
21
  import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
21
22
  import { metricField } from "../types";
@@ -608,15 +609,6 @@ function validateFieldsXorGroups(
608
609
  }
609
610
  }
610
611
 
611
- function flattenFieldsOrGroups(section: {
612
- readonly fields: readonly EditFieldSpec[];
613
- readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
614
- }): readonly EditFieldSpec[] {
615
- return section.groups !== undefined
616
- ? section.groups.flatMap((group) => group.fields)
617
- : section.fields;
618
- }
619
-
620
612
  function validateFormLayoutSections(
621
613
  featureName: string,
622
614
  screenId: string,
@@ -657,7 +649,7 @@ function validateFormLayoutSections(
657
649
  );
658
650
  }
659
651
  validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
660
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
652
+ for (const fieldSpec of sectionFieldSpecs(section)) {
661
653
  const normalized = normalizeEditField(fieldSpec);
662
654
  if (!fieldNames.has(normalized.field)) {
663
655
  throw new Error(
@@ -1420,7 +1412,7 @@ export function validateScreens(
1420
1412
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
1421
1413
  section,
1422
1414
  );
1423
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
1415
+ for (const fieldSpec of sectionFieldSpecs(section)) {
1424
1416
  const normalized = normalizeEditField(fieldSpec);
1425
1417
  if (!fieldNames.has(normalized.field)) {
1426
1418
  throw new Error(
@@ -1837,7 +1829,7 @@ export function validateScreens(
1837
1829
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
1838
1830
  section,
1839
1831
  );
1840
- for (const fieldSpec of flattenFieldsOrGroups(section)) {
1832
+ for (const fieldSpec of sectionFieldSpecs(section)) {
1841
1833
  const normalized = normalizeEditField(fieldSpec);
1842
1834
  if (!fieldNames.has(normalized.field)) {
1843
1835
  throw new Error(
@@ -3,17 +3,32 @@
3
3
  // Mirror of tenant-data.ts, but the destroyTenant hook takes (tenantId, ctx)
4
4
  // rather than just (ctx) — runExtensionDestroyHooks (tenant-lifecycle/stages.ts)
5
5
  // passes tenantId as its own positional arg for every EXT_*_RESOURCE-style
6
- // extension point, not just this one. ctx here only guarantees tenantId plus
6
+ // extension point, not just this one. ctx here guarantees tenantId, db, plus
7
7
  // the optional fileProviderResolver/log; the richer stage-runner ctx
8
8
  // (tenant-lifecycle's DestructionStageCtx) is a structural superset, so a hook
9
9
  // typed against this minimal ctx is safely assignable wherever that richer ctx
10
10
  // is passed.
11
-
11
+ //
12
+ // `db` is a raw DbRunner, NOT a tenant-scoped TenantDb — unlike
13
+ // TenantDataHookCtx.db (EXT_TENANT_DATA), runExtensionDestroyHooks hands every
14
+ // EXT_*_RESOURCE-style hook (this one included) the stage runner's own
15
+ // DestructionStageCtx.db straight through, with no tenant filter and no
16
+ // escapeHatch/unsafeRaw gate — there is nothing to declare, because nothing
17
+ // ever wraps it. That's deliberate for this stage family: wiping a whole
18
+ // search index, an S3 prefix, or infra resources is inherently a cross-tenant,
19
+ // bulk operation, not a row-scoped one. A hook that needs a plain read across
20
+ // tenants (kumiko-framework#3035: does a foreign tenant's file_refs row still
21
+ // reference a key under THIS tenant's storage prefix) uses `db` directly via
22
+ // executeRawQueryRead — there is no `ctx.systemDb` here (that belongs to
23
+ // HandlerContext / r.systemScope() write-handler dispatch, a different code
24
+ // path this stage runner never goes through).
12
25
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
26
+ import type { DbRunner } from "../../db/connection";
13
27
  import type { TenantId } from "../types";
14
28
 
15
29
  export interface StorageProviderHookCtx {
16
30
  readonly tenantId: TenantId;
31
+ readonly db: DbRunner;
17
32
  readonly fileProviderResolver?: FileProviderResolver;
18
33
  readonly log?: (message: string) => void;
19
34
  }
@@ -3,6 +3,7 @@
3
3
  // Mirror of engine/extensions/user-data.ts at tenant granularity.
4
4
  // tenant-lifecycle orchestrates destroy via registry.getExtensionUsages(EXT_TENANT_DATA).
5
5
 
6
+ import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
6
7
  import type { TenantDb } from "../../db/tenant-db";
7
8
  import type { Registry, TenantId } from "../types";
8
9
 
@@ -12,6 +13,14 @@ export interface TenantDataHookCtx {
12
13
  readonly db: TenantDb;
13
14
  readonly registry: Registry;
14
15
  readonly tenantId: TenantId;
16
+ // Threaded through from DestructionStageCtx (tenant-lifecycle/stages.ts),
17
+ // which resolves it unconditionally for every stage. "app-data" is the
18
+ // only stage where a fileRef row still exists to read a storageKey off —
19
+ // the "files" stage's prefix sweep runs after this stage already purged
20
+ // the rows (kumiko-framework#3035). Undefined when no file-provider is
21
+ // wired; a hook that needs it must treat that as "nothing to clean up".
22
+ readonly fileProviderResolver?: FileProviderResolver;
23
+ readonly log?: (message: string) => void;
15
24
  }
16
25
 
17
26
  export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
@@ -472,6 +472,8 @@ export function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
472
472
  readonly retention?: RetentionDef;
473
473
  readonly derivedFields?: EntityDefinition["derivedFields"];
474
474
  readonly tenancy?: T;
475
+ /** See EntityDefinition.transferable. */
476
+ readonly transferable?: boolean;
475
477
  }): F extends FieldsMap ? EntityDefinition<F, T> : never {
476
478
  return {
477
479
  softDelete: false,
@@ -266,6 +266,7 @@ export {
266
266
  isWriteFormEditSection,
267
267
  normalizeEditField,
268
268
  normalizeListColumn,
269
+ sectionFieldSpecs,
269
270
  } from "./screen-helpers";
270
271
  export type { TransitionGraph } from "./state-machine";
271
272
  export { defineTransitions, guardTransition } from "./state-machine";
@@ -130,6 +130,15 @@ export function isKebabSegment(name: string): boolean {
130
130
  return QN_SEGMENT.test(name);
131
131
  }
132
132
 
133
+ // Owning scope of a qualified name — the segment before the first ":".
134
+ // Deliberately lenient where parseQn() throws: callers here hold names that
135
+ // may be unqualified (system consumers, ad-hoc jobs) and want undefined, not
136
+ // an exception.
137
+ export function qnScope(qualifiedName: string): string | undefined {
138
+ const idx = qualifiedName.indexOf(":");
139
+ return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
140
+ }
141
+
133
142
  // Build a fully-qualified entity name from a feature name + QN type + short
134
143
  // name, running both names through toKebab first. This is the canonical
135
144
  // "how the registry qualifies things" helper — both createRegistry and