@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,275 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/tenant-settings. Do not edit. -->
2
+
3
+ # tenant-settings
4
+
5
+ Tenant-Settings: per-tenant Currency + Locale defaults, auto-filled into a money field / locale field on create instead of a hard-coded literal.
6
+
7
+ ## README
8
+
9
+ # Recipe: Tenant-Settings
10
+
11
+ **What this shows:** how a per-tenant Currency + Locale default fills a
12
+ money/locale field on create — without hard-coding `"EUR"`/`"de"` as a field
13
+ literal, the mistake that cost a sibling project (phronexsis) a retrofit once
14
+ a second tenant needed a different default.
15
+
16
+ ## Pattern
17
+
18
+ ```ts illustration
19
+ import {
20
+ createEntity,
21
+ createMoneyField,
22
+ createSelectField,
23
+ createTextField,
24
+ defineFeature,
25
+ } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
27
+
28
+ export const invoiceEntity = createEntity({
29
+ table: "read_invoices",
30
+ fields: {
31
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
32
+ amount: createMoneyField({ required: true }),
33
+ language: createSelectField({ options: ["en", "de", "fr"] as const }),
34
+ },
35
+ });
36
+
37
+ export const invoiceFeature = defineFeature("invoice", (r) => {
38
+ r.entity("invoice", invoiceEntity);
39
+ r.writeHandler(
40
+ defineCreateWithTenantDefaults("invoice", invoiceEntity, {
41
+ access: { roles: ["Admin"] },
42
+ currencyFields: ["amount"],
43
+ localeField: "language",
44
+ }),
45
+ );
46
+ // update/delete/list/detail stay the plain defineEntity*Handler factories —
47
+ // only create needs the tenant-default fill-in.
48
+ });
49
+ ```
50
+
51
+ Mount `createTenantSettingsFeature()` alongside — it provisions the two
52
+ config keys (`tenant-settings:config:currency`, `tenant-settings:config:locale`)
53
+ that `defineCreateWithTenantDefaults` reads:
54
+
55
+ ```ts illustration
56
+ import { createTenantSettingsFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
57
+
58
+ const features = [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature];
59
+ ```
60
+
61
+ `createTenantSettingsFeature(opts)` takes `currencies`, `defaultCurrency`,
62
+ `defaultLocale`, `write` — an app with its own supported-currency list or
63
+ role vocabulary overrides those instead of forking the feature.
64
+
65
+ ## Why the schema needs a special case
66
+
67
+ `buildInsertSchema` always makes `currency` required *inside* a money field's
68
+ value object, even when the field itself is optional — a caller literally
69
+ cannot submit `{ amount: 1000 }` against the plain `defineEntityCreateHandler`.
70
+ `defineCreateWithTenantDefaults` swaps in its own schema (`.extend()` over the
71
+ same `buildInsertSchema` output, with just the declared `currencyFields`
72
+ relaxed) so the caller *can* omit `currency`, then fills it from
73
+ `ctx.config(TenantSettingsConfig.currency)` before delegating to the same
74
+ `executor.create()` the generic factory uses.
75
+
76
+ ## Settings-Hub
77
+
78
+ Both keys declare `mask`, so they surface in the self-populating Settings-Hub
79
+ (Tenant-Audience) without a hand-written `r.screen`/`r.nav` — same mechanism
80
+ as [managed-config](/en/samples/recipes-managed-config/). A `TenantAdmin`
81
+ changes them from **Settings → Tenant → Default Currency / Default Locale**.
82
+
83
+ ## Flow
84
+
85
+ 1. No tenant override yet → a new invoice gets the *feature's own* default
86
+ (`"EUR"`/`"en"`, set via `createTenantSettingsFeature()`'s own defaults).
87
+ 2. Tenant sets `currency` to `"CHF"` and `locale` to `"de"` via the
88
+ Settings-Hub (or `config:write:set` directly).
89
+ 3. A new invoice that doesn't specify `amount.currency`/`language` picks up
90
+ `"CHF"`/`"de"` — no entity code changed, no second migration.
91
+ 4. A caller that *does* specify `amount.currency` still wins — the tenant
92
+ setting only fills gaps, it never overrides an explicit value.
93
+
94
+ ## Tests
95
+
96
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
97
+
98
+ ```bash
99
+ bun test src/__tests__/feature.integration.test.ts
100
+ ```
101
+
102
+ Covers the happy path above (tenant defaults fill gaps; explicit caller
103
+ values still win). Creating without a `tenant-settings` mount is not a
104
+ supported fallback — mount the feature (or the recipe's stack) first.
105
+
106
+ ## What's not in this recipe
107
+
108
+ - **Timezone, number-format, week-start** — deliberately out of scope; add
109
+ them as further config keys on the same feature when a real consumer needs
110
+ them, don't speculate ahead of a use case.
111
+ - **A dedicated locale-options select** — `locale` is a free-text ISO-639-1(-region)
112
+ field validated by regex, not a fixed enum, because the framework doesn't
113
+ own which languages an app supports. An app with a fixed language list
114
+ builds its own `createTenantConfig("select", {...})` key if it wants a
115
+ dropdown instead of `TenantSettingsConfig.locale`.
116
+
117
+ ## Related samples
118
+
119
+ - [managed-config](/en/samples/recipes-managed-config/) — the `mask` +
120
+ Settings-Hub mechanism these keys rely on, explained in depth.
121
+
122
+ ## Source files
123
+
124
+ ### src/__tests__/feature.integration.test.ts
125
+
126
+ ```ts
127
+ // End-to-end over real HTTP: a tenant's currency/locale setting flows into
128
+ // a new invoice's money/locale fields without the caller specifying them.
129
+
130
+ import { afterAll, beforeAll, expect, test } from "bun:test";
131
+ import {
132
+ configValuesTable,
133
+ createConfigAccessorFactory,
134
+ createConfigFeature,
135
+ createConfigResolver,
136
+ } from "@cosmicdrift/kumiko-bundled-features/config";
137
+ import { createTenantSettingsFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
138
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
139
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
140
+ import {
141
+ pushEntityProjectionTables,
142
+ setupTestStack,
143
+ type TestStack,
144
+ testTenantId,
145
+ unsafeCreateEntityTable,
146
+ unsafePushTables,
147
+ } from "@cosmicdrift/kumiko-framework/stack";
148
+ import { invoiceEntity, invoiceFeature } from "../feature";
149
+
150
+ const tenantId = testTenantId(1);
151
+ const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
152
+
153
+ let stack: TestStack;
154
+
155
+ beforeAll(async () => {
156
+ const resolver = createConfigResolver();
157
+ stack = await setupTestStack({
158
+ features: [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature],
159
+ extraContext: ({ registry }) => ({
160
+ configResolver: resolver,
161
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
162
+ }),
163
+ });
164
+ await unsafePushTables(stack.db, { configValuesTable });
165
+ await unsafeCreateEntityTable(stack.db, invoiceEntity);
166
+ await createEventsTable(stack.db);
167
+ await pushEntityProjectionTables(stack, stack.registry);
168
+ });
169
+
170
+ afterAll(async () => {
171
+ await stack.cleanup();
172
+ });
173
+
174
+ test("no tenant override yet — new invoice gets the feature's own default (EUR/en)", async () => {
175
+ const invoice = await stack.http.writeOk<{
176
+ data: {
177
+ amount: { amount: number; currency: string; amountScaled: number; amountMinor: number };
178
+ language: string;
179
+ };
180
+ }>("invoice:write:invoice:create", { title: "Invoice 1", amount: { amount: 1000 } }, admin);
181
+ expect(invoice.data.amount).toEqual({
182
+ amount: 1000,
183
+ currency: "EUR",
184
+ amountScaled: 100_000,
185
+ amountMinor: 100_000,
186
+ });
187
+ expect(invoice.data.language).toBe("en");
188
+ });
189
+
190
+ test("tenant sets CHF/de — a new invoice without explicit values picks up the tenant setting", async () => {
191
+ await stack.http.writeOk(
192
+ "config:write:set",
193
+ { key: "tenant-settings:config:currency", value: "CHF" },
194
+ admin,
195
+ );
196
+ await stack.http.writeOk(
197
+ "config:write:set",
198
+ { key: "tenant-settings:config:locale", value: "de" },
199
+ admin,
200
+ );
201
+
202
+ const invoice = await stack.http.writeOk<{
203
+ data: {
204
+ amount: { amount: number; currency: string; amountScaled: number; amountMinor: number };
205
+ language: string;
206
+ };
207
+ }>("invoice:write:invoice:create", { title: "Invoice 2", amount: { amount: 2000 } }, admin);
208
+
209
+ expect(invoice.data.amount).toEqual({
210
+ amount: 2000,
211
+ currency: "CHF",
212
+ amountScaled: 200_000,
213
+ amountMinor: 200_000,
214
+ });
215
+ expect(invoice.data.language).toBe("de");
216
+ });
217
+
218
+ test("an explicit value in the payload wins over the tenant setting", async () => {
219
+ const invoice = await stack.http.writeOk<{
220
+ data: {
221
+ amount: { amount: number; currency: string; amountScaled: number; amountMinor: number };
222
+ };
223
+ }>(
224
+ "invoice:write:invoice:create",
225
+ { title: "Invoice 3", amount: { amount: 3000, currency: "USD" } },
226
+ admin,
227
+ );
228
+ expect(invoice.data.amount).toEqual({
229
+ amount: 3000,
230
+ currency: "USD",
231
+ amountScaled: 300_000,
232
+ amountMinor: 300_000,
233
+ });
234
+ });
235
+ ```
236
+
237
+ ### src/feature.ts
238
+
239
+ ```ts
240
+ // Consuming tenant-settings: an `invoice` entity whose `amount` (money) and
241
+ // `language` (locale) are auto-filled from the tenant's currency/locale
242
+ // setting when the caller omits them — instead of hard-coding "EUR"/"en" on
243
+ // the field, the mistake this recipe exists to prevent (solon#P19).
244
+
245
+ import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
246
+ import {
247
+ createEntity,
248
+ createMoneyField,
249
+ createSelectField,
250
+ createTextField,
251
+ defineFeature,
252
+ } from "@cosmicdrift/kumiko-framework/engine";
253
+
254
+ export const invoiceEntity = createEntity({
255
+ table: "read_invoices",
256
+ fields: {
257
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
258
+ amount: createMoneyField({ required: true }),
259
+ language: createSelectField({ options: ["en", "de", "fr"] as const }),
260
+ },
261
+ });
262
+
263
+ const ACCESS = { roles: ["Admin"] } as const;
264
+
265
+ export const invoiceFeature = defineFeature("invoice", (r) => {
266
+ r.entity("invoice", invoiceEntity);
267
+ r.writeHandler(
268
+ defineCreateWithTenantDefaults("invoice", invoiceEntity, {
269
+ access: ACCESS,
270
+ currencyFields: ["amount"],
271
+ localeField: "language",
272
+ }),
273
+ );
274
+ });
275
+ ```
@@ -0,0 +1,347 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/tier-admin. Do not edit. -->
2
+
3
+ # tier-admin
4
+
5
+ Manually grant a tier to a tenant without a billing purchase — the SystemAdmin operator flow and how the resolver cache stays in sync.
6
+
7
+ ## README
8
+
9
+ # Tier Admin
10
+
11
+ Manually assign a pricing tier to a tenant — without a billing purchase
12
+ and without writing the projection by hand. The recipe shows the
13
+ SystemAdmin-only operator flow plus the subtlety that makes it correct:
14
+ the grant updates the in-memory resolver cache synchronously, so
15
+ toggleable features unlock in the same request.
16
+
17
+ ## What it shows
18
+
19
+ - **`tier-engine:write:set-tenant-tier`** — a SystemAdmin assigns a tier
20
+ to *any* tenant, cross-tenant. Stamps `source: "manual"` so a future
21
+ Stripe → tier sync won't overwrite the grant.
22
+ - **`tier-engine:query:get-tenant-tier`** — SystemAdmin reads back which
23
+ tier a tenant is on plus its source (`"manual"` vs `"billing"`).
24
+ - **`tier-engine:query:tier-options`** — lists the configured tier names
25
+ so the admin UI doesn't have to hard-code them.
26
+ - **`notes-export`** — a `r.toggleable()` feature the `pro` tier unlocks.
27
+ It appears in a tenant's effective-features set only when its tier lists
28
+ it (`pro` does, `free` does not).
29
+ - **Cache-sync invariant** — the manual grant updates the resolver cache
30
+ the same request, not just the projection. `notes-export` is in the
31
+ tenant's effective set immediately after the set call — same request,
32
+ before any cache refresh, replay, or restart.
33
+
34
+ ## Feature composition
35
+
36
+ ```
37
+ config → tenant config (tier-engine dependency)
38
+ tenant → tenant records for cross-tenant grants
39
+ tier-engine → set-tenant-tier, get-tenant-tier, tier-options
40
+ notes-export → r.toggleable() domain feature unlocked by pro tier
41
+ ```
42
+
43
+ ## Flow
44
+
45
+ 1. SystemAdmin calls `set-tenant-tier` for a **foreign** tenant with
46
+ `tier: "pro"` → event lands in the target tenant's stream,
47
+ `source: "manual"`.
48
+ 2. `get-tenant-tier` read-back confirms tier + source (billing sync must
49
+ not overwrite manual grants).
50
+ 3. `tier-options` returns keys from your static `TierMap` — no hard-coded
51
+ tier list in the UI.
52
+ 4. Resolver built **before** the grant now reports `notes-export` in the
53
+ tenant's effective set — same process, no rebuild (cache-sync proof).
54
+
55
+ ## Why the cache invariant matters
56
+
57
+ `set-tenant-tier` writes through the event-store executor directly. That
58
+ path bypasses the `postSave` entity-hook the resolver normally uses to
59
+ invalidate the cache after a tier-assignment change. Without an explicit
60
+ cache update the grant would persist (next request would see it) but
61
+ *this* request still sees the old tier — surprising for an operator who
62
+ just clicked "set tier to pro".
63
+
64
+ The feature wires `onAssigned` into `createSetTenantTierWrite` for
65
+ exactly this reason.
66
+
67
+ ## Tests
68
+
69
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
70
+
71
+ ```bash
72
+ bun test src/__tests__/feature.integration.test.ts
73
+ ```
74
+
75
+ Integration test proves:
76
+
77
+ - Cross-tenant grant + read-back with `source: "manual"`
78
+ - `tier-options` matches `TierMap` keys
79
+ - Idempotent re-grant updates the same aggregate
80
+ - TenantAdmin without SystemAdmin → 403
81
+ - Resolver sees `notes-export` immediately after grant (cache-sync)
82
+
83
+ ## Related samples
84
+
85
+ - [apps-cap-billing-demo](/en/samples/apps-cap-billing-demo/) — tier from
86
+ billing webhooks + cap enforcement.
87
+ - [recipes-encrypted-tenant-config](/en/samples/recipes-encrypted-tenant-config/) —
88
+ per-tenant config keys gated by tier.
89
+
90
+ ## Source files
91
+
92
+ ### src/__tests__/feature.integration.test.ts
93
+
94
+ ```ts
95
+ // Tier Admin — integration test.
96
+ //
97
+ // Drives the SystemAdmin-only operator flow end-to-end against the real
98
+ // dispatcher + DB:
99
+ // 1. SystemAdmin sits in his own tenant.
100
+ // 2. He grants a tier to a FOREIGN tenant via set-tenant-tier.
101
+ // 3. get-tenant-tier (cross-tenant read) returns tier:"pro",
102
+ // source:"manual" — proving the grant landed in the target stream
103
+ // and is marked as a manual grant (Stripe sync won't overwrite it).
104
+ // 4. tier-options returns the TierMap keys without hard-coding.
105
+
106
+ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
107
+ import { randomBytes } from "node:crypto";
108
+ import {
109
+ configValuesTable,
110
+ createConfigFeature,
111
+ createConfigResolver,
112
+ } from "@cosmicdrift/kumiko-bundled-features/config";
113
+ import { createTenantFeature, tenantEntity } from "@cosmicdrift/kumiko-bundled-features/tenant";
114
+ import {
115
+ TierEngineHandlers,
116
+ TierEngineQueries,
117
+ tierAssignmentEntity,
118
+ } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
119
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/db";
120
+ import { findTierResolverUsage, isTierResolverPlugin } from "@cosmicdrift/kumiko-framework/engine";
121
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
122
+ import {
123
+ createTestUser,
124
+ setupTestStack,
125
+ type TestStack,
126
+ testTenantId,
127
+ unsafeCreateEntityTable,
128
+ unsafePushTables,
129
+ } from "@cosmicdrift/kumiko-framework/stack";
130
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
131
+ import { appTierMap, NOTES_EXPORT_FEATURE, notesExportFeature, tierEngineForApp } from "../feature";
132
+
133
+ const encryptionKey = randomBytes(32).toString("base64");
134
+ const configFeature = createConfigFeature();
135
+ const tenantFeature = createTenantFeature();
136
+ const recipeFeatures = [configFeature, tenantFeature, tierEngineForApp, notesExportFeature];
137
+
138
+ let stack: TestStack;
139
+
140
+ beforeAll(async () => {
141
+ const encryption = createTestEnvelopeCipher(encryptionKey);
142
+ const configResolver = createConfigResolver({ cipher: encryption });
143
+
144
+ stack = await setupTestStack({
145
+ features: recipeFeatures,
146
+ extraContext: { configResolver, configEncryption: encryption },
147
+ });
148
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
149
+ await unsafeCreateEntityTable(stack.db, tierAssignmentEntity);
150
+ await unsafePushTables(stack.db, { configValuesTable });
151
+ await createEventsTable(stack.db);
152
+ });
153
+
154
+ afterAll(async () => {
155
+ await stack.cleanup();
156
+ });
157
+
158
+ beforeEach(async () => {
159
+ await asRawClient(stack.db).unsafe(
160
+ "TRUNCATE read_tier_assignments, kumiko_events RESTART IDENTITY CASCADE",
161
+ );
162
+ });
163
+
164
+ type GrantResult = { tenantId: string; tier: string; isNew: boolean };
165
+ type GrantRow = { tier: string; source: string };
166
+ type Options = { tiers: ReadonlyArray<string> };
167
+
168
+ test("SystemAdmin grants a foreign tenant the 'pro' tier without billing", async () => {
169
+ const adminTenant = testTenantId(1);
170
+ const targetTenant = testTenantId(2);
171
+ const sysadmin = createTestUser({ id: 1, tenantId: adminTenant, roles: ["SystemAdmin"] });
172
+
173
+ // (1) Grant — cross-tenant write. SystemAdmin sits in adminTenant, the
174
+ // tier-assignment event lands in targetTenant's stream.
175
+ const grant = await stack.http.writeOk<GrantResult>(
176
+ TierEngineHandlers.setTenantTier,
177
+ { tenantId: targetTenant, tier: "pro" },
178
+ sysadmin,
179
+ );
180
+ expect(grant.tenantId).toBe(targetTenant);
181
+ expect(grant.tier).toBe("pro");
182
+ expect(grant.isNew).toBe(true);
183
+
184
+ // (2) Read-back via get-tenant-tier — cross-tenant query. source:"manual"
185
+ // is the load-bearing field: it tells a future Stripe → tier sync NOT
186
+ // to overwrite the operator-set tier.
187
+ const row = await stack.http.queryOk<GrantRow>(
188
+ TierEngineQueries.getTenantTier,
189
+ { tenantId: targetTenant },
190
+ sysadmin,
191
+ );
192
+ expect(row.tier).toBe("pro");
193
+ expect(row.source).toBe("manual");
194
+ });
195
+
196
+ test("tier-options returns the configured TierMap keys (no hard-coding)", async () => {
197
+ const sysadmin = createTestUser({
198
+ id: 2,
199
+ tenantId: testTenantId(3),
200
+ roles: ["SystemAdmin"],
201
+ });
202
+
203
+ const opts = await stack.http.queryOk<Options>(TierEngineQueries.tierOptions, {}, sysadmin);
204
+
205
+ expect(opts.tiers).toEqual(Object.keys(appTierMap));
206
+ });
207
+
208
+ test("upsert is idempotent — re-granting updates the same aggregate", async () => {
209
+ const sysadmin = createTestUser({
210
+ id: 3,
211
+ tenantId: testTenantId(4),
212
+ roles: ["SystemAdmin"],
213
+ });
214
+ const target = testTenantId(5);
215
+
216
+ const first = await stack.http.writeOk<GrantResult>(
217
+ TierEngineHandlers.setTenantTier,
218
+ { tenantId: target, tier: "free" },
219
+ sysadmin,
220
+ );
221
+ expect(first.isNew).toBe(true);
222
+
223
+ const second = await stack.http.writeOk<GrantResult>(
224
+ TierEngineHandlers.setTenantTier,
225
+ { tenantId: target, tier: "pro" },
226
+ sysadmin,
227
+ );
228
+ expect(second.isNew).toBe(false);
229
+ expect(second.tier).toBe("pro");
230
+ });
231
+
232
+ test("TenantAdmin without SystemAdmin role cannot grant a foreign tenant a tier", async () => {
233
+ const tenantAdmin = createTestUser({
234
+ id: 4,
235
+ tenantId: testTenantId(6),
236
+ roles: ["TenantAdmin"],
237
+ });
238
+ const target = testTenantId(7);
239
+
240
+ const res = await stack.http.write(
241
+ TierEngineHandlers.setTenantTier,
242
+ { tenantId: target, tier: "pro" },
243
+ tenantAdmin,
244
+ );
245
+ expect(res.status).toBe(403);
246
+ });
247
+
248
+ test("set-tenant-tier lights up the tenant's toggleable feature in the resolver — same request, not a projection re-read", async () => {
249
+ const adminTenant = testTenantId(8);
250
+ const targetTenant = testTenantId(9);
251
+ const sysadmin = createTestUser({ id: 5, tenantId: adminTenant, roles: ["SystemAdmin"] });
252
+
253
+ // Build the resolver BEFORE the grant, so its in-memory cache is warm with
254
+ // the pre-grant state. The only path for notes-export to appear afterwards
255
+ // is the synchronous onAssigned cache update inside set-tenant-tier — not a
256
+ // fresh projection read at build time.
257
+ const usage = findTierResolverUsage(recipeFeatures);
258
+ if (!usage) throw new Error("setup failure: no tier-resolver plugin registered");
259
+ if (!isTierResolverPlugin(usage.options)) {
260
+ throw new Error("setup failure: tier-resolver registration has no build() plugin");
261
+ }
262
+ const resolver = await usage.options.build({
263
+ db: stack.db,
264
+ registry: stack.registry,
265
+ });
266
+ expect(resolver(targetTenant).has(NOTES_EXPORT_FEATURE)).toBe(false);
267
+
268
+ // SystemAdmin grants "pro" cross-tenant. set-tenant-tier writes through the
269
+ // event-store executor, which skips the postSave hook that would otherwise
270
+ // invalidate the cache — so the write calls onAssigned explicitly.
271
+ await stack.http.writeOk(
272
+ TierEngineHandlers.setTenantTier,
273
+ { tenantId: targetTenant, tier: "pro" },
274
+ sysadmin,
275
+ );
276
+
277
+ // Same process, no rebuild / replay / restart: the resolver already sees
278
+ // the pro features. Drop onAssigned from createSetTenantTierWrite and this
279
+ // assertion stays false — the projection updates, the cache does not.
280
+ expect(resolver(targetTenant).has(NOTES_EXPORT_FEATURE)).toBe(true);
281
+ });
282
+ ```
283
+
284
+ ### src/feature.ts
285
+
286
+ ```ts
287
+ // Tier Admin Sample
288
+ //
289
+ // Shows the SystemAdmin-only operator flow: assigning a tier to *any*
290
+ // tenant without a billing purchase. The app side is intentionally tiny —
291
+ // the recipe is the bundled tier-engine itself, configured with the app's
292
+ // own TierMap. The integration test exercises the cross-tenant grant
293
+ // (set-tenant-tier), the read-back (get-tenant-tier returning
294
+ // source:"manual"), and the option-list (tier-options) end-to-end.
295
+
296
+ import {
297
+ createTierEngineFeature,
298
+ type TierMap,
299
+ } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
300
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
301
+
302
+ // --- App caps ---
303
+ //
304
+ // Each app picks its own cap dimensions. Here a tiny example: how many
305
+ // notes a tenant may keep. The TierMap is generic in this cap-shape so
306
+ // downstream code stays type-safe end-to-end.
307
+
308
+ export type AppCaps = { readonly maxNotes: number };
309
+
310
+ // --- The toggleable feature a paid tier unlocks ---
311
+ //
312
+ // A `r.toggleable()` feature shows up in a tenant's effective-features set
313
+ // exactly when its tier lists it. "pro" lists it below; "free" does not.
314
+ // This is what makes the cache-sync invariant observable: granting "pro"
315
+ // must light this feature up in the resolver the same request, not after a
316
+ // refresh, replay, or restart.
317
+
318
+ export const NOTES_EXPORT_FEATURE = "notes-export";
319
+
320
+ export const notesExportFeature = defineFeature(NOTES_EXPORT_FEATURE, (r) => {
321
+ r.toggleable({ default: false });
322
+ });
323
+
324
+ // --- Tier map ---
325
+ //
326
+ // "free" + "pro" — the operator picks one of these names when granting
327
+ // a tier manually. "pro" unlocks the notes-export toggleable feature; the
328
+ // integration test grants "pro" and then reaches that feature in the same
329
+ // request, proving the cache-sync invariant end-to-end.
330
+
331
+ export const appTierMap: TierMap<AppCaps> = {
332
+ free: { features: [], caps: { maxNotes: 5 } },
333
+ pro: { features: [NOTES_EXPORT_FEATURE], caps: { maxNotes: 100 } },
334
+ };
335
+
336
+ // --- Configured tier-engine ---
337
+ //
338
+ // `defaultTier: "free"` means every new tenant starts on free via the
339
+ // `inTransaction` entity hook the tier-engine registers — no app code
340
+ // needed. `tierMap` makes `tier-options` return ["free", "pro"] so the
341
+ // tier-admin screen can populate its picker without hard-coding.
342
+
343
+ export const tierEngineForApp = createTierEngineFeature<AppCaps>({
344
+ defaultTier: "free",
345
+ tierMap: appTierMap,
346
+ });
347
+ ```