@cosmicdrift/kumiko-framework 0.377.0 → 0.379.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 (118) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/compose-stacks.md +1 -2
  3. package/agents/examples/currencies-global.md +2 -3
  4. package/agents/examples/delivery-notifications.md +25 -2
  5. package/agents/examples/embedded.md +177 -0
  6. package/agents/examples/event-sourcing.md +6 -5
  7. package/agents/examples/legal-pages.md +5 -5
  8. package/agents/examples/reference-data.md +26 -21
  9. package/agents/examples/relations.md +2 -1
  10. package/agents/features.md +5 -5
  11. package/agents/widgets.md +1 -0
  12. package/dist/api/entity-read-access.d.ts +2 -0
  13. package/dist/api/entity-read-access.js +27 -0
  14. package/dist/api/index.d.ts +1 -0
  15. package/dist/api/index.js +1 -0
  16. package/dist/api/server.js +10 -3
  17. package/dist/api/sse-route.d.ts +5 -0
  18. package/dist/api/sse-route.js +59 -9
  19. package/dist/db/entity-table-from-registry.d.ts +2 -1
  20. package/dist/db/entity-table-meta.d.ts +0 -2
  21. package/dist/db/entity-table-meta.js +1 -3
  22. package/dist/db/event-store-executor-write.js +21 -16
  23. package/dist/db/executor-managed-tables.d.ts +6 -0
  24. package/dist/db/executor-managed-tables.js +40 -0
  25. package/dist/db/index.d.ts +4 -2
  26. package/dist/db/index.js +4 -2
  27. package/dist/db/money.d.ts +3 -2
  28. package/dist/db/money.js +2 -7
  29. package/dist/db/queries/backfill-pii.js +22 -12
  30. package/dist/db/queries/database-now.d.ts +5 -0
  31. package/dist/db/queries/database-now.js +12 -0
  32. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  33. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  34. package/dist/db/query.js +28 -0
  35. package/dist/db/reference-data.d.ts +5 -4
  36. package/dist/db/reference-data.js +23 -21
  37. package/dist/db/table-builder.js +2 -0
  38. package/dist/db/tenant-db-runner.d.ts +2 -0
  39. package/dist/db/tenant-db-runner.js +7 -0
  40. package/dist/db/tenant-db.d.ts +3 -0
  41. package/dist/db/tenant-db.js +54 -18
  42. package/dist/engine/boot-validator/entity-handler.js +12 -4
  43. package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
  44. package/dist/engine/boot-validator/form-derivations.js +82 -0
  45. package/dist/engine/boot-validator/query-refs.js +8 -0
  46. package/dist/engine/boot-validator/screens.js +50 -12
  47. package/dist/engine/build-config-feature-schema.js +33 -10
  48. package/dist/engine/embedded-derived.d.ts +1 -1
  49. package/dist/engine/embedded-derived.js +7 -1
  50. package/dist/engine/feature-ui-extensions.js +4 -0
  51. package/dist/engine/form-derivations.d.ts +21 -0
  52. package/dist/engine/form-derivations.js +175 -0
  53. package/dist/engine/index.d.ts +4 -1
  54. package/dist/engine/index.js +2 -0
  55. package/dist/engine/minor-units.d.ts +2 -0
  56. package/dist/engine/minor-units.js +8 -0
  57. package/dist/engine/registry-state.js +2 -0
  58. package/dist/engine/registry-validate.js +4 -1
  59. package/dist/engine/schema-builder.js +18 -4
  60. package/dist/engine/screen-helpers.d.ts +1 -0
  61. package/dist/engine/screen-helpers.js +3 -0
  62. package/dist/engine/soft-delete-cleanup.js +53 -24
  63. package/dist/engine/steps/unsafe-projection-delete.js +2 -1
  64. package/dist/engine/template-variables.d.ts +2 -1
  65. package/dist/engine/template-variables.js +4 -0
  66. package/dist/engine/types/index.d.ts +2 -2
  67. package/dist/engine/with-form-derivations.d.ts +9 -0
  68. package/dist/engine/with-form-derivations.js +16 -0
  69. package/dist/entrypoint/index.d.ts +1 -3
  70. package/dist/errors/reasons.d.ts +1 -0
  71. package/dist/errors/reasons.js +4 -1
  72. package/dist/errors/serialize.d.ts +1 -0
  73. package/dist/errors/serialize.js +9 -5
  74. package/dist/errors/write-error-info.js +3 -2
  75. package/dist/errors/zod-bridge.js +7 -0
  76. package/dist/event-store/index.d.ts +1 -0
  77. package/dist/event-store/index.js +1 -0
  78. package/dist/files/action-form-upload.d.ts +16 -0
  79. package/dist/files/action-form-upload.js +45 -0
  80. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  81. package/dist/files/bind-pending-file-refs.js +44 -4
  82. package/dist/files/create-file-ref.d.ts +3 -0
  83. package/dist/files/create-file-ref.js +41 -13
  84. package/dist/files/file-routes.js +29 -2
  85. package/dist/files/index.d.ts +2 -1
  86. package/dist/files/index.js +1 -0
  87. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  88. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  89. package/dist/i18n/settings-hub-keys.js +1 -0
  90. package/dist/internal/testing.d.ts +2 -0
  91. package/dist/internal/testing.js +15 -1
  92. package/dist/migrations/pii-event-backfill.js +14 -7
  93. package/dist/observability/escape-hatch-report.d.ts +1 -1
  94. package/dist/observability/escape-hatch-report.js +14 -3
  95. package/dist/pipeline/cascade-handler.js +79 -10
  96. package/dist/pipeline/dispatch-shared.js +1 -9
  97. package/dist/pipeline/msp-rebuild.js +5 -3
  98. package/dist/pipeline/projection-rebuild.js +4 -2
  99. package/dist/pipeline/projections-runner.js +2 -1
  100. package/dist/rate-limit/index.d.ts +1 -1
  101. package/dist/rate-limit/index.js +1 -1
  102. package/dist/rate-limit/middleware.d.ts +4 -0
  103. package/dist/rate-limit/middleware.js +5 -2
  104. package/dist/search/configure-tenant-index.d.ts +4 -0
  105. package/dist/search/configure-tenant-index.js +8 -0
  106. package/dist/search/index.d.ts +1 -0
  107. package/dist/search/index.js +1 -0
  108. package/dist/ui-types/index.d.ts +4 -2
  109. package/dist/ui-types/index.js +3 -2
  110. package/dist/ui-types/list-row-meta.d.ts +3 -0
  111. package/dist/ui-types/list-row-meta.js +12 -1
  112. package/kumiko-es-tables.json +6 -0
  113. package/package.json +4 -3
  114. package/src/changes.json +142 -0
  115. package/src/scripts/codemod/README.md +20 -0
  116. package/src/scripts/codemod/codemod-support.ts +1 -1
  117. package/src/scripts/codemod/feature-layout.ts +416 -0
  118. package/src/scripts/codemod/package-merge.ts +0 -11
package/AGENTS.md CHANGED
@@ -195,7 +195,10 @@ produces code that compiles and then fails in production.
195
195
  - Never `INSERT`, `UPDATE` or `DELETE` on an entity table or the events
196
196
  table, and never call a raw database client for it. Write through a
197
197
  handler: `r.crud`, the `defineEntity*Handler` helpers, the executor from
198
- `createEntityExecutor`, or `emitEvent` / `ctx.appendEvent`.
198
+ `createEntityExecutor`, or `emitEvent` / `ctx.appendEvent`. The query
199
+ helpers and `ctx.db` throw `AccessDeniedError` on such a write, also
200
+ through `ctx.db.unsafeRaw()`. Test seeds use `seedRow` / `deleteRows` from
201
+ `@cosmicdrift/kumiko-testing`.
199
202
  - Never store a value that can be computed. Derive it in a query or a
200
203
  projection.
201
204
  - Never hand-write SQL for reads that an executor `list` or `detail` covers.
@@ -26,8 +26,7 @@ the option.
26
26
  `securityBaselineFeatures()`.
27
27
  - **`composeGdprStack({ tenantLifecycle, selfService })`**: `data-retention`
28
28
  and `compliance-profiles`, optionally `tenant-lifecycle`. `selfService` adds
29
- `sessions`, `user-data-rights` and `user-profile` (needs `mail`) and
30
- replaces `dsgvoSelfServiceFeatures()`.
29
+ `sessions`, `user-data-rights` and `user-profile` (needs `mail`).
31
30
  - **`composeSaasStack({ billing, tiers, caps, waitlist })`**:
32
31
  `billing-foundation` and `tier-engine`, plus `cap-counter`/`cap-overview`
33
32
  and `waitlist` when configured. `billing: "stripe"` leaves
@@ -59,7 +59,7 @@ import {
59
59
  type TestStack,
60
60
  unsafeCreateEntityTable,
61
61
  } from "@cosmicdrift/kumiko-testing/stack";
62
- import { currencyEntity, currencyTable } from "../entities/currency";
62
+ import { currencyEntity } from "../entities/currency";
63
63
  import { invoiceEntity } from "../entities/invoice";
64
64
  import { tenantCurrencyEntity } from "../entities/tenant-currency";
65
65
  import { currenciesGlobalFeature } from "../feature";
@@ -84,8 +84,7 @@ beforeAll(async () => {
84
84
  await unsafeCreateEntityTable(stack.db, invoiceEntity);
85
85
 
86
86
  // Seed global currency table — same as r.referenceData() does at boot
87
- const tables = new Map([["currency", currencyTable]]);
88
- await seedReferenceData(currenciesGlobalFeature.referenceData, tables, stack.db);
87
+ await seedReferenceData(currenciesGlobalFeature.referenceData, stack.registry, stack.db);
89
88
 
90
89
  // --- Assign currencies to tenant 1: EUR, USD (standard) + BHD, XYZ (custom) ---
91
90
  for (const currencyCode of ["EUR", "USD", "BHD", "XYZ"]) {
@@ -16,7 +16,7 @@ Shows how a feature sends notifications to multiple channels (InApp + Email + Pu
16
16
 
17
17
  **Mail text is stored data:** The boot seed (`seedNotificationTemplates`) writes the declared `template` and `meta` into the template store as system templates. The email channel renders them with `renderer-mail-html`. A notification routed to email without `template` and `meta` fails its delivery.
18
18
 
19
- **Tenant override:** A tenant admin overrides the text through the template-resolver handlers (`upsertTenant`, then `publish`). The next mail for that tenant uses the override; the system text stays for everyone else.
19
+ **Tenant override:** Only a SystemAdmin edits templates unless the app unlocks them. The app lists the mail in `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug, access: { roles: ["TenantAdmin"] } }] })`; then a tenant admin overrides the text through the template-resolver handlers (`upsertTenant`, then `publish`) and every other template is refused with 403. The next mail for that tenant uses the override; the system text stays for everyone else.
20
20
 
21
21
  **Sending:** The write handler calls `ctx.notify(type, { to, data })`. The `data` keys are the template variables; sending fails loudly when a required variable is missing.
22
22
 
@@ -137,7 +137,9 @@ const assignee = createTestUser({ id: 5, roles: ["Support"] });
137
137
  const features = [
138
138
  createConfigFeature(),
139
139
  createTenantFeature(),
140
- createTemplateResolverFeature(),
140
+ createTemplateResolverFeature({
141
+ tenantEditable: [{ kind: "mail", slug: TICKET_ASSIGNED, access: { roles: ["TenantAdmin"] } }],
142
+ }),
141
143
  createRendererFoundationFeature(),
142
144
  createRendererMailHtmlFeature(),
143
145
  createDeliveryFeature(),
@@ -275,6 +277,27 @@ describe("delivery-notifications sample", () => {
275
277
  expect(email?.html).toContain("Tenant text: Printer on fire");
276
278
  });
277
279
 
280
+ test("a tenant admin may not override a mail the app did not list in tenantEditable", async () => {
281
+ const tenantAdmin = { ...admin, roles: ["TenantAdmin"] };
282
+
283
+ const error = await stack.http.writeErr(
284
+ TemplateResolverHandlers.upsertTenant,
285
+ {
286
+ slug: "support:notify:ticket-closed",
287
+ kind: "mail",
288
+ locale: "en",
289
+ content: "<p>Closed</p>",
290
+ contentFormat: "html",
291
+ meta: { subject: "Closed" },
292
+ variableSchema: {},
293
+ },
294
+ tenantAdmin,
295
+ );
296
+
297
+ expect(error.code).toBe("access_denied");
298
+ expect(error.i18nKey).toBe("templateResolver.errors.templateNotTenantEditable");
299
+ });
300
+
278
301
  test("ticket without assignee skips notification (recipient returns null)", async () => {
279
302
  emailTransport.sent.length = 0;
280
303
  pushTransport.sent.length = 0;
@@ -27,6 +27,14 @@ object and an embedded list rendered by a declarative `entityEdit` screen.
27
27
  `createEmbeddedListField`; its renderer receives `embeddedListCells`, so no
28
28
  custom screen component is required.
29
29
 
30
+ - **Derived head totals** (`src/screens/invoice-receive.screen.ts`): an
31
+ `actionForm` declares `derive` so net, VAT, and gross follow the lines
32
+ (`listSum`); the list requires at least one line (`minItems: 1`), so there
33
+ is no lineless input to cover. The head totals are not laid out; the lines footer shows Net, VAT, and Gross through labelled `totals`
34
+ entries (`{ label, from }`). The handler wraps its schema with
35
+ `withFormDerivations(schema, screen)` (`src/handlers/invoice-receive.write.ts`),
36
+ so totals sent by the client are replaced on the server.
37
+
30
38
  ## When to reach for it
31
39
 
32
40
  Use an embedded field or list when the nested data is created, changed, and
@@ -397,6 +405,64 @@ describe("invoice line items with select/reference/derived/totals metadata", ()
397
405
  expect(error).toBeDefined();
398
406
  });
399
407
  });
408
+
409
+ // --- actionForm derive ---
410
+
411
+ describe("invoice receive form derives its totals on the server", () => {
412
+ type ReceiveTotals = {
413
+ netTotal: { amount: number; currency: string };
414
+ vatTotal: { amount: number; currency: string };
415
+ grossTotal: { amount: number; currency: string };
416
+ };
417
+ const eur = (amount: number) => ({ amount, currency: "EUR" });
418
+ const head = { supplier: "Papier AG", invoiceNumber: "RE-2026-0042" };
419
+
420
+ test("with lines, manipulated client totals are replaced by the line sums", async () => {
421
+ const totals = await stack.http.writeOk<ReceiveTotals>(
422
+ "contacts:write:invoice:receive",
423
+ {
424
+ ...head,
425
+ lines: [
426
+ { description: "Paper", amount: 1005, vatRate: 19, vatAmount: 5 },
427
+ { description: "Toner", amount: 2000, vatRate: 7 },
428
+ ],
429
+ netTotal: eur(1),
430
+ vatTotal: eur(2),
431
+ grossTotal: eur(3),
432
+ },
433
+ admin,
434
+ );
435
+ expect(totals).toEqual({
436
+ netTotal: eur(30.05),
437
+ vatTotal: eur(3.31),
438
+ grossTotal: eur(33.36),
439
+ });
440
+ });
441
+
442
+ test("an invoice without lines is rejected, the totals have nothing to derive from", async () => {
443
+ const error = await stack.http.writeErr(
444
+ "contacts:write:invoice:receive",
445
+ { ...head, lines: [], netTotal: eur(100), vatTotal: eur(0), grossTotal: eur(0) },
446
+ admin,
447
+ );
448
+ expect(error.code).toBe("validation_error");
449
+ });
450
+
451
+ test("a caller without the Admin role is rejected", async () => {
452
+ const error = await stack.http.writeErr(
453
+ "contacts:write:invoice:receive",
454
+ {
455
+ ...head,
456
+ lines: [{ description: "Paper", amount: 1005, vatRate: 19 }],
457
+ netTotal: eur(0),
458
+ vatTotal: eur(0),
459
+ grossTotal: eur(0),
460
+ },
461
+ viewer,
462
+ );
463
+ expect(error.code).toBe("access_denied");
464
+ });
465
+ });
400
466
  ```
401
467
 
402
468
  ### src/entities/contact.ts
@@ -529,6 +595,9 @@ export const productTable = buildEntityTable("product", productEntity);
529
595
  // on its `lines` sub-fields (Issue #1835), plus a declarative
530
596
  // entityEdit screen that renders the list end-to-end via
531
597
  // EmbeddedListField, no custom-screen code required
598
+ // invoice-receive — actionForm whose net/VAT/gross head fields are derived
599
+ // from the lines (`derive`); the handler recomputes them with
600
+ // `withFormDerivations`, so client-sent totals are never trusted
532
601
 
533
602
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
534
603
  import { contactEntity } from "./entities/contact";
@@ -536,6 +605,8 @@ import { invoiceEntity } from "./entities/invoice";
536
605
  import { productEntity } from "./entities/product";
537
606
  import { contactCreate } from "./handlers/contact-create.write";
538
607
  import { contactDetail } from "./handlers/contact-detail.query";
608
+ import { invoiceReceive } from "./handlers/invoice-receive.write";
609
+ import { invoiceReceiveScreen } from "./screens/invoice-receive.screen";
539
610
 
540
611
  export { contactEntity } from "./entities/contact";
541
612
  export { invoiceEntity } from "./entities/invoice";
@@ -557,6 +628,7 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
557
628
 
558
629
  r.writeHandler(contactCreate);
559
630
  r.queryHandler(contactDetail);
631
+ r.writeHandler(invoiceReceive);
560
632
 
561
633
  r.crud("product", productEntity, { write: adminWrite, read: openRead });
562
634
  r.crud("invoice", invoiceEntity, { write: adminWrite, read: openRead });
@@ -569,6 +641,8 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
569
641
  sections: [{ columns: 1, fields: ["customer", "lines"] }],
570
642
  },
571
643
  });
644
+
645
+ r.screen(invoiceReceiveScreen);
572
646
  });
573
647
  ```
574
648
 
@@ -646,3 +720,106 @@ export const contactDetail = defineQueryHandler({
646
720
  },
647
721
  });
648
722
  ```
723
+
724
+ ### src/handlers/invoice-receive.write.ts
725
+
726
+ ```ts
727
+ import {
728
+ defineWriteHandler,
729
+ fieldToZod,
730
+ withFormDerivations,
731
+ } from "@cosmicdrift/kumiko-framework/engine";
732
+ import { z } from "zod";
733
+ import { invoiceReceiveLines, invoiceReceiveScreen } from "../screens/invoice-receive.screen";
734
+
735
+ const moneyPayload = z.object({ amount: z.number(), currency: z.string().min(1) });
736
+
737
+ // The preprocess recomputes net/VAT/gross before validation: totals sent by the
738
+ // client never reach the handler.
739
+ const receiveInput = withFormDerivations(
740
+ z.object({
741
+ supplier: z.string().min(1),
742
+ invoiceNumber: z.string().min(1),
743
+ lines: fieldToZod(invoiceReceiveLines, ["EUR"]),
744
+ netTotal: moneyPayload,
745
+ vatTotal: moneyPayload,
746
+ grossTotal: moneyPayload,
747
+ }),
748
+ invoiceReceiveScreen,
749
+ );
750
+
751
+ export const invoiceReceive = defineWriteHandler({
752
+ name: "invoice:receive",
753
+ schema: receiveInput,
754
+ access: { roles: ["Admin"] },
755
+ handler: async (event) => ({
756
+ isSuccess: true,
757
+ data: {
758
+ netTotal: event.payload.netTotal,
759
+ vatTotal: event.payload.vatTotal,
760
+ grossTotal: event.payload.grossTotal,
761
+ },
762
+ }),
763
+ });
764
+ ```
765
+
766
+ ### src/screens/invoice-receive.screen.ts
767
+
768
+ ```ts
769
+ import {
770
+ type ActionFormScreenDefinition,
771
+ createEmbeddedListField,
772
+ createTextField,
773
+ } from "@cosmicdrift/kumiko-framework/engine";
774
+
775
+ const euro = { kind: "literal", code: "EUR" } as const;
776
+
777
+ export const invoiceReceiveLines = createEmbeddedListField(
778
+ {
779
+ description: { type: "text", required: true },
780
+ amount: { type: "money", required: true },
781
+ vatRate: { type: "decimal", scale: 2, required: true },
782
+ vatAmount: { type: "money" },
783
+ },
784
+ {
785
+ minItems: 1,
786
+ derived: { vatAmount: { op: "percent", from: ["amount", "vatRate"] } },
787
+ totals: [
788
+ { label: "invoice.total.net", from: ["amount"] },
789
+ { label: "invoice.total.vat", from: ["vatAmount"] },
790
+ { label: "invoice.total.gross", from: ["amount", "vatAmount"] },
791
+ ],
792
+ },
793
+ );
794
+
795
+ // Net/VAT/gross are derived on the server from the lines (at least one is required)
796
+ // and shown only in the list footer, so the head totals are declared but not laid out.
797
+ export const invoiceReceiveScreen: ActionFormScreenDefinition = {
798
+ id: "invoice-receive",
799
+ type: "actionForm",
800
+ handler: "contacts:write:invoice:receive",
801
+ fields: {
802
+ supplier: createTextField({ required: true, personal: "tenant", find: "none" }),
803
+ invoiceNumber: createTextField({
804
+ required: true,
805
+ personal: false,
806
+ reason: "is_business_data",
807
+ }),
808
+ lines: invoiceReceiveLines,
809
+ netTotal: { type: "money", currency: euro },
810
+ vatTotal: { type: "money", currency: euro },
811
+ grossTotal: { type: "money", currency: euro },
812
+ },
813
+ derive: {
814
+ netTotal: { op: "listSum", list: "lines", cell: "amount" },
815
+ vatTotal: { op: "listSum", list: "lines", cell: "vatAmount" },
816
+ grossTotal: { op: "sum", from: ["netTotal", "vatTotal"] },
817
+ },
818
+ layout: {
819
+ sections: [
820
+ { columns: 2, fields: ["supplier", "invoiceNumber"] },
821
+ { columns: 1, fields: ["lines"] },
822
+ ],
823
+ },
824
+ };
825
+ ```
@@ -97,7 +97,7 @@ Six integration tests pin everything at once:
97
97
 
98
98
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
99
99
  import type { DbTx } from "@cosmicdrift/kumiko-framework/db";
100
- import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
100
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
101
101
  import {
102
102
  append,
103
103
  loadAggregate as loadAggregateRaw,
@@ -107,6 +107,7 @@ import {
107
107
  } from "@cosmicdrift/kumiko-framework/event-store";
108
108
  import { getAllProjectionProgress } from "@cosmicdrift/kumiko-framework/pipeline";
109
109
  import { createTestUser } from "@cosmicdrift/kumiko-framework/seeding";
110
+ import { seedRow } from "@cosmicdrift/kumiko-testing/helpers";
110
111
  import {
111
112
  resetEventStore,
112
113
  setupTestStack,
@@ -270,7 +271,7 @@ describe("Event Sourcing Showcase", () => {
270
271
  // on disk today — the upcaster walks them to v2 on read.
271
272
  const invoiceId = "00000000-0000-4000-8000-00000000f001";
272
273
  await stack.db.begin(async (tx: DbTx) => {
273
- await insertOne(tx, invoiceDetailTable, {
274
+ await seedRow(tx, invoiceDetailTable, {
274
275
  invoiceId,
275
276
  tenantId: admin.tenantId,
276
277
  customer: "Legacy Co",
@@ -388,7 +389,7 @@ describe("Event Sourcing Showcase", () => {
388
389
  // Seed only v1-shaped events directly to prove upcasting walks delta.
389
390
  const invoiceId = "00000000-0000-4000-8000-00000000f002";
390
391
  await stack.db.begin(async (tx: DbTx) => {
391
- await insertOne(tx, invoiceDetailTable, {
392
+ await seedRow(tx, invoiceDetailTable, {
392
393
  invoiceId,
393
394
  tenantId: admin.tenantId,
394
395
  customer: "Upcast Co",
@@ -528,7 +529,7 @@ describe("Event Sourcing Showcase", () => {
528
529
 
529
530
  test("async upcaster: invoice-acknowledged v1 enriches via directory DB lookup at read time", async () => {
530
531
  // Seed the directory so the async upcaster can find a name.
531
- await insertOne(stack.db, approverDirectoryTable, {
532
+ await seedRow(stack.db, approverDirectoryTable, {
532
533
  approverId: "u-42",
533
534
  displayName: "Quincy Acknowledger",
534
535
  });
@@ -539,7 +540,7 @@ describe("Event Sourcing Showcase", () => {
539
540
  // shape via the raw event-store.
540
541
  const invoiceId = "00000000-0000-4000-8000-00000000ac01";
541
542
  await stack.db.begin(async (tx: DbTx) => {
542
- await insertOne(tx, invoiceDetailTable, {
543
+ await seedRow(tx, invoiceDetailTable, {
543
544
  invoiceId,
544
545
  tenantId: admin.tenantId,
545
546
  customer: "AckCo",
@@ -81,7 +81,7 @@ await runProdApp({
81
81
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
82
82
  // Required (2): boot check + internal lookup use ctx.templateResolver
83
83
  extraContext: ({ db }) => ({
84
- templateResolver: createTemplateResolverApi(db),
84
+ templateResolver: createTemplateResolverApi(db, registry),
85
85
  }),
86
86
  });
87
87
  ```
@@ -215,7 +215,7 @@ from SYSTEM_TENANT:
215
215
  | Symptom | Cause | Fix |
216
216
  |---|---|---|
217
217
  | Route returns `503 legal page unavailable` | `anonymousAccess` not configured in `runProdApp` | Set `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` |
218
- | Boot check throws `ctx.templateResolver missing` | `extraContext.templateResolver` not wired | Set `extraContext: ({ db }) => ({ templateResolver: createTemplateResolverApi(db) })` |
218
+ | Boot check throws `ctx.templateResolver missing` | `extraContext.templateResolver` not wired | Set `extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) })` |
219
219
  | Route returns `404 not configured` | Required block doesn't exist or has `content=null` | `seedTextBlock` with a content string |
220
220
  | Multi-tenant app: tenant subdomain shows an empty page | (Bug regression?) Routes should ALWAYS show SYSTEM_TENANT texts | The `legal-pages.integration.test.ts` test "SYSTEM_TENANT routing" covers this — should be green |
221
221
  | `<script>` tags in a Markdown body land 1:1 in the HTML | Deliberately accepted right now — see [legal-pages/README.md XSS section](../../../packages/bundled-features/src/legal-pages/README.md#xss--currently-not-secured-by-design) | DOMPurify is a Phase 2 once a multi-author setup arrives |
@@ -259,8 +259,8 @@ beforeAll(async () => {
259
259
  stack = await setupTestStack({
260
260
  features: [templateResolverFeature, legalPagesFeature],
261
261
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
262
- extraContext: ({ db }) => ({
263
- templateResolver: createTemplateResolverApi(db),
262
+ extraContext: ({ db, registry }) => ({
263
+ templateResolver: createTemplateResolverApi(db, registry),
264
264
  }),
265
265
  });
266
266
  db = stack.db;
@@ -407,7 +407,7 @@ describe("legal-pages sample", () => {
407
407
  // - `anonymousAccess` muss in runProdApp/runDevApp konfiguriert sein
408
408
  // (defaultTenantId = SYSTEM_TENANT_ID), sonst antworten die
409
409
  // legal-pages-Routes mit 503
410
- // - `extraContext.templateResolver = createTemplateResolverApi(db)` muss
410
+ // - `extraContext.templateResolver = createTemplateResolverApi(db, registry)` muss
411
411
  // gewired sein, sonst wirft der Boot-Check mit Wiring-Hinweis
412
412
  // - Beim ersten Boot müssen die TextBlocks geseedet sein —
413
413
  // template-resolver/seeding `seedTextBlock` oder via API
@@ -40,33 +40,35 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
40
40
 
41
41
  import { seedReferenceData, selectMany } from "@cosmicdrift/kumiko-framework/db";
42
42
  import type { ReferenceDataDef } from "@cosmicdrift/kumiko-framework/engine";
43
- import { createTestDb, type TestDb, unsafePushTables } from "@cosmicdrift/kumiko-testing/stack";
44
- import { categoryFeature, categoryTable } from "../feature";
43
+ import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
44
+ import {
45
+ setupTestStack,
46
+ type TestStack,
47
+ unsafeCreateEntityTable,
48
+ } from "@cosmicdrift/kumiko-testing/stack";
49
+ import { categoryEntity, categoryFeature, categoryTable } from "../feature";
45
50
 
46
- let testDb: TestDb;
51
+ let stack: TestStack;
47
52
 
48
53
  beforeAll(async () => {
49
- testDb = await createTestDb();
50
- await unsafePushTables(testDb.db, { categoryTable });
54
+ stack = await setupTestStack({ features: [categoryFeature] });
55
+ await unsafeCreateEntityTable(stack.db, categoryEntity, "category");
51
56
  });
52
57
 
53
58
  afterAll(async () => {
54
- await testDb?.cleanup();
59
+ await stack?.cleanup();
55
60
  });
56
61
 
57
62
  type CategoryRow = { code: string; name: string; description: string | null };
58
63
 
59
64
  async function readCategories(): Promise<CategoryRow[]> {
60
- const rows = await selectMany(testDb.db, categoryTable);
65
+ const rows = await selectMany(stack.db, categoryTable);
61
66
  return [...rows].sort((a: CategoryRow, b: CategoryRow) => a.code.localeCompare(b.code));
62
67
  }
63
68
 
64
69
  describe("reference data seeding", () => {
65
70
  test("inserts initial seed data", async () => {
66
- const tables = new Map([["category", categoryTable]]);
67
-
68
- // seedReferenceData(defs, tables, db)
69
- await seedReferenceData(categoryFeature.referenceData, tables, testDb.db);
71
+ await seedReferenceData(categoryFeature.referenceData, stack.registry, stack.db);
70
72
 
71
73
  const rows = await readCategories();
72
74
  expect(rows).toHaveLength(4);
@@ -77,8 +79,6 @@ describe("reference data seeding", () => {
77
79
  });
78
80
 
79
81
  test("upsert updates existing rows without duplicating", async () => {
80
- const tables = new Map([["category", categoryTable]]);
81
-
82
82
  // Modified seed data: changed name for "books", added new category
83
83
  const updatedDefs: ReferenceDataDef[] = [
84
84
  {
@@ -94,7 +94,7 @@ describe("reference data seeding", () => {
94
94
  },
95
95
  ];
96
96
 
97
- await seedReferenceData(updatedDefs, tables, testDb.db);
97
+ await seedReferenceData(updatedDefs, stack.registry, stack.db);
98
98
 
99
99
  const rows = await readCategories();
100
100
  expect(rows).toHaveLength(5);
@@ -104,6 +104,15 @@ describe("reference data seeding", () => {
104
104
 
105
105
  const sports = rows.find((r: CategoryRow) => r.code === "sports");
106
106
  expect(sports?.name).toBe("Sports");
107
+
108
+ // Seeded rows are event-sourced: a projection rebuild keeps them.
109
+ await rebuildProjection("catalog:projection:category-entity", {
110
+ db: stack.db,
111
+ registry: stack.registry,
112
+ });
113
+ const rebuilt = await readCategories();
114
+ expect(rebuilt).toHaveLength(5);
115
+ expect(rebuilt.find((r: CategoryRow) => r.code === "books")?.name).toBe("Books & Media");
107
116
  });
108
117
  });
109
118
  ```
@@ -114,7 +123,7 @@ describe("reference data seeding", () => {
114
123
  // Reference Data Sample
115
124
  // Shows: r.referenceData() for static seed data with upsert logic
116
125
 
117
- import { table, text } from "@cosmicdrift/kumiko-framework/db";
126
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
118
127
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
119
128
 
120
129
  export const categoryEntity = createEntity({
@@ -126,12 +135,8 @@ export const categoryEntity = createEntity({
126
135
  },
127
136
  });
128
137
 
129
- // Drizzle table for direct DB queries in tests
130
- export const categoryTable = table("read_sample_categories", {
131
- code: text("code").notNull(),
132
- name: text("name").notNull(),
133
- description: text("description"),
134
- });
138
+ // Read-model table for direct DB queries in tests
139
+ export const categoryTable = buildEntityTable("category", categoryEntity);
135
140
 
136
141
  export const categoryFeature = defineFeature("catalog", (r) => {
137
142
  r.entity({ name: "category", ...categoryEntity });
@@ -37,6 +37,7 @@ bun test
37
37
  // Proves: hasMany relations, cascade delete, restrict delete
38
38
 
39
39
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
40
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
40
41
  import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
41
42
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
42
43
  import { createCascadeDeleteHook } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -177,7 +178,7 @@ describe("cascade: deleting member removes tasks", () => {
177
178
  entityName: "member",
178
179
  event: createTestStoredEvent(),
179
180
  },
180
- { db: stack.db },
181
+ { db: createTenantDb({ db: stack.db, tenantId: admin.tenantId }) },
181
182
  );
182
183
 
183
184
  // Tasks should be gone
@@ -10,7 +10,7 @@ Requires: `sessions`, `auth-mfa`
10
10
 
11
11
  ## admin-shell
12
12
 
13
- Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards, built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.
13
+ Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards, built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.
14
14
 
15
15
  Category: operations
16
16
 
@@ -24,7 +24,7 @@ Category: ai
24
24
 
25
25
  ## audit
26
26
 
27
- Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection — the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: "system"` to read the app-instance system events such as `app.started`. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).
27
+ Exposes the framework's event store as a paginated, filterable audit log via the `audit:query:list` handler (accessible to `Admin` and `SystemAdmin` roles). No separate table or projection — the event store is the audit trail by construction: every entity write already records who, when, what entity, and the event payload with PII stripped. A SystemAdmin can pass `scope: "system"` to read the app-instance system events such as `app.started`, and reads the events of every tenant (system tenant excluded) through `audit:query:list-all-tenants` and the `audit-log-all-tenants` screen. Filter by `aggregateType`, `aggregateId`, `eventType`, `userId`, or time range. Also records `audit:event:escape-hatch-used` whenever a handler uses one of the framework's escape hatches (unsafeRaw, acknowledgeCrossTenant, db.global() writes, or a granted identity switch).
28
28
 
29
29
  Category: compliance
30
30
 
@@ -228,7 +228,7 @@ Requires: `file-derivatives`
228
228
 
229
229
  ## document-designer
230
230
 
231
- Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`.
231
+ Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`. The tenant overview and the editors are for SystemAdmin only; the `access` option adds roles (normally `templateEditAccess(tenantEditable)` of the template-resolver mount), and the server still enforces per template which of them may save.
232
232
 
233
233
  Category: content
234
234
 
@@ -564,7 +564,7 @@ Requires: `renderer-foundation`
564
564
 
565
565
  ## renderer-puppeteer-client
566
566
 
567
- A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
567
+ A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 502, 503 and network errors with exponential backoff (default 3 retries); 4xx, other 5xx and client timeouts fail at once, because the render has already run. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
568
568
 
569
569
  Requires: `renderer-foundation`
570
570
 
@@ -644,7 +644,7 @@ Category: data
644
644
 
645
645
  ## template-resolver
646
646
 
647
- Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
647
+ Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. The same list decides delivery: a tenant override is served (mail, PDF, layouts, text blocks) only while its template is listed in tenantEditable; otherwise the system version is served. A locked override stays stored, a SystemAdmin sees it in the overview marked as not delivered, and listing the template makes it effective again. Tenant rows of a collection mounted with `access` are always served. A collection mounted without `access` is SystemAdmin only. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
648
648
 
649
649
  Category: notifications
650
650
 
package/agents/widgets.md CHANGED
@@ -55,6 +55,7 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
55
55
  | `NavTree` | |
56
56
  | `NumberField` | Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die wiederholte id/name/value/onChange-Verdrahtung ab. |
57
57
  | `PageSection` | |
58
+ | `PdfPreview` | |
58
59
  | `PercentField` | |
59
60
  | `PhotoSlots` | Guided photo capture: one tile per requested shot in a two-column grid, each its own camera/gallery picker, filled tiles show the thumbnail. |
60
61
  | `PlainContentEditor` | |
@@ -0,0 +1,2 @@
1
+ import type { AccessRule, Registry } from "../engine/types/index.ts";
2
+ export declare function collectEntityReadAccess(registry: Registry): ReadonlyMap<string, readonly AccessRule[]>;
@@ -0,0 +1,27 @@
1
+ // Access rules of the query handlers that let a role read an entity (the
2
+ // handler's own entity or one it declares via liveEntities). A live signal
3
+ // carries the id of every row of the entity, so a signed-in connection gets it
4
+ // only when one of these rules grants its roles. An aggregate type without a
5
+ // reading query has no entry and is never put on the tenant channel.
6
+ function toKebabCase(name) {
7
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
8
+ }
9
+ export function collectEntityReadAccess(registry) {
10
+ const rulesByEntity = new Map();
11
+ for (const [handlerName, handler] of registry.getAllQueryHandlers()) {
12
+ const entities = new Set(handler.liveEntities ?? []);
13
+ const ownEntity = registry.getHandlerEntity(handlerName);
14
+ if (ownEntity !== undefined)
15
+ entities.add(ownEntity);
16
+ for (const entityName of entities) {
17
+ // Executors may write under the kebab-case form of the camelCase r.entity() name
18
+ // (document-extract for documentExtract); the signal carries that aggregate type.
19
+ for (const aggregateType of new Set([entityName, toKebabCase(entityName)])) {
20
+ const rules = rulesByEntity.get(aggregateType) ?? [];
21
+ rules.push(handler.access);
22
+ rulesByEntity.set(aggregateType, rules);
23
+ }
24
+ }
25
+ }
26
+ return rulesByEntity;
27
+ }
@@ -1,3 +1,4 @@
1
+ export { DEFAULT_GLOBAL_RATE_LIMIT } from "../rate-limit/middleware.ts";
1
2
  export type { SetTenantCookieOptions } from "./anonymous-cookie.ts";
2
3
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.ts";
3
4
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.ts";
package/dist/api/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { DEFAULT_GLOBAL_RATE_LIMIT } from "../rate-limit/middleware.js";
1
2
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
2
3
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
3
4
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";