@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.
- package/AGENTS.md +4 -1
- package/agents/examples/compose-stacks.md +1 -2
- package/agents/examples/currencies-global.md +2 -3
- package/agents/examples/embedded.md +23 -37
- package/agents/examples/event-sourcing.md +6 -5
- package/agents/examples/legal-pages.md +5 -5
- package/agents/examples/reference-data.md +26 -21
- package/agents/examples/relations.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +27 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/server.js +10 -3
- package/dist/api/sse-route.d.ts +5 -0
- package/dist/api/sse-route.js +59 -9
- package/dist/db/apply-entity-event.d.ts +3 -2
- package/dist/db/apply-entity-event.js +1 -1
- package/dist/db/dialect.d.ts +2 -1
- package/dist/db/dialect.js +2 -2
- package/dist/db/entity-table-from-registry.js +0 -2
- package/dist/db/entity-table-meta.d.ts +5 -3
- package/dist/db/entity-table-meta.js +6 -15
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +8 -0
- package/dist/db/executor-managed-tables.js +48 -0
- package/dist/db/index.d.ts +6 -4
- package/dist/db/index.js +4 -2
- package/dist/db/money.d.ts +1 -1
- package/dist/db/queries/backfill-pii.js +22 -12
- package/dist/db/queries/database-now.d.ts +5 -0
- package/dist/db/queries/database-now.js +12 -0
- package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
- package/dist/db/queries/event-store-orphan-rows.js +32 -0
- package/dist/db/query.d.ts +22 -11
- package/dist/db/query.js +41 -36
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.d.ts +2 -3
- package/dist/db/table-builder.js +2 -0
- package/dist/db/table-introspection.d.ts +2 -0
- package/dist/db/table-introspection.js +4 -0
- package/dist/db/tenant-db-runner.d.ts +2 -0
- package/dist/db/tenant-db-runner.js +7 -0
- package/dist/db/tenant-db.d.ts +9 -2
- package/dist/db/tenant-db.js +82 -25
- package/dist/engine/boot-validator/entity-handler.js +9 -4
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +34 -9
- package/dist/engine/extensions/tenant-data.d.ts +2 -2
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/projection-helpers.d.ts +3 -3
- package/dist/engine/registry-state.js +2 -0
- package/dist/engine/registry-validate.js +4 -1
- package/dist/engine/schema-builder.js +18 -4
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +3 -0
- package/dist/engine/soft-delete-cleanup.js +53 -24
- package/dist/engine/steps/unsafe-projection-delete.js +4 -2
- package/dist/entrypoint/index.d.ts +1 -3
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +4 -1
- package/dist/errors/serialize.d.ts +1 -0
- package/dist/errors/serialize.js +9 -5
- package/dist/errors/write-error-info.js +3 -2
- package/dist/errors/zod-bridge.js +7 -0
- package/dist/es-ops/operations-schema.d.ts +1 -1
- package/dist/event-store/archive.d.ts +1 -1
- package/dist/event-store/events-schema.d.ts +1 -1
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
- package/dist/event-store/snapshot.d.ts +1 -1
- package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
- package/dist/files/action-form-upload.d.ts +16 -0
- package/dist/files/action-form-upload.js +45 -0
- package/dist/files/bind-pending-file-refs.d.ts +14 -0
- package/dist/files/bind-pending-file-refs.js +44 -4
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +41 -13
- package/dist/files/file-routes.js +29 -2
- package/dist/files/index.d.ts +2 -1
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
- package/dist/files/purge-expired-file-ref-bytes.js +11 -5
- package/dist/files/storage-tracking.d.ts +1 -1
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/internal/testing.d.ts +7 -0
- package/dist/internal/testing.js +14 -1
- package/dist/migrations/pending-rebuilds.d.ts +1 -1
- package/dist/migrations/pii-event-backfill.d.ts +1 -1
- package/dist/migrations/pii-event-backfill.js +14 -7
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +14 -3
- package/dist/pipeline/cascade-handler.js +79 -10
- package/dist/pipeline/dispatch-shared.js +1 -9
- package/dist/pipeline/event-consumer-state.d.ts +1 -1
- package/dist/pipeline/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- package/dist/pipeline/projection-state.d.ts +1 -1
- package/dist/pipeline/projections-runner.js +2 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/middleware.d.ts +4 -0
- package/dist/rate-limit/middleware.js +5 -2
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +2 -2
- package/dist/ui-types/list-row-meta.d.ts +3 -0
- package/dist/ui-types/list-row-meta.js +12 -1
- package/kumiko-es-tables.json +6 -0
- package/package.json +4 -3
- package/src/changes.json +117 -0
- package/src/scripts/codemod/README.md +20 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +416 -0
- 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`)
|
|
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
|
|
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
|
-
|
|
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`)
|
|
33
|
-
|
|
34
|
-
handler wraps its schema with
|
|
35
|
-
(`src/handlers/invoice-receive.write.ts`),
|
|
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
|
|
443
|
-
const
|
|
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),
|
|
445
|
+
{ ...head, lines: [], netTotal: eur(100), vatTotal: eur(0), grossTotal: eur(0) },
|
|
446
446
|
admin,
|
|
447
447
|
);
|
|
448
|
-
expect(
|
|
448
|
+
expect(error.code).toBe("validation_error");
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
-
test("a
|
|
452
|
-
const
|
|
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(
|
|
458
|
-
vatRate: 20,
|
|
456
|
+
lines: [{ description: "Paper", amount: 1005, vatRate: 19 }],
|
|
457
|
+
netTotal: eur(0),
|
|
459
458
|
vatTotal: eur(0),
|
|
460
|
-
grossTotal: eur(
|
|
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: [
|
|
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
|
|
803
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
44
|
-
import {
|
|
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
|
|
51
|
+
let stack: TestStack;
|
|
47
52
|
|
|
48
53
|
beforeAll(async () => {
|
|
49
|
-
|
|
50
|
-
await
|
|
54
|
+
stack = await setupTestStack({ features: [categoryFeature] });
|
|
55
|
+
await unsafeCreateEntityTable(stack.db, categoryEntity, "category");
|
|
51
56
|
});
|
|
52
57
|
|
|
53
58
|
afterAll(async () => {
|
|
54
|
-
await
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
//
|
|
130
|
-
export const categoryTable =
|
|
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
|
package/agents/features.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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,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
|
+
}
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/api/server.js
CHANGED
|
@@ -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:
|
|
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,
|
|
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
|
package/dist/api/sse-route.d.ts
CHANGED
|
@@ -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, "/">;
|