@cosmicdrift/kumiko-framework 0.378.0 → 0.380.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 (116) 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/embedded.md +23 -37
  5. package/agents/examples/event-sourcing.md +6 -5
  6. package/agents/examples/legal-pages.md +5 -5
  7. package/agents/examples/reference-data.md +26 -21
  8. package/agents/examples/relations.md +2 -1
  9. package/agents/features.md +4 -4
  10. package/dist/api/entity-read-access.d.ts +2 -0
  11. package/dist/api/entity-read-access.js +27 -0
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/server.js +10 -3
  15. package/dist/api/sse-route.d.ts +5 -0
  16. package/dist/api/sse-route.js +59 -9
  17. package/dist/db/apply-entity-event.d.ts +3 -2
  18. package/dist/db/apply-entity-event.js +1 -1
  19. package/dist/db/dialect.d.ts +2 -1
  20. package/dist/db/dialect.js +2 -2
  21. package/dist/db/entity-table-from-registry.js +0 -2
  22. package/dist/db/entity-table-meta.d.ts +5 -3
  23. package/dist/db/entity-table-meta.js +6 -15
  24. package/dist/db/event-store-executor-write.js +21 -16
  25. package/dist/db/executor-managed-tables.d.ts +8 -0
  26. package/dist/db/executor-managed-tables.js +48 -0
  27. package/dist/db/index.d.ts +6 -4
  28. package/dist/db/index.js +4 -2
  29. package/dist/db/money.d.ts +1 -1
  30. package/dist/db/queries/backfill-pii.js +22 -12
  31. package/dist/db/queries/database-now.d.ts +5 -0
  32. package/dist/db/queries/database-now.js +12 -0
  33. package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
  34. package/dist/db/queries/event-store-orphan-rows.js +32 -0
  35. package/dist/db/query.d.ts +22 -11
  36. package/dist/db/query.js +41 -36
  37. package/dist/db/reference-data.d.ts +5 -4
  38. package/dist/db/reference-data.js +23 -21
  39. package/dist/db/table-builder.d.ts +2 -3
  40. package/dist/db/table-builder.js +2 -0
  41. package/dist/db/table-introspection.d.ts +2 -0
  42. package/dist/db/table-introspection.js +4 -0
  43. package/dist/db/tenant-db-runner.d.ts +2 -0
  44. package/dist/db/tenant-db-runner.js +7 -0
  45. package/dist/db/tenant-db.d.ts +9 -2
  46. package/dist/db/tenant-db.js +82 -25
  47. package/dist/engine/boot-validator/entity-handler.js +9 -4
  48. package/dist/engine/boot-validator/query-refs.js +8 -0
  49. package/dist/engine/boot-validator/screens.js +34 -9
  50. package/dist/engine/extensions/tenant-data.d.ts +2 -2
  51. package/dist/engine/feature-ui-extensions.js +4 -0
  52. package/dist/engine/projection-helpers.d.ts +3 -3
  53. package/dist/engine/registry-state.js +2 -0
  54. package/dist/engine/registry-validate.js +4 -1
  55. package/dist/engine/schema-builder.js +18 -4
  56. package/dist/engine/screen-helpers.d.ts +1 -0
  57. package/dist/engine/screen-helpers.js +3 -0
  58. package/dist/engine/soft-delete-cleanup.js +53 -24
  59. package/dist/engine/steps/unsafe-projection-delete.js +4 -2
  60. package/dist/entrypoint/index.d.ts +1 -3
  61. package/dist/errors/reasons.d.ts +1 -0
  62. package/dist/errors/reasons.js +4 -1
  63. package/dist/errors/serialize.d.ts +1 -0
  64. package/dist/errors/serialize.js +9 -5
  65. package/dist/errors/write-error-info.js +3 -2
  66. package/dist/errors/zod-bridge.js +7 -0
  67. package/dist/es-ops/operations-schema.d.ts +1 -1
  68. package/dist/event-store/archive.d.ts +1 -1
  69. package/dist/event-store/events-schema.d.ts +1 -1
  70. package/dist/event-store/index.d.ts +1 -0
  71. package/dist/event-store/index.js +1 -0
  72. package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
  73. package/dist/event-store/snapshot.d.ts +1 -1
  74. package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
  75. package/dist/files/action-form-upload.d.ts +16 -0
  76. package/dist/files/action-form-upload.js +45 -0
  77. package/dist/files/bind-pending-file-refs.d.ts +14 -0
  78. package/dist/files/bind-pending-file-refs.js +44 -4
  79. package/dist/files/create-file-ref.d.ts +3 -0
  80. package/dist/files/create-file-ref.js +41 -13
  81. package/dist/files/file-routes.js +29 -2
  82. package/dist/files/index.d.ts +2 -1
  83. package/dist/files/index.js +1 -0
  84. package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
  85. package/dist/files/purge-expired-file-ref-bytes.js +11 -5
  86. package/dist/files/storage-tracking.d.ts +1 -1
  87. package/dist/files/upload-ledger.d.ts +2 -2
  88. package/dist/internal/testing.d.ts +7 -0
  89. package/dist/internal/testing.js +14 -1
  90. package/dist/migrations/pending-rebuilds.d.ts +1 -1
  91. package/dist/migrations/pii-event-backfill.d.ts +1 -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/event-consumer-state.d.ts +1 -1
  98. package/dist/pipeline/msp-rebuild.js +5 -3
  99. package/dist/pipeline/projection-rebuild.js +4 -2
  100. package/dist/pipeline/projection-state.d.ts +1 -1
  101. package/dist/pipeline/projections-runner.js +2 -1
  102. package/dist/rate-limit/index.d.ts +1 -1
  103. package/dist/rate-limit/index.js +1 -1
  104. package/dist/rate-limit/middleware.d.ts +4 -0
  105. package/dist/rate-limit/middleware.js +5 -2
  106. package/dist/ui-types/index.d.ts +2 -2
  107. package/dist/ui-types/index.js +2 -2
  108. package/dist/ui-types/list-row-meta.d.ts +3 -0
  109. package/dist/ui-types/list-row-meta.js +12 -1
  110. package/kumiko-es-tables.json +6 -0
  111. package/package.json +4 -3
  112. package/src/changes.json +117 -0
  113. package/src/scripts/codemod/README.md +20 -0
  114. package/src/scripts/codemod/codemod-support.ts +1 -1
  115. package/src/scripts/codemod/feature-layout.ts +416 -0
  116. 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"]) {
@@ -29,11 +29,11 @@ object and an embedded list rendered by a declarative `entityEdit` screen.
29
29
 
30
30
  - **Derived head totals** (`src/screens/invoice-receive.screen.ts`): an
31
31
  `actionForm` declares `derive` so net, VAT, and gross follow the lines
32
- (`listSum`), or follow a typed net and VAT rate while the list is empty
33
- (`whenEmpty`, `percent`). The renderer shows them read-only and live; the
34
- handler wraps its schema with `withFormDerivations(schema, screen)`
35
- (`src/handlers/invoice-receive.write.ts`), so totals sent by the client are
36
- replaced on the server.
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
37
 
38
38
  ## When to reach for it
39
39
 
@@ -439,35 +439,25 @@ describe("invoice receive form derives its totals on the server", () => {
439
439
  });
440
440
  });
441
441
 
442
- test("without lines, the typed net stays and VAT and gross are computed from the rate", async () => {
443
- const totals = await stack.http.writeOk<ReceiveTotals>(
442
+ test("an invoice without lines is rejected, the totals have nothing to derive from", async () => {
443
+ const error = await stack.http.writeErr(
444
444
  "contacts:write:invoice:receive",
445
- { ...head, lines: [], netTotal: eur(100), vatRate: 19, vatTotal: eur(0), grossTotal: eur(0) },
445
+ { ...head, lines: [], netTotal: eur(100), vatTotal: eur(0), grossTotal: eur(0) },
446
446
  admin,
447
447
  );
448
- expect(totals).toEqual({ netTotal: eur(100), vatTotal: eur(19), grossTotal: eur(119) });
448
+ expect(error.code).toBe("validation_error");
449
449
  });
450
450
 
451
- test("a stale gross total is overwritten", async () => {
452
- const totals = await stack.http.writeOk<ReceiveTotals>(
451
+ test("a caller without the Admin role is rejected", async () => {
452
+ const error = await stack.http.writeErr(
453
453
  "contacts:write:invoice:receive",
454
454
  {
455
455
  ...head,
456
- lines: [],
457
- netTotal: eur(50),
458
- vatRate: 20,
456
+ lines: [{ description: "Paper", amount: 1005, vatRate: 19 }],
457
+ netTotal: eur(0),
459
458
  vatTotal: eur(0),
460
- grossTotal: eur(999),
459
+ grossTotal: eur(0),
461
460
  },
462
- admin,
463
- );
464
- expect(totals.grossTotal).toEqual(eur(60));
465
- });
466
-
467
- test("a caller without the Admin role is rejected", async () => {
468
- const error = await stack.http.writeErr(
469
- "contacts:write:invoice:receive",
470
- { ...head, lines: [], netTotal: eur(50), vatRate: 20, vatTotal: eur(0), grossTotal: eur(0) },
471
461
  viewer,
472
462
  );
473
463
  expect(error.code).toBe("access_denied");
@@ -752,7 +742,6 @@ const receiveInput = withFormDerivations(
752
742
  invoiceNumber: z.string().min(1),
753
743
  lines: fieldToZod(invoiceReceiveLines, ["EUR"]),
754
744
  netTotal: moneyPayload,
755
- vatRate: z.number().optional(),
756
745
  vatTotal: moneyPayload,
757
746
  grossTotal: moneyPayload,
758
747
  }),
@@ -779,7 +768,6 @@ export const invoiceReceive = defineWriteHandler({
779
768
  ```ts
780
769
  import {
781
770
  type ActionFormScreenDefinition,
782
- createDecimalField,
783
771
  createEmbeddedListField,
784
772
  createTextField,
785
773
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -794,13 +782,18 @@ export const invoiceReceiveLines = createEmbeddedListField(
794
782
  vatAmount: { type: "money" },
795
783
  },
796
784
  {
785
+ minItems: 1,
797
786
  derived: { vatAmount: { op: "percent", from: ["amount", "vatRate"] } },
798
- totals: ["amount", "vatAmount"],
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
+ ],
799
792
  },
800
793
  );
801
794
 
802
- // Net/VAT/gross are derived: with line items they are the sum of the lines,
803
- // without lines the net amount and the VAT rate are typed and VAT/gross follow.
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.
804
797
  export const invoiceReceiveScreen: ActionFormScreenDefinition = {
805
798
  id: "invoice-receive",
806
799
  type: "actionForm",
@@ -814,25 +807,18 @@ export const invoiceReceiveScreen: ActionFormScreenDefinition = {
814
807
  }),
815
808
  lines: invoiceReceiveLines,
816
809
  netTotal: { type: "money", currency: euro },
817
- vatRate: createDecimalField({ precision: 5, scale: 2 }),
818
810
  vatTotal: { type: "money", currency: euro },
819
811
  grossTotal: { type: "money", currency: euro },
820
812
  },
821
813
  derive: {
822
814
  netTotal: { op: "listSum", list: "lines", cell: "amount" },
823
- vatTotal: {
824
- op: "listSum",
825
- list: "lines",
826
- cell: "vatAmount",
827
- whenEmpty: { op: "percent", from: ["netTotal", "vatRate"] },
828
- },
815
+ vatTotal: { op: "listSum", list: "lines", cell: "vatAmount" },
829
816
  grossTotal: { op: "sum", from: ["netTotal", "vatTotal"] },
830
817
  },
831
818
  layout: {
832
819
  sections: [
833
820
  { columns: 2, fields: ["supplier", "invoiceNumber"] },
834
821
  { columns: 1, fields: ["lines"] },
835
- { columns: 2, fields: ["netTotal", "vatRate", "vatTotal", "grossTotal"] },
836
822
  ],
837
823
  },
838
824
  };
@@ -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
 
@@ -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. 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. Reading and delivery do not depend on it: a tenant override that exists is 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`.
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
 
@@ -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";
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { ROLES } from "../auth/roles.js";
3
- import { createProjectingTenantDb } from "../db/tenant-db.js";
3
+ import { createProjectingTenantDb, withExecutorManagedWrites } from "../db/tenant-db.js";
4
4
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
5
5
  import { buildAppSchema } from "../engine/build-app-schema.js";
6
6
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
@@ -35,6 +35,7 @@ import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.
35
35
  import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
36
36
  import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
37
37
  import { csrfMiddleware } from "./csrf-middleware.js";
38
+ import { collectEntityReadAccess } from "./entity-read-access.js";
38
39
  import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
39
40
  import { computeStrongEtag, etagMatches } from "./http-cache.js";
40
41
  import { createJwtHelper } from "./jwt.js";
@@ -392,9 +393,10 @@ export function buildServer(options) {
392
393
  const mspFiles = fileProviderResolver
393
394
  ? createTenantFileContext(fileProviderResolver, event.tenantId)
394
395
  : undefined;
396
+ const mspDb = withExecutorManagedWrites(baseDb);
395
397
  const applyCtx = createMultiStreamApplyContext({
396
398
  registry: options.registry,
397
- db: baseDb,
399
+ db: mspDb,
398
400
  tenantId: event.tenantId,
399
401
  userId: event.metadata.userId,
400
402
  ...(mspOwner && { callerFeature: mspOwner }),
@@ -410,7 +412,7 @@ export function buildServer(options) {
410
412
  }),
411
413
  }),
412
414
  });
413
- await applyFn(event, baseDb, applyCtx);
415
+ await applyFn(event, mspDb, applyCtx);
414
416
  // Keep ctx reachable to satisfy the EventConsumerHandler signature.
415
417
  void ctx;
416
418
  },
@@ -480,6 +482,10 @@ export function buildServer(options) {
480
482
  if (options.metrics) {
481
483
  registerMetricsRoute(app, observability.meter, options.metrics);
482
484
  }
485
+ else if (process.env["NODE_ENV"] === "production") {
486
+ console.warn("[kumiko:boot] /metrics is off: no `metrics` option, so nothing is measured or scraped. " +
487
+ "Set PROMETHEUS_METRICS_TOKEN (min 32 chars) and spread resolveObservabilityWiring(token) into the server options.");
488
+ }
483
489
  app.use("/api/*", requestIdMiddleware({
484
490
  resolver: clientIpResolver,
485
491
  ...(options.errorDocs && { errorDocs: options.errorDocs }),
@@ -662,6 +668,7 @@ export function buildServer(options) {
662
668
  }));
663
669
  app.route("/api", createSseRoute(sseBroker, {
664
670
  anonymousLiveEntities: collectAnonymousLiveEntities(options.registry),
671
+ entityReadAccess: collectEntityReadAccess(options.registry),
665
672
  }));
666
673
  // GET /api/schema — the only way a client obtains the AppSchema. Behind
667
674
  // jwtGuard (NON_PUBLIC_API_PATHS), so anonymousAccess's synthesised user
@@ -1,4 +1,5 @@
1
1
  import { Hono } from "hono";
2
+ import type { AccessRule } from "../engine/types/index.ts";
2
3
  import { type SseBroker } from "./sse-broker.ts";
3
4
  /**
4
5
  * Heartbeat-Cadence für SSE-Streams.
@@ -21,7 +22,11 @@ import { type SseBroker } from "./sse-broker.ts";
21
22
  * gegen versehentliches Hochsetzen.
22
23
  */
23
24
  export declare const SSE_HEARTBEAT_INTERVAL_MS = 15000;
25
+ export declare const SSE_STALLED_CLIENT_TIMEOUT_MS = 15000;
26
+ export declare const SSE_MAX_PENDING_WRITES_PER_CLIENT = 10000;
24
27
  export type SseRouteOptions = {
25
28
  readonly anonymousLiveEntities: ReadonlySet<string>;
29
+ readonly entityReadAccess: ReadonlyMap<string, readonly AccessRule[]>;
30
+ readonly now?: () => number;
26
31
  };
27
32
  export declare function createSseRoute(broker: SseBroker, options: SseRouteOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;