@cosmicdrift/kumiko-framework 0.377.0 → 0.379.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/agents/examples/compose-stacks.md +1 -2
- package/agents/examples/currencies-global.md +2 -3
- package/agents/examples/delivery-notifications.md +25 -2
- package/agents/examples/embedded.md +177 -0
- 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 +5 -5
- package/agents/widgets.md +1 -0
- 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/entity-table-from-registry.d.ts +2 -1
- package/dist/db/entity-table-meta.d.ts +0 -2
- package/dist/db/entity-table-meta.js +1 -3
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +6 -0
- package/dist/db/executor-managed-tables.js +40 -0
- package/dist/db/index.d.ts +4 -2
- package/dist/db/index.js +4 -2
- package/dist/db/money.d.ts +3 -2
- package/dist/db/money.js +2 -7
- 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.js +28 -0
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.js +2 -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 +3 -0
- package/dist/db/tenant-db.js +54 -18
- package/dist/engine/boot-validator/entity-handler.js +12 -4
- package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
- package/dist/engine/boot-validator/form-derivations.js +82 -0
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +50 -12
- package/dist/engine/build-config-feature-schema.js +33 -10
- package/dist/engine/embedded-derived.d.ts +1 -1
- package/dist/engine/embedded-derived.js +7 -1
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/form-derivations.d.ts +21 -0
- package/dist/engine/form-derivations.js +175 -0
- package/dist/engine/index.d.ts +4 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/minor-units.d.ts +2 -0
- package/dist/engine/minor-units.js +8 -0
- 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 +2 -1
- package/dist/engine/template-variables.d.ts +2 -1
- package/dist/engine/template-variables.js +4 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/with-form-derivations.d.ts +9 -0
- package/dist/engine/with-form-derivations.js +16 -0
- 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/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- 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/i18n/settings-hub-keys.js +1 -0
- package/dist/internal/testing.d.ts +2 -0
- package/dist/internal/testing.js +15 -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/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- 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/search/configure-tenant-index.d.ts +4 -0
- package/dist/search/configure-tenant-index.js +8 -0
- package/dist/search/index.d.ts +1 -0
- package/dist/search/index.js +1 -0
- package/dist/ui-types/index.d.ts +4 -2
- package/dist/ui-types/index.js +3 -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 +142 -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"]) {
|
|
@@ -16,7 +16,7 @@ Shows how a feature sends notifications to multiple channels (InApp + Email + Pu
|
|
|
16
16
|
|
|
17
17
|
**Mail text is stored data:** The boot seed (`seedNotificationTemplates`) writes the declared `template` and `meta` into the template store as system templates. The email channel renders them with `renderer-mail-html`. A notification routed to email without `template` and `meta` fails its delivery.
|
|
18
18
|
|
|
19
|
-
**Tenant override:**
|
|
19
|
+
**Tenant override:** Only a SystemAdmin edits templates unless the app unlocks them. The app lists the mail in `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug, access: { roles: ["TenantAdmin"] } }] })`; then a tenant admin overrides the text through the template-resolver handlers (`upsertTenant`, then `publish`) and every other template is refused with 403. The next mail for that tenant uses the override; the system text stays for everyone else.
|
|
20
20
|
|
|
21
21
|
**Sending:** The write handler calls `ctx.notify(type, { to, data })`. The `data` keys are the template variables; sending fails loudly when a required variable is missing.
|
|
22
22
|
|
|
@@ -137,7 +137,9 @@ const assignee = createTestUser({ id: 5, roles: ["Support"] });
|
|
|
137
137
|
const features = [
|
|
138
138
|
createConfigFeature(),
|
|
139
139
|
createTenantFeature(),
|
|
140
|
-
createTemplateResolverFeature(
|
|
140
|
+
createTemplateResolverFeature({
|
|
141
|
+
tenantEditable: [{ kind: "mail", slug: TICKET_ASSIGNED, access: { roles: ["TenantAdmin"] } }],
|
|
142
|
+
}),
|
|
141
143
|
createRendererFoundationFeature(),
|
|
142
144
|
createRendererMailHtmlFeature(),
|
|
143
145
|
createDeliveryFeature(),
|
|
@@ -275,6 +277,27 @@ describe("delivery-notifications sample", () => {
|
|
|
275
277
|
expect(email?.html).toContain("Tenant text: Printer on fire");
|
|
276
278
|
});
|
|
277
279
|
|
|
280
|
+
test("a tenant admin may not override a mail the app did not list in tenantEditable", async () => {
|
|
281
|
+
const tenantAdmin = { ...admin, roles: ["TenantAdmin"] };
|
|
282
|
+
|
|
283
|
+
const error = await stack.http.writeErr(
|
|
284
|
+
TemplateResolverHandlers.upsertTenant,
|
|
285
|
+
{
|
|
286
|
+
slug: "support:notify:ticket-closed",
|
|
287
|
+
kind: "mail",
|
|
288
|
+
locale: "en",
|
|
289
|
+
content: "<p>Closed</p>",
|
|
290
|
+
contentFormat: "html",
|
|
291
|
+
meta: { subject: "Closed" },
|
|
292
|
+
variableSchema: {},
|
|
293
|
+
},
|
|
294
|
+
tenantAdmin,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(error.code).toBe("access_denied");
|
|
298
|
+
expect(error.i18nKey).toBe("templateResolver.errors.templateNotTenantEditable");
|
|
299
|
+
});
|
|
300
|
+
|
|
278
301
|
test("ticket without assignee skips notification (recipient returns null)", async () => {
|
|
279
302
|
emailTransport.sent.length = 0;
|
|
280
303
|
pushTransport.sent.length = 0;
|
|
@@ -27,6 +27,14 @@ object and an embedded list rendered by a declarative `entityEdit` screen.
|
|
|
27
27
|
`createEmbeddedListField`; its renderer receives `embeddedListCells`, so no
|
|
28
28
|
custom screen component is required.
|
|
29
29
|
|
|
30
|
+
- **Derived head totals** (`src/screens/invoice-receive.screen.ts`): an
|
|
31
|
+
`actionForm` declares `derive` so net, VAT, and gross follow the lines
|
|
32
|
+
(`listSum`); the list requires at least one line (`minItems: 1`), so there
|
|
33
|
+
is no lineless input to cover. The head totals are not laid out; the lines footer shows Net, VAT, and Gross through labelled `totals`
|
|
34
|
+
entries (`{ label, from }`). The handler wraps its schema with
|
|
35
|
+
`withFormDerivations(schema, screen)` (`src/handlers/invoice-receive.write.ts`),
|
|
36
|
+
so totals sent by the client are replaced on the server.
|
|
37
|
+
|
|
30
38
|
## When to reach for it
|
|
31
39
|
|
|
32
40
|
Use an embedded field or list when the nested data is created, changed, and
|
|
@@ -397,6 +405,64 @@ describe("invoice line items with select/reference/derived/totals metadata", ()
|
|
|
397
405
|
expect(error).toBeDefined();
|
|
398
406
|
});
|
|
399
407
|
});
|
|
408
|
+
|
|
409
|
+
// --- actionForm derive ---
|
|
410
|
+
|
|
411
|
+
describe("invoice receive form derives its totals on the server", () => {
|
|
412
|
+
type ReceiveTotals = {
|
|
413
|
+
netTotal: { amount: number; currency: string };
|
|
414
|
+
vatTotal: { amount: number; currency: string };
|
|
415
|
+
grossTotal: { amount: number; currency: string };
|
|
416
|
+
};
|
|
417
|
+
const eur = (amount: number) => ({ amount, currency: "EUR" });
|
|
418
|
+
const head = { supplier: "Papier AG", invoiceNumber: "RE-2026-0042" };
|
|
419
|
+
|
|
420
|
+
test("with lines, manipulated client totals are replaced by the line sums", async () => {
|
|
421
|
+
const totals = await stack.http.writeOk<ReceiveTotals>(
|
|
422
|
+
"contacts:write:invoice:receive",
|
|
423
|
+
{
|
|
424
|
+
...head,
|
|
425
|
+
lines: [
|
|
426
|
+
{ description: "Paper", amount: 1005, vatRate: 19, vatAmount: 5 },
|
|
427
|
+
{ description: "Toner", amount: 2000, vatRate: 7 },
|
|
428
|
+
],
|
|
429
|
+
netTotal: eur(1),
|
|
430
|
+
vatTotal: eur(2),
|
|
431
|
+
grossTotal: eur(3),
|
|
432
|
+
},
|
|
433
|
+
admin,
|
|
434
|
+
);
|
|
435
|
+
expect(totals).toEqual({
|
|
436
|
+
netTotal: eur(30.05),
|
|
437
|
+
vatTotal: eur(3.31),
|
|
438
|
+
grossTotal: eur(33.36),
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("an invoice without lines is rejected, the totals have nothing to derive from", async () => {
|
|
443
|
+
const error = await stack.http.writeErr(
|
|
444
|
+
"contacts:write:invoice:receive",
|
|
445
|
+
{ ...head, lines: [], netTotal: eur(100), vatTotal: eur(0), grossTotal: eur(0) },
|
|
446
|
+
admin,
|
|
447
|
+
);
|
|
448
|
+
expect(error.code).toBe("validation_error");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("a caller without the Admin role is rejected", async () => {
|
|
452
|
+
const error = await stack.http.writeErr(
|
|
453
|
+
"contacts:write:invoice:receive",
|
|
454
|
+
{
|
|
455
|
+
...head,
|
|
456
|
+
lines: [{ description: "Paper", amount: 1005, vatRate: 19 }],
|
|
457
|
+
netTotal: eur(0),
|
|
458
|
+
vatTotal: eur(0),
|
|
459
|
+
grossTotal: eur(0),
|
|
460
|
+
},
|
|
461
|
+
viewer,
|
|
462
|
+
);
|
|
463
|
+
expect(error.code).toBe("access_denied");
|
|
464
|
+
});
|
|
465
|
+
});
|
|
400
466
|
```
|
|
401
467
|
|
|
402
468
|
### src/entities/contact.ts
|
|
@@ -529,6 +595,9 @@ export const productTable = buildEntityTable("product", productEntity);
|
|
|
529
595
|
// on its `lines` sub-fields (Issue #1835), plus a declarative
|
|
530
596
|
// entityEdit screen that renders the list end-to-end via
|
|
531
597
|
// EmbeddedListField, no custom-screen code required
|
|
598
|
+
// invoice-receive — actionForm whose net/VAT/gross head fields are derived
|
|
599
|
+
// from the lines (`derive`); the handler recomputes them with
|
|
600
|
+
// `withFormDerivations`, so client-sent totals are never trusted
|
|
532
601
|
|
|
533
602
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
534
603
|
import { contactEntity } from "./entities/contact";
|
|
@@ -536,6 +605,8 @@ import { invoiceEntity } from "./entities/invoice";
|
|
|
536
605
|
import { productEntity } from "./entities/product";
|
|
537
606
|
import { contactCreate } from "./handlers/contact-create.write";
|
|
538
607
|
import { contactDetail } from "./handlers/contact-detail.query";
|
|
608
|
+
import { invoiceReceive } from "./handlers/invoice-receive.write";
|
|
609
|
+
import { invoiceReceiveScreen } from "./screens/invoice-receive.screen";
|
|
539
610
|
|
|
540
611
|
export { contactEntity } from "./entities/contact";
|
|
541
612
|
export { invoiceEntity } from "./entities/invoice";
|
|
@@ -557,6 +628,7 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
|
|
|
557
628
|
|
|
558
629
|
r.writeHandler(contactCreate);
|
|
559
630
|
r.queryHandler(contactDetail);
|
|
631
|
+
r.writeHandler(invoiceReceive);
|
|
560
632
|
|
|
561
633
|
r.crud("product", productEntity, { write: adminWrite, read: openRead });
|
|
562
634
|
r.crud("invoice", invoiceEntity, { write: adminWrite, read: openRead });
|
|
@@ -569,6 +641,8 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
|
|
|
569
641
|
sections: [{ columns: 1, fields: ["customer", "lines"] }],
|
|
570
642
|
},
|
|
571
643
|
});
|
|
644
|
+
|
|
645
|
+
r.screen(invoiceReceiveScreen);
|
|
572
646
|
});
|
|
573
647
|
```
|
|
574
648
|
|
|
@@ -646,3 +720,106 @@ export const contactDetail = defineQueryHandler({
|
|
|
646
720
|
},
|
|
647
721
|
});
|
|
648
722
|
```
|
|
723
|
+
|
|
724
|
+
### src/handlers/invoice-receive.write.ts
|
|
725
|
+
|
|
726
|
+
```ts
|
|
727
|
+
import {
|
|
728
|
+
defineWriteHandler,
|
|
729
|
+
fieldToZod,
|
|
730
|
+
withFormDerivations,
|
|
731
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
732
|
+
import { z } from "zod";
|
|
733
|
+
import { invoiceReceiveLines, invoiceReceiveScreen } from "../screens/invoice-receive.screen";
|
|
734
|
+
|
|
735
|
+
const moneyPayload = z.object({ amount: z.number(), currency: z.string().min(1) });
|
|
736
|
+
|
|
737
|
+
// The preprocess recomputes net/VAT/gross before validation: totals sent by the
|
|
738
|
+
// client never reach the handler.
|
|
739
|
+
const receiveInput = withFormDerivations(
|
|
740
|
+
z.object({
|
|
741
|
+
supplier: z.string().min(1),
|
|
742
|
+
invoiceNumber: z.string().min(1),
|
|
743
|
+
lines: fieldToZod(invoiceReceiveLines, ["EUR"]),
|
|
744
|
+
netTotal: moneyPayload,
|
|
745
|
+
vatTotal: moneyPayload,
|
|
746
|
+
grossTotal: moneyPayload,
|
|
747
|
+
}),
|
|
748
|
+
invoiceReceiveScreen,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
export const invoiceReceive = defineWriteHandler({
|
|
752
|
+
name: "invoice:receive",
|
|
753
|
+
schema: receiveInput,
|
|
754
|
+
access: { roles: ["Admin"] },
|
|
755
|
+
handler: async (event) => ({
|
|
756
|
+
isSuccess: true,
|
|
757
|
+
data: {
|
|
758
|
+
netTotal: event.payload.netTotal,
|
|
759
|
+
vatTotal: event.payload.vatTotal,
|
|
760
|
+
grossTotal: event.payload.grossTotal,
|
|
761
|
+
},
|
|
762
|
+
}),
|
|
763
|
+
});
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
### src/screens/invoice-receive.screen.ts
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
import {
|
|
770
|
+
type ActionFormScreenDefinition,
|
|
771
|
+
createEmbeddedListField,
|
|
772
|
+
createTextField,
|
|
773
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
774
|
+
|
|
775
|
+
const euro = { kind: "literal", code: "EUR" } as const;
|
|
776
|
+
|
|
777
|
+
export const invoiceReceiveLines = createEmbeddedListField(
|
|
778
|
+
{
|
|
779
|
+
description: { type: "text", required: true },
|
|
780
|
+
amount: { type: "money", required: true },
|
|
781
|
+
vatRate: { type: "decimal", scale: 2, required: true },
|
|
782
|
+
vatAmount: { type: "money" },
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
minItems: 1,
|
|
786
|
+
derived: { vatAmount: { op: "percent", from: ["amount", "vatRate"] } },
|
|
787
|
+
totals: [
|
|
788
|
+
{ label: "invoice.total.net", from: ["amount"] },
|
|
789
|
+
{ label: "invoice.total.vat", from: ["vatAmount"] },
|
|
790
|
+
{ label: "invoice.total.gross", from: ["amount", "vatAmount"] },
|
|
791
|
+
],
|
|
792
|
+
},
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
// Net/VAT/gross are derived on the server from the lines (at least one is required)
|
|
796
|
+
// and shown only in the list footer, so the head totals are declared but not laid out.
|
|
797
|
+
export const invoiceReceiveScreen: ActionFormScreenDefinition = {
|
|
798
|
+
id: "invoice-receive",
|
|
799
|
+
type: "actionForm",
|
|
800
|
+
handler: "contacts:write:invoice:receive",
|
|
801
|
+
fields: {
|
|
802
|
+
supplier: createTextField({ required: true, personal: "tenant", find: "none" }),
|
|
803
|
+
invoiceNumber: createTextField({
|
|
804
|
+
required: true,
|
|
805
|
+
personal: false,
|
|
806
|
+
reason: "is_business_data",
|
|
807
|
+
}),
|
|
808
|
+
lines: invoiceReceiveLines,
|
|
809
|
+
netTotal: { type: "money", currency: euro },
|
|
810
|
+
vatTotal: { type: "money", currency: euro },
|
|
811
|
+
grossTotal: { type: "money", currency: euro },
|
|
812
|
+
},
|
|
813
|
+
derive: {
|
|
814
|
+
netTotal: { op: "listSum", list: "lines", cell: "amount" },
|
|
815
|
+
vatTotal: { op: "listSum", list: "lines", cell: "vatAmount" },
|
|
816
|
+
grossTotal: { op: "sum", from: ["netTotal", "vatTotal"] },
|
|
817
|
+
},
|
|
818
|
+
layout: {
|
|
819
|
+
sections: [
|
|
820
|
+
{ columns: 2, fields: ["supplier", "invoiceNumber"] },
|
|
821
|
+
{ columns: 1, fields: ["lines"] },
|
|
822
|
+
],
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
```
|
|
@@ -97,7 +97,7 @@ Six integration tests pin everything at once:
|
|
|
97
97
|
|
|
98
98
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
99
99
|
import type { DbTx } from "@cosmicdrift/kumiko-framework/db";
|
|
100
|
-
import {
|
|
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
|
|
|
@@ -228,7 +228,7 @@ Requires: `file-derivatives`
|
|
|
228
228
|
|
|
229
229
|
## document-designer
|
|
230
230
|
|
|
231
|
-
Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`.
|
|
231
|
+
Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`. The tenant overview and the editors are for SystemAdmin only; the `access` option adds roles (normally `templateEditAccess(tenantEditable)` of the template-resolver mount), and the server still enforces per template which of them may save.
|
|
232
232
|
|
|
233
233
|
Category: content
|
|
234
234
|
|
|
@@ -564,7 +564,7 @@ Requires: `renderer-foundation`
|
|
|
564
564
|
|
|
565
565
|
## renderer-puppeteer-client
|
|
566
566
|
|
|
567
|
-
A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries
|
|
567
|
+
A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 502, 503 and network errors with exponential backoff (default 3 retries); 4xx, other 5xx and client timeouts fail at once, because the render has already run. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.
|
|
568
568
|
|
|
569
569
|
Requires: `renderer-foundation`
|
|
570
570
|
|
|
@@ -644,7 +644,7 @@ Category: data
|
|
|
644
644
|
|
|
645
645
|
## template-resolver
|
|
646
646
|
|
|
647
|
-
Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
|
|
647
|
+
Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`). Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away. Editing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug: "status-update", access: { roles: ["TenantAdmin"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. The same list decides delivery: a tenant override is served (mail, PDF, layouts, text blocks) only while its template is listed in tenantEditable; otherwise the system version is served. A locked override stays stored, a SystemAdmin sees it in the overview marked as not delivered, and listing the template makes it effective again. Tenant rows of a collection mounted with `access` are always served. A collection mounted without `access` is SystemAdmin only. Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation. How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning). A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side. `validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only. `preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address. Images used by a template (`<img data-resource="logo">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: "attachment"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10. Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.
|
|
648
648
|
|
|
649
649
|
Category: notifications
|
|
650
650
|
|
package/agents/widgets.md
CHANGED
|
@@ -55,6 +55,7 @@ Value exports of `@cosmicdrift/kumiko-renderer/web`.
|
|
|
55
55
|
| `NavTree` | |
|
|
56
56
|
| `NumberField` | Zahlenfeld = Field + Input(kind:"number") in einem — nimmt der Screen die wiederholte id/name/value/onChange-Verdrahtung ab. |
|
|
57
57
|
| `PageSection` | |
|
|
58
|
+
| `PdfPreview` | |
|
|
58
59
|
| `PercentField` | |
|
|
59
60
|
| `PhotoSlots` | Guided photo capture: one tile per requested shot in a two-column grid, each its own camera/gallery picker, filled tiles show the thumbnail. |
|
|
60
61
|
| `PlainContentEditor` | |
|
|
@@ -0,0 +1,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";
|