@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/managed-pages. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# managed-pages
|
|
4
|
+
|
|
5
|
+
Tenant-editable, server-rendered public pages with per-tenant branding + tier-gated sanitized custom CSS — managed-pages wired up with config + anonymousAccess.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Recipe: managed-pages
|
|
10
|
+
|
|
11
|
+
Tenant-editable, server-rendered public pages with per-tenant branding and
|
|
12
|
+
opt-in, tier-gated **custom CSS** — the framework generalization of the
|
|
13
|
+
`legal-pages`/`wrapLayout` render pattern, hardened for untrusted tenant input.
|
|
14
|
+
|
|
15
|
+
**What the recipe demonstrates:**
|
|
16
|
+
- Compose `managed-pages` (+ its `config` dependency and the `managed-pages-css`
|
|
17
|
+
companion toggle) in `runProdApp({ features: [...] })`
|
|
18
|
+
- Required wirings: `anonymousAccess` + `extraContext` config plumbing
|
|
19
|
+
- Single-tenant `resolveApexTenant` (multi-tenant resolves from the Host)
|
|
20
|
+
- The render boundary: published-only, Markdown raw-HTML escaped, per-tenant
|
|
21
|
+
branding applied as scoped `:root` vars, custom CSS allowlist-sanitized and
|
|
22
|
+
contained in a `<style data-tenant-css>` block
|
|
23
|
+
- 6 integration tests proving end-to-end behavior
|
|
24
|
+
|
|
25
|
+
## Layout
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
samples/recipes/managed-pages/
|
|
29
|
+
├── package.json # workspace deps
|
|
30
|
+
├── README.md # this file
|
|
31
|
+
└── src/
|
|
32
|
+
├── feature.ts # the composed features re-exported for tests
|
|
33
|
+
└── __tests__/
|
|
34
|
+
└── feature.integration.test.ts # 6 tests: render + branding + CSS
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Run tests
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# From the repo root:
|
|
41
|
+
bun kumiko test all samples/recipes/managed-pages/
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
When the tests are green:
|
|
45
|
+
- A published page is reachable via `stack.app.request("/p/about")`; a draft is `404`
|
|
46
|
+
- Per-tenant content is cache-isolated (`Vary: Host`)
|
|
47
|
+
- Raw HTML in a Markdown body is escaped (no `<script>` execution)
|
|
48
|
+
- Branding writes (`config:write:set`) roundtrip into the rendered HTML
|
|
49
|
+
- Attack CSS (`position:fixed`, `@import`, `url()`) is neutralized at render
|
|
50
|
+
|
|
51
|
+
## Integration into a real app
|
|
52
|
+
|
|
53
|
+
### 1. Compose the features
|
|
54
|
+
|
|
55
|
+
```typescript illustration
|
|
56
|
+
// bin/main.ts
|
|
57
|
+
import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";
|
|
58
|
+
import {
|
|
59
|
+
createManagedPagesFeature,
|
|
60
|
+
createManagedPagesCssFeature,
|
|
61
|
+
} from "@cosmicdrift/kumiko-bundled-features/managed-pages";
|
|
62
|
+
|
|
63
|
+
await runProdApp({
|
|
64
|
+
features: [
|
|
65
|
+
createManagedPagesFeature({
|
|
66
|
+
// Host → tenantId. Single-tenant returns a constant; multi-tenant maps
|
|
67
|
+
// the subdomain / custom domain. NULL → 404 (no tenant for this host).
|
|
68
|
+
resolveApexTenant: (host) => resolveTenantForHost(host),
|
|
69
|
+
// Opt-in custom CSS (default false, fail-closed). The render-time
|
|
70
|
+
// sanitizer is the safety boundary; gate per tier with the companion.
|
|
71
|
+
allowCustomCss: true,
|
|
72
|
+
}),
|
|
73
|
+
createManagedPagesCssFeature(),
|
|
74
|
+
/* ... your other features */
|
|
75
|
+
],
|
|
76
|
+
// routes run anonymous; multi-tenant honors the per-page X-Tenant header
|
|
77
|
+
anonymousAccess: { tenantExists: async (id) => /* validate */ true },
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`config` is auto-mounted by `runProdApp`. In `setupTestStack` it is **not**, so
|
|
82
|
+
the recipe lists `createConfigFeature()` explicitly (see `src/feature.ts`).
|
|
83
|
+
|
|
84
|
+
> **anonymousAccess:** a fixed `defaultTenantId` locks single-tenant and rejects
|
|
85
|
+
> the per-page `X-Tenant` with `400 tenant_mismatch` unless it equals the
|
|
86
|
+
> default. Multi-tenant apps wire `tenantExists` (or a `tenantResolver`) and let
|
|
87
|
+
> the route's `X-Tenant` win.
|
|
88
|
+
|
|
89
|
+
### 2. Create the table
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
bun kumiko-schema generate <name> # detects the `page` entity → SQL migration
|
|
93
|
+
bun kumiko-schema apply
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 3. Authoring + branding
|
|
97
|
+
|
|
98
|
+
- Pages: TenantAdmin/SystemAdmin author via the `entityList`/`entityEdit`
|
|
99
|
+
screens (`managed-pages:screen:page-list`); wire nav/workspace onto them.
|
|
100
|
+
- Branding: the `configEdit` screen (`managed-pages:screen:branding-settings`)
|
|
101
|
+
or `config:write:set` against the `BRANDING_QN.*` keys
|
|
102
|
+
(`managed-pages:config:branding-{title,accent-color,...,custom-css}`).
|
|
103
|
+
|
|
104
|
+
### 4. Per-tenant CSS gating
|
|
105
|
+
|
|
106
|
+
`createManagedPagesCssFeature()` is `r.toggleable({ default: false })`. To gate
|
|
107
|
+
custom CSS per tier, wire `feature-toggles`/`tier-engine` so the
|
|
108
|
+
`managed-pages-css` toggle is on only for entitled tenants. **Without a toggle
|
|
109
|
+
runtime, `ctx.hasFeature` fails open** — the capability stays on. The fail-closed
|
|
110
|
+
anchor is `allowCustomCss` (app-level opt-in); the toggle is the commercial gate.
|
|
111
|
+
|
|
112
|
+
## Custom wrapLayout (your own marketing chrome)
|
|
113
|
+
|
|
114
|
+
A custom `wrapLayout` receives `branding` RAW and untrusted: `title`/
|
|
115
|
+
`description` are length-capped at write but **not** HTML-escaped, and
|
|
116
|
+
`customCss` is unsanitized. Emit every tenant value through the exported
|
|
117
|
+
boundary helpers — never interpolate `branding.title` yourself (stored XSS):
|
|
118
|
+
|
|
119
|
+
```typescript illustration
|
|
120
|
+
import {
|
|
121
|
+
brandingHeaderHtml,
|
|
122
|
+
brandingStyleBlock,
|
|
123
|
+
tenantStyleBlock,
|
|
124
|
+
TENANT_CONTENT_ATTR,
|
|
125
|
+
} from "@cosmicdrift/kumiko-bundled-features/managed-pages";
|
|
126
|
+
|
|
127
|
+
const wrapLayout = (o) => `<!doctype html>
|
|
128
|
+
<html lang="${o.lang}">
|
|
129
|
+
<head>
|
|
130
|
+
<title>${o.title}</title>
|
|
131
|
+
${/* your layout CSS */ ""}
|
|
132
|
+
${brandingStyleBlock(o.branding)} <!-- escaped :root theme vars -->
|
|
133
|
+
${tenantStyleBlock(o.branding.customCss)} <!-- scoped + sanitized + contained -->
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
${brandingHeaderHtml(o.branding)} <!-- escaped logo + title header -->
|
|
137
|
+
<main ${TENANT_CONTENT_ATTR}>${o.bodyHtml}</main>
|
|
138
|
+
</body>
|
|
139
|
+
</html>`;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`brandingHeaderHtml`/`brandingStyleBlock` escape + re-validate (hex/https) the
|
|
143
|
+
branding tokens; `tenantStyleBlock` bakes in the scope selector so callers can't
|
|
144
|
+
mis-scope and lose containment. The default skeleton (`wrapInLayout`) uses the
|
|
145
|
+
exact same helpers.
|
|
146
|
+
|
|
147
|
+
## Security posture
|
|
148
|
+
|
|
149
|
+
| Threat | Mitigation |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `<script>`/raw HTML in a page body | `renderSafeMarkdown` escapes raw HTML (Markdown-only) |
|
|
152
|
+
| Cross-tenant content on a shared CDN | `Vary: Host` + per-tenant `resolveApexTenant` |
|
|
153
|
+
| Draft leak to anonymous visitors | published-only; drafts → 404 |
|
|
154
|
+
| `@import`/`url()`/`expression()`/`</style>` in custom CSS | allowlist-by-construction sanitizer (rebuilt from validated tokens) |
|
|
155
|
+
| Scope-escape / overlay onto host chrome | scope-prefix + `position`/`isolation`/`overflow` containment |
|
|
156
|
+
| Untrusted tenant abusing CSS | tier-gate (`managed-pages-css`) + `allowCustomCss` opt-in |
|
|
157
|
+
|
|
158
|
+
Custom-CSS sanitization for untrusted tenants is **best-effort defense-in-depth**:
|
|
159
|
+
a tenant can still restyle its **own** page area within the clip. Keep it
|
|
160
|
+
tier-gated.
|
|
161
|
+
|
|
162
|
+
## Cross-refs
|
|
163
|
+
|
|
164
|
+
- [packages/bundled-features/src/managed-pages](../../../packages/bundled-features/src/managed-pages) — the feature
|
|
165
|
+
- [packages/bundled-features/src/page-render/css-sanitize.ts](../../../packages/bundled-features/src/page-render/css-sanitize.ts) — the allowlist sanitizer
|
|
166
|
+
- [samples/recipes/legal-pages](../legal-pages) — the simpler, static sibling
|
|
167
|
+
|
|
168
|
+
## Source files
|
|
169
|
+
|
|
170
|
+
### src/__tests__/feature.integration.test.ts
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
174
|
+
import {
|
|
175
|
+
configValuesTable,
|
|
176
|
+
createConfigAccessorFactory,
|
|
177
|
+
createConfigResolver,
|
|
178
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
179
|
+
import { BRANDING_QN, pageEntity } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
|
|
180
|
+
import { seedPage } from "@cosmicdrift/kumiko-bundled-features/managed-pages/seeding";
|
|
181
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
182
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
183
|
+
import {
|
|
184
|
+
createTestUser,
|
|
185
|
+
setupTestStack,
|
|
186
|
+
type TestStack,
|
|
187
|
+
unsafeCreateEntityTable,
|
|
188
|
+
unsafePushTables,
|
|
189
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
190
|
+
import { configFeature, managedPagesCssFeature, managedPagesFeature } from "../feature";
|
|
191
|
+
|
|
192
|
+
// Branding writes are tenant-scoped — the admin's tenantId must equal the
|
|
193
|
+
// rendered tenant (SYSTEM_TENANT_ID here), or the config row lands in the wrong
|
|
194
|
+
// bucket and the render reads the default.
|
|
195
|
+
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"], tenantId: SYSTEM_TENANT_ID });
|
|
196
|
+
|
|
197
|
+
let stack: TestStack;
|
|
198
|
+
|
|
199
|
+
const render = async (path: string): Promise<string> => {
|
|
200
|
+
const res = await stack.app.request(`http://acme.example.com${path}`);
|
|
201
|
+
return res.text();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
beforeAll(async () => {
|
|
205
|
+
const resolver = createConfigResolver();
|
|
206
|
+
stack = await setupTestStack({
|
|
207
|
+
features: [configFeature, managedPagesFeature, managedPagesCssFeature],
|
|
208
|
+
// Single-tenant: resolveApexTenant returns SYSTEM_TENANT_ID, so the route's
|
|
209
|
+
// X-Tenant header matches this defaultTenantId — no tenant_mismatch.
|
|
210
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
211
|
+
// Wire ctx.config() so the branding query resolves the tenant's cascade.
|
|
212
|
+
extraContext: ({ registry }) => ({
|
|
213
|
+
configResolver: resolver,
|
|
214
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
await unsafeCreateEntityTable(stack.db, pageEntity);
|
|
218
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
219
|
+
await createEventsTable(stack.db);
|
|
220
|
+
|
|
221
|
+
await seedPage(stack.db, {
|
|
222
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
223
|
+
slug: "about",
|
|
224
|
+
lang: "en",
|
|
225
|
+
title: "About Acme",
|
|
226
|
+
body: "# Hello from **Acme**",
|
|
227
|
+
published: true,
|
|
228
|
+
});
|
|
229
|
+
await seedPage(stack.db, {
|
|
230
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
231
|
+
slug: "draft",
|
|
232
|
+
lang: "en",
|
|
233
|
+
title: "Draft",
|
|
234
|
+
body: "not live yet",
|
|
235
|
+
published: false,
|
|
236
|
+
});
|
|
237
|
+
await seedPage(stack.db, {
|
|
238
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
239
|
+
slug: "xss",
|
|
240
|
+
lang: "en",
|
|
241
|
+
title: "XSS probe",
|
|
242
|
+
body: "intro\n\n<script>alert('pwned')</script>\n\nfin",
|
|
243
|
+
published: true,
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
afterAll(async () => {
|
|
248
|
+
await stack.cleanup();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("recipe :: managed-pages render", () => {
|
|
252
|
+
test("published page → 200 with rendered Markdown + Vary: Host", async () => {
|
|
253
|
+
const res = await stack.app.request("http://acme.example.com/p/about");
|
|
254
|
+
expect(res.status).toBe(200);
|
|
255
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
256
|
+
expect(res.headers.get("vary")).toBe("Host");
|
|
257
|
+
const html = await res.text();
|
|
258
|
+
expect(html).toContain("About Acme");
|
|
259
|
+
expect(html).toContain("<strong>Acme</strong>");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("unpublished draft → 404 for anonymous visitors", async () => {
|
|
263
|
+
const res = await stack.app.request("http://acme.example.com/p/draft");
|
|
264
|
+
expect(res.status).toBe(404);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("raw HTML in a Markdown body is escaped, never executed", async () => {
|
|
268
|
+
const html = await render("/p/xss");
|
|
269
|
+
expect(html).not.toContain("<script>alert");
|
|
270
|
+
expect(html).toContain("<script>");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("recipe :: branding (config self-service)", () => {
|
|
275
|
+
test("accent color + title roundtrip into the render", async () => {
|
|
276
|
+
await stack.http.writeOk(
|
|
277
|
+
"config:write:set",
|
|
278
|
+
{ key: BRANDING_QN.accentColor, value: "#ff0066" },
|
|
279
|
+
admin,
|
|
280
|
+
);
|
|
281
|
+
await stack.http.writeOk(
|
|
282
|
+
"config:write:set",
|
|
283
|
+
{ key: BRANDING_QN.title, value: "Acme Inc" },
|
|
284
|
+
admin,
|
|
285
|
+
);
|
|
286
|
+
const html = await render("/p/about");
|
|
287
|
+
expect(html).toContain("#ff0066");
|
|
288
|
+
expect(html).toContain("Acme Inc");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("recipe :: custom CSS (allowCustomCss, sanitized)", () => {
|
|
293
|
+
test("safe rule renders scoped + contained in <style data-tenant-css>", async () => {
|
|
294
|
+
await stack.http.writeOk(
|
|
295
|
+
"config:write:set",
|
|
296
|
+
{ key: BRANDING_QN.customCss, value: ".note { color: rebeccapurple; }" },
|
|
297
|
+
admin,
|
|
298
|
+
);
|
|
299
|
+
const html = await render("/p/about");
|
|
300
|
+
expect(html).toContain("<style data-tenant-css>");
|
|
301
|
+
expect(html).toContain("[data-tenant-content] .note");
|
|
302
|
+
expect(html).toContain("color: rebeccapurple");
|
|
303
|
+
expect(html).toContain("<main data-tenant-content>");
|
|
304
|
+
expect(html).toContain("[data-tenant-content]{overflow:hidden}");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("attack CSS is neutralized at render; the one safe rule survives scoped", async () => {
|
|
308
|
+
const attack =
|
|
309
|
+
".evil { position: fixed; top: 0; } .ok { color: red; } @import url('http://evil.test');";
|
|
310
|
+
await stack.http.writeOk(
|
|
311
|
+
"config:write:set",
|
|
312
|
+
{ key: BRANDING_QN.customCss, value: attack },
|
|
313
|
+
admin,
|
|
314
|
+
);
|
|
315
|
+
const html = await render("/p/about");
|
|
316
|
+
expect(html).not.toContain("position: fixed");
|
|
317
|
+
expect(html).not.toContain("@import");
|
|
318
|
+
expect(html).not.toContain("evil.test");
|
|
319
|
+
expect(html).toContain("[data-tenant-content] .ok");
|
|
320
|
+
expect(html).toContain("color: red");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### src/feature.ts
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
329
|
+
import {
|
|
330
|
+
createManagedPagesCssFeature,
|
|
331
|
+
createManagedPagesFeature,
|
|
332
|
+
} from "@cosmicdrift/kumiko-bundled-features/managed-pages";
|
|
333
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
334
|
+
|
|
335
|
+
// managed-pages declares `r.requires("config")` for its branding keys — the
|
|
336
|
+
// config feature must be in the stack (runProdApp auto-mounts it; setupTestStack
|
|
337
|
+
// does not, so the recipe lists it explicitly).
|
|
338
|
+
export const configFeature = createConfigFeature();
|
|
339
|
+
|
|
340
|
+
// Single-tenant apex: every request maps to the one system tenant. A multi-
|
|
341
|
+
// tenant app resolves the tenant from the request Host (subdomain / custom
|
|
342
|
+
// domain) and returns the matching tenantId instead — see README.
|
|
343
|
+
export const managedPagesFeature = createManagedPagesFeature({
|
|
344
|
+
resolveApexTenant: () => SYSTEM_TENANT_ID,
|
|
345
|
+
allowCustomCss: true,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// Companion per-tenant toggle for the CSS-inject capability. Compose it and wire
|
|
349
|
+
// feature-toggles to gate custom CSS per tier; without a toggle runtime,
|
|
350
|
+
// `ctx.hasFeature` fails open and CSS stays on (the render-time sanitizer is the
|
|
351
|
+
// safety boundary regardless).
|
|
352
|
+
export const managedPagesCssFeature = createManagedPagesCssFeature();
|
|
353
|
+
```
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/notes-history-basic. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# notes-history-basic
|
|
4
|
+
|
|
5
|
+
Append-only note history — who wrote what, when — attached to any entity without changing it.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Notes History
|
|
10
|
+
|
|
11
|
+
Attach an append-only note history to **any** entity — without adding a
|
|
12
|
+
column to it, writing a migration, or touching its feature. The recipe
|
|
13
|
+
creates a plain `task` entity that knows nothing about notes, then notes
|
|
14
|
+
tasks through the `notes-history` bundle alone.
|
|
15
|
+
|
|
16
|
+
The result: a task can carry any number of notes, each with an author and
|
|
17
|
+
timestamp, in the order they were written — and the `task` row stays exactly
|
|
18
|
+
`{ id, title }`.
|
|
19
|
+
|
|
20
|
+
## What it shows
|
|
21
|
+
|
|
22
|
+
- **Zero host changes** — `task` has no notes column, no notes-specific
|
|
23
|
+
wiring, no awareness of notes at all. Note-taking works anyway, because
|
|
24
|
+
the notes-history feature owns its own table.
|
|
25
|
+
- **`notes-history:write:add-note`** — appends a `note-entry`
|
|
26
|
+
(`read_note_entries`) keyed by `{ entityType, entityId, body }`. The author
|
|
27
|
+
is never client-supplied: the write always attributes to the authenticated
|
|
28
|
+
caller, and `insertedAt` (a framework base column) is the timestamp.
|
|
29
|
+
- **Strictly append-only** — there is no update or delete handler. A
|
|
30
|
+
correction is a new entry, not an edit, so who wrote what and when stays
|
|
31
|
+
reconstructable — the entire reason this bundle exists (a single
|
|
32
|
+
overwritable textarea can't do that).
|
|
33
|
+
- **Read-layer composition, no JOIN** — "this task's note history" lists
|
|
34
|
+
`note-entry` filtered by `entityId`, sorted by `insertedAt`. The app reads
|
|
35
|
+
and sorts; there is no relational pivot.
|
|
36
|
+
|
|
37
|
+
## Using it — the notes flow
|
|
38
|
+
|
|
39
|
+
You use the bundle by dispatching its handlers; nothing is wired into the
|
|
40
|
+
noted entity. A host needs just two calls — `write` and `query` — which any
|
|
41
|
+
app dispatcher provides. The flow below is embedded from `usage.ts` and is
|
|
42
|
+
run end-to-end against the real dispatcher + DB by this recipe's integration
|
|
43
|
+
test (`the documented notesFlow appends two notes, newest first`):
|
|
44
|
+
|
|
45
|
+
```ts file=<rootDir>/_samples/recipes-notes-history-basic/src/usage.ts
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Web UI — the drop-in `<NotesSection>`
|
|
49
|
+
|
|
50
|
+
You don't have to hand-build a notes UI. The feature ships one from its
|
|
51
|
+
client subpath `@cosmicdrift/kumiko-bundled-features/notes-history/web`:
|
|
52
|
+
`<NotesSection>` takes an `entityName` + `entityId`, shows that entity's note
|
|
53
|
+
history newest-first, and lets the user append a new one — calling the same
|
|
54
|
+
handler as above. Register `notesHistoryClient()` once (for its component +
|
|
55
|
+
i18n), then mount it either way:
|
|
56
|
+
|
|
57
|
+
```tsx illustration
|
|
58
|
+
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer-web";
|
|
59
|
+
import { notesHistoryClient, NotesSection, NOTES_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/notes-history/web";
|
|
60
|
+
|
|
61
|
+
// once, at app boot — required even for standalone use (registers i18n):
|
|
62
|
+
createKumikoApp({ clientFeatures: [notesHistoryClient()] });
|
|
63
|
+
|
|
64
|
+
// standalone — drop it into any screen, no entityEdit screen needed:
|
|
65
|
+
<NotesSection entityName="task" entityId={taskId} />
|
|
66
|
+
|
|
67
|
+
// or as an extension section in an entityEdit screen schema:
|
|
68
|
+
{ kind: "extension", title: "Notes", component: { react: { __component: NOTES_SECTION_EXTENSION_NAME } } }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The component itself is `notes-history/web/notes-section.tsx`
|
|
72
|
+
([source](https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/packages/bundled-features/src/notes-history/web/notes-section.tsx)).
|
|
73
|
+
|
|
74
|
+
## Feature composition
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
notes-history → core bundle: note-entry entity, add-note handler, list query
|
|
78
|
+
task-management → our feature: a plain `task` entity. Declares
|
|
79
|
+
r.requires("notes-history") only so the bundle is
|
|
80
|
+
mounted — the task itself is completely notes-agnostic.
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Why it's event-sourced, not a pivot table
|
|
84
|
+
|
|
85
|
+
Kumiko is event-sourced: there are no relational pivots queried by JOIN. The
|
|
86
|
+
`note-entry` entity is a feature-owned, event-sourced row keyed by
|
|
87
|
+
`(entityType, entityId)`, and the framework projects it into
|
|
88
|
+
`read_note_entries` from its own create events. Unlike a join-table pattern
|
|
89
|
+
with a deterministic id (see the `tags` bundle), there is no dedup key here —
|
|
90
|
+
an entity legitimately carries many notes, so every `add-note` is an ordinary
|
|
91
|
+
random-id stream. Cross-entity reads (a task's note history) are assembled by
|
|
92
|
+
reading that projection and filtering in the app — never by joining across
|
|
93
|
+
aggregates.
|
|
94
|
+
|
|
95
|
+
## Run
|
|
96
|
+
|
|
97
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Source files
|
|
104
|
+
|
|
105
|
+
### src/__tests__/feature.integration.test.ts
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// Notes History Basic — integration test.
|
|
109
|
+
//
|
|
110
|
+
// Proves the host-agnostic note-history flow via the real dispatcher + DB:
|
|
111
|
+
// 1. a plain `task` is created (the task feature knows nothing about notes)
|
|
112
|
+
// 2. two notes are appended by (entityType, entityId)
|
|
113
|
+
// 3. "this task's note history" reads from note-entry — read-layer
|
|
114
|
+
// composition, no JOIN, no column on `task`
|
|
115
|
+
//
|
|
116
|
+
// This is the smallest evidence that an entity needs zero notes-wiring.
|
|
117
|
+
|
|
118
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
119
|
+
import {
|
|
120
|
+
noteEntryEntity,
|
|
121
|
+
noteMentionEntity,
|
|
122
|
+
notesHistoryFeature,
|
|
123
|
+
} from "@cosmicdrift/kumiko-bundled-features/notes-history";
|
|
124
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
125
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
126
|
+
import {
|
|
127
|
+
createTestUser,
|
|
128
|
+
setupTestStack,
|
|
129
|
+
type TestStack,
|
|
130
|
+
unsafeCreateEntityTable,
|
|
131
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
132
|
+
import { taskEntity, taskFeature } from "../feature";
|
|
133
|
+
import { type NotesClient, notesFlow } from "../usage";
|
|
134
|
+
|
|
135
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
136
|
+
|
|
137
|
+
// Adapt the test stack to the host-facing NotesClient the docs embed uses.
|
|
138
|
+
const notesClient: NotesClient = {
|
|
139
|
+
write: <T>(type: string, payload: unknown) => stack.http.writeOk<T>(type, payload, admin),
|
|
140
|
+
query: <T>(type: string, payload: unknown) => stack.http.queryOk<T>(type, payload, admin),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
let stack: TestStack;
|
|
144
|
+
|
|
145
|
+
beforeAll(async () => {
|
|
146
|
+
stack = await setupTestStack({ features: [notesHistoryFeature, taskFeature] });
|
|
147
|
+
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
148
|
+
await unsafeCreateEntityTable(stack.db, noteMentionEntity);
|
|
149
|
+
await unsafeCreateEntityTable(stack.db, taskEntity);
|
|
150
|
+
await createEventsTable(stack.db);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterAll(async () => {
|
|
154
|
+
await stack.cleanup();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
beforeEach(async () => {
|
|
158
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
159
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_sample_notes_history_tasks");
|
|
160
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_note_entries");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
async function createTask(id: string, title: string) {
|
|
164
|
+
return stack.http.writeOk("task-management:write:task:create", { id, title }, admin);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
describe("notes-history-basic recipe — append + compose", () => {
|
|
168
|
+
// Exercises the exact code embedded on the docs page (usage.ts → notesFlow).
|
|
169
|
+
test("the documented notesFlow appends two notes, newest first", async () => {
|
|
170
|
+
const taskId = "55555555-5555-4000-8000-000000000005";
|
|
171
|
+
await createTask(taskId, "Documented flow");
|
|
172
|
+
|
|
173
|
+
const result = await notesFlow(notesClient, taskId);
|
|
174
|
+
|
|
175
|
+
expect(result.entries).toHaveLength(2);
|
|
176
|
+
expect(result.entries[0]?.body).toBe("Client confirmed the scope.");
|
|
177
|
+
expect(result.entries[1]?.body).toBe("Kick-off call scheduled for Monday.");
|
|
178
|
+
expect(result.entries[0]?.authorId).toBe(admin.id);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a task carries its note history without any notes-column on the task", async () => {
|
|
182
|
+
const taskId = "11111111-1111-4000-8000-000000000001";
|
|
183
|
+
await createTask(taskId, "Quarterly review");
|
|
184
|
+
|
|
185
|
+
await notesClient.write("notes-history:write:add-note", {
|
|
186
|
+
entityType: "task",
|
|
187
|
+
entityId: taskId,
|
|
188
|
+
body: "Reviewed budget.",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const tasks = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
192
|
+
"task-management:query:task:list",
|
|
193
|
+
{},
|
|
194
|
+
admin,
|
|
195
|
+
);
|
|
196
|
+
const task = tasks.rows.find((r) => r["id"] === taskId);
|
|
197
|
+
expect(task?.["title"]).toBe("Quarterly review");
|
|
198
|
+
expect(task).not.toHaveProperty("body");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("notes are scoped per entity — a second task starts with an empty history", async () => {
|
|
202
|
+
const taskA = "22222222-2222-4000-8000-000000000002";
|
|
203
|
+
const taskB = "33333333-3333-4000-8000-000000000003";
|
|
204
|
+
await createTask(taskA, "Task A");
|
|
205
|
+
await createTask(taskB, "Task B");
|
|
206
|
+
await notesClient.write("notes-history:write:add-note", {
|
|
207
|
+
entityType: "task",
|
|
208
|
+
entityId: taskA,
|
|
209
|
+
body: "Only on A",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const ofB = await notesClient.query<{ rows: unknown[] }>(
|
|
213
|
+
"notes-history:query:note-entry:list",
|
|
214
|
+
{
|
|
215
|
+
filter: { field: "entityId", op: "eq", value: taskB },
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
expect(ofB.rows).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### src/feature.ts
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// kumiko-feature-version: 1
|
|
227
|
+
// Notes History Basic Sample
|
|
228
|
+
//
|
|
229
|
+
// Shows the whole point of the `notes-history` bundle: attaching a note
|
|
230
|
+
// history to an entity needs ZERO changes to that entity. The `task` entity
|
|
231
|
+
// below has no notes column, no note-specific wiring, no awareness of notes
|
|
232
|
+
// at all — yet tasks can carry a chronological, authored note history,
|
|
233
|
+
// because the notes-history feature owns its own table (read_note_entries)
|
|
234
|
+
// and keys entries by (entityType, entityId).
|
|
235
|
+
//
|
|
236
|
+
// Flow (see the integration test):
|
|
237
|
+
// 1. App-author defines a plain `task` entity — nothing note-specific.
|
|
238
|
+
// 2. A user appends a note via `notes-history:write:add-note` with
|
|
239
|
+
// { entityType: "task", entityId: <taskId>, body }.
|
|
240
|
+
// 3. "What notes does this task have?" is a read-layer composition: list
|
|
241
|
+
// `note-entry` filtered by entityId — no JOIN, no column on `task`.
|
|
242
|
+
// 4. The author is never client-supplied — the write-handler always
|
|
243
|
+
// attributes the note to the authenticated caller.
|
|
244
|
+
|
|
245
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
246
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
247
|
+
import { z } from "zod";
|
|
248
|
+
|
|
249
|
+
// --- Entity ---
|
|
250
|
+
//
|
|
251
|
+
// A plain entity. Note there is NOTHING here that mentions notes — that is
|
|
252
|
+
// the feature's promise: any entity can carry a note history as-is.
|
|
253
|
+
|
|
254
|
+
export const taskEntity = createEntity({
|
|
255
|
+
table: "read_sample_notes_history_tasks",
|
|
256
|
+
fields: {
|
|
257
|
+
title: createTextField({
|
|
258
|
+
personal: false,
|
|
259
|
+
reason: "is_business_data",
|
|
260
|
+
required: true,
|
|
261
|
+
maxLength: 200,
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const taskTable = buildEntityTable("task", taskEntity);
|
|
267
|
+
|
|
268
|
+
const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
|
|
269
|
+
|
|
270
|
+
// --- Feature ---
|
|
271
|
+
|
|
272
|
+
export const taskFeature = defineFeature("task-management", (r) => {
|
|
273
|
+
// notes-history is non-optional for this recipe: the demo notes tasks.
|
|
274
|
+
// The task feature itself stays completely notes-agnostic — it only
|
|
275
|
+
// declares the dependency so the bundle is mounted.
|
|
276
|
+
r.requires("notes-history");
|
|
277
|
+
|
|
278
|
+
r.entity("task", taskEntity);
|
|
279
|
+
|
|
280
|
+
r.writeHandler({
|
|
281
|
+
name: "task:create",
|
|
282
|
+
schema: z.object({ id: z.string(), title: z.string() }),
|
|
283
|
+
access: { roles: ["TenantAdmin"] },
|
|
284
|
+
handler: async (event, ctx) =>
|
|
285
|
+
taskExecutor.create({ id: event.payload.id, title: event.payload.title }, event.user, ctx.db),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
r.queryHandler({
|
|
289
|
+
name: "task:list",
|
|
290
|
+
schema: z.object({}),
|
|
291
|
+
access: { roles: ["TenantAdmin"] },
|
|
292
|
+
handler: async (_query, ctx) => {
|
|
293
|
+
const rows = await ctx.db.selectMany(taskTable);
|
|
294
|
+
return { rows };
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### src/usage.ts
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
// Notes History Basic — using the bundle.
|
|
304
|
+
//
|
|
305
|
+
// The notes-history feature is driven entirely by dispatching its handlers;
|
|
306
|
+
// nothing is wired into the noted entity. A host needs exactly two calls —
|
|
307
|
+
// write and query — which any app dispatcher provides. This recipe's
|
|
308
|
+
// integration test runs `notesFlow` below against the real dispatcher + DB.
|
|
309
|
+
|
|
310
|
+
// The minimal surface the notes flow needs from a host dispatcher. An app's
|
|
311
|
+
// client satisfies this; the integration test adapts the test stack to it.
|
|
312
|
+
export type NotesClient = {
|
|
313
|
+
write: <T>(type: string, payload: unknown) => Promise<T>;
|
|
314
|
+
query: <T>(type: string, payload: unknown) => Promise<T>;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Append two notes to a task, then read its history back.
|
|
318
|
+
export async function notesFlow(client: NotesClient, taskId: string) {
|
|
319
|
+
// 1. Append a note to ANY entity by (type, id) — no column on that entity.
|
|
320
|
+
// The author is never passed in: the server always attributes the note
|
|
321
|
+
// to the authenticated caller.
|
|
322
|
+
await client.write("notes-history:write:add-note", {
|
|
323
|
+
entityType: "task",
|
|
324
|
+
entityId: taskId,
|
|
325
|
+
body: "Kick-off call scheduled for Monday.",
|
|
326
|
+
});
|
|
327
|
+
await client.write("notes-history:write:add-note", {
|
|
328
|
+
entityType: "task",
|
|
329
|
+
entityId: taskId,
|
|
330
|
+
body: "Client confirmed the scope.",
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// 2. "What notes does this task have?" — filter note-entry by entityId,
|
|
334
|
+
// newest first.
|
|
335
|
+
const history = await client.query<{
|
|
336
|
+
rows: Array<{ body: string; authorId: string; insertedAt: string }>;
|
|
337
|
+
}>("notes-history:query:note-entry:list", {
|
|
338
|
+
filter: { field: "entityId", op: "eq", value: taskId },
|
|
339
|
+
sort: "insertedAt",
|
|
340
|
+
sortDirection: "desc",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return { entries: history.rows };
|
|
344
|
+
}
|
|
345
|
+
```
|