@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.
Files changed (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. 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("&lt;script&gt;");
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
+ ```