@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,263 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/feature-toggles. Do not edit. -->
2
+
3
+ # feature-toggles
4
+
5
+ Runtime feature-toggle flip — two toggleable features, one registers a cross-feature hook on the other; disabling either has the documented effect without reboot.
6
+
7
+ ## README
8
+
9
+ # Feature Toggles
10
+
11
+ Runtime global feature toggles without reboot.
12
+
13
+ ## What it shows
14
+
15
+ - Dispatcher gate → `feature_disabled` when a feature is off
16
+ - Cross-feature hooks skipped when a dependency toggle is off
17
+
18
+ ## Source
19
+
20
+ Feature entry point: `src/feature.ts`.
21
+
22
+ ## Tests
23
+
24
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
25
+
26
+ ```bash
27
+ cd samples/recipes/feature-toggles
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
37
+ import {
38
+ createFeatureTogglesFeature,
39
+ GlobalFeatureToggleRuntime,
40
+ globalFeatureStateTable,
41
+ } from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
42
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
43
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
44
+ import {
45
+ createTestUser,
46
+ setupTestStack,
47
+ type TestStack,
48
+ unsafeCreateEntityTable,
49
+ unsafePushTables,
50
+ } from "@cosmicdrift/kumiko-framework/stack";
51
+ import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
52
+ import {
53
+ createProductAuditFeature,
54
+ createProductFeature,
55
+ productAuditEntity,
56
+ productAuditTable,
57
+ productEntity,
58
+ productTable,
59
+ } from "../feature";
60
+
61
+ let stack: TestStack;
62
+ let runtime: GlobalFeatureToggleRuntime;
63
+
64
+ beforeAll(async () => {
65
+ let effective: () => ReadonlySet<string> = () => new Set();
66
+ const runtimeHolder = createLateBoundHolder<GlobalFeatureToggleRuntime>("runtime");
67
+
68
+ stack = await setupTestStack({
69
+ features: [
70
+ createProductFeature(),
71
+ createProductAuditFeature(),
72
+ createFeatureTogglesFeature({ getRuntime: () => runtimeHolder.get() }),
73
+ ],
74
+ effectiveFeatures: () => effective(),
75
+ systemHooks: [],
76
+ });
77
+
78
+ await unsafePushTables(stack.db, { globalFeatureStateTable });
79
+ await unsafeCreateEntityTable(stack.db, productEntity);
80
+ await unsafeCreateEntityTable(stack.db, productAuditEntity, "product-audit");
81
+
82
+ runtime = new GlobalFeatureToggleRuntime(stack.db, stack.registry);
83
+ await runtime.initialize();
84
+ effective = runtime.effectiveFeatures;
85
+ runtimeHolder.set(runtime);
86
+ });
87
+
88
+ afterAll(async () => {
89
+ await stack?.cleanup();
90
+ });
91
+
92
+ beforeEach(async () => {
93
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${productAuditTable.tableName}"`);
94
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${productTable.tableName}"`);
95
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${globalFeatureStateTable.tableName}"`);
96
+ await runtime.refresh();
97
+ });
98
+
99
+ const admin = createTestUser({
100
+ id: "11111111-1111-1111-1111-111111111111",
101
+ tenantId: SYSTEM_TENANT_ID,
102
+ roles: ["SystemAdmin"],
103
+ });
104
+
105
+ async function createProduct(name: string) {
106
+ return stack.http.write("product:write:product:create", { name }, admin);
107
+ }
108
+
109
+ describe("feature-toggles showcase", () => {
110
+ test("runtime flip: product on → create works + audit-hook fires; off → 403; on → works again", async () => {
111
+ // ON
112
+ const ok = await createProduct("alpha");
113
+ expect((await ok.json()).isSuccess).toBe(true);
114
+ const auditRowsAfterOn = await selectMany(stack.db, productAuditTable);
115
+ expect(auditRowsAfterOn.length).toBe(1);
116
+
117
+ // OFF
118
+ runtime.apply("product", false);
119
+ const denied = await createProduct("beta");
120
+ const deniedBody = (await denied.json()) as {
121
+ isSuccess: boolean;
122
+ error?: { code: string };
123
+ };
124
+ expect(deniedBody.error?.code).toBe("feature_disabled");
125
+
126
+ // ON again
127
+ runtime.apply("product", true);
128
+ const again = await createProduct("gamma");
129
+ expect((await again.json()).isSuccess).toBe(true);
130
+ const auditRowsAfterReEnable = await selectMany(stack.db, productAuditTable);
131
+ expect(auditRowsAfterReEnable.length).toBe(2);
132
+ });
133
+
134
+ test("cross-feature hook: disabling product-audit skips its hook but product keeps working", async () => {
135
+ runtime.apply("product-audit", false);
136
+
137
+ const ok = await createProduct("delta");
138
+ expect((await ok.json()).isSuccess).toBe(true);
139
+ // Product row written — handler unaffected.
140
+ const productRows = await selectMany(stack.db, productTable);
141
+ expect(productRows.length).toBe(1);
142
+ // Audit hook owned by product-audit — silently skipped.
143
+ const auditRows = await selectMany(stack.db, productAuditTable);
144
+ expect(auditRows.length).toBe(0);
145
+ });
146
+ });
147
+ ```
148
+
149
+ ### src/feature.ts
150
+
151
+ ```ts
152
+ // Feature-Toggles Showcase — two toggleable features that prove the two
153
+ // operator-visible gate points in one test:
154
+ //
155
+ // 1. Dispatcher-gate on the owner feature's handler (403 feature_disabled)
156
+ // 2. Hook-filter on a cross-feature r.hook({ allOf }) (skipped when hook-owner off)
157
+ //
158
+ // The feature-toggles bundled-feature itself is loaded in the integration
159
+ // test so the canonical wiring (runtime accessor + effectiveFeatures
160
+ // callback + set-handler) is exercised as documentation, not just in the
161
+ // framework's own tests.
162
+
163
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
164
+ import {
165
+ createBooleanField,
166
+ createEntity,
167
+ createSystemUser,
168
+ createTextField,
169
+ defineFeature,
170
+ type FeatureDefinition,
171
+ SYSTEM_TENANT_ID,
172
+ } from "@cosmicdrift/kumiko-framework/engine";
173
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
174
+ import { z } from "zod";
175
+
176
+ // product — toggleable, default on. Owns the `product` entity and a
177
+ // create-handler. Wire-path: event-store executor → projection table →
178
+ // lifecycle pipeline (that's where the cross-feature hook below gets
179
+ // invoked from).
180
+ export const productEntity = createEntity({
181
+ table: "read_products",
182
+ fields: {
183
+ name: createTextField({
184
+ personal: false,
185
+ reason: "is_business_data",
186
+ required: true,
187
+ maxLength: 100,
188
+ }),
189
+ active: createBooleanField({ default: true }),
190
+ },
191
+ });
192
+ export const productTable = buildEntityTable("product", productEntity);
193
+ const productCrud = createEventStoreExecutor(productTable, productEntity, {
194
+ entityName: "product",
195
+ });
196
+
197
+ export function createProductFeature(): FeatureDefinition {
198
+ return defineFeature("product", (r) => {
199
+ r.systemScope();
200
+ r.toggleable({ default: true });
201
+ r.entity("product", productEntity);
202
+ r.writeHandler(
203
+ "product:create",
204
+ z.object({ name: z.string().min(1).max(100) }),
205
+ async (event, ctx) => {
206
+ if (!ctx.systemDb) {
207
+ throw new InternalError({
208
+ message: "product:create requires ctx.systemDb — is r.systemScope() still set?",
209
+ });
210
+ }
211
+ const db = ctx.systemDb.acknowledgeCrossTenant(
212
+ "product catalog is system-wide, not tenant-scoped",
213
+ );
214
+ return productCrud.create(event.payload, event.user, db);
215
+ },
216
+ { access: { roles: ["SystemAdmin"] } },
217
+ );
218
+ });
219
+ }
220
+
221
+ // product-audit — toggleable, default on. Registers a cross-feature
222
+ // r.hook({ allOf: "product" }) on product's postSave. When this feature is globally off,
223
+ // the hook is silently skipped; product's own write-handler keeps working.
224
+ // When product itself is off, the handler is gated before any write
225
+ // happens — the hook never has anything to react to.
226
+ export const productAuditEntity = createEntity({
227
+ table: "read_product_audits",
228
+ fields: {
229
+ productName: createTextField({
230
+ personal: false,
231
+ reason: "is_business_data",
232
+ required: true,
233
+ maxLength: 100,
234
+ }),
235
+ },
236
+ });
237
+ export const productAuditTable = buildEntityTable("product-audit", productAuditEntity);
238
+ const productAuditCrud = createEventStoreExecutor(productAuditTable, productAuditEntity, {
239
+ entityName: "product-audit",
240
+ });
241
+
242
+ export function createProductAuditFeature(): FeatureDefinition {
243
+ return defineFeature("product-audit", (r) => {
244
+ r.systemScope();
245
+ r.toggleable({ default: true });
246
+ r.entity("product-audit", productAuditEntity);
247
+
248
+ r.hook("postSave", { allOf: "product" }, async (result, ctx) => {
249
+ if (result.kind !== "save" || !result.isNew) return;
250
+ if (!ctx.systemDb) return;
251
+ const name = (result.changes as Record<string, unknown>)["name"] as string | undefined;
252
+ if (!name) return;
253
+ const db = ctx.systemDb.acknowledgeCrossTenant(
254
+ "audit sink is system-wide, mirrors the system-wide product catalog",
255
+ );
256
+ // Cross-feature audit sink is itself an r.entity projection — write it via
257
+ // the executor (system actor, no human caller) so the row is event-backed
258
+ // and survives a rebuild, not a direct method-form insert.
259
+ await productAuditCrud.create({ productName: name }, createSystemUser(SYSTEM_TENANT_ID), db);
260
+ });
261
+ });
262
+ }
263
+ ```
@@ -0,0 +1,271 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/field-access. Do not edit. -->
2
+
3
+ # field-access
4
+
5
+ Field-level access control. When entity-level roles aren't granular enough.
6
+
7
+ ## README
8
+
9
+ # Field-level access
10
+
11
+ Restrict individual fields by role — separate read and write rules per
12
+ field, no `if` branches in the handler. This recipe ships an `employee`
13
+ entity where `salary` is visible to Admin and Accounting (only Admin
14
+ writes) and `internalNotes` is Admin-only on both sides.
15
+
16
+ The framework strips fields the caller cannot read on the way out and
17
+ rejects writes that touch fields the caller cannot write. Your handler
18
+ never sees a forbidden field on input, never produces one on output.
19
+
20
+ ## What it shows
21
+
22
+ - **`access: { read, write }` on a field factory** — two independent
23
+ rules per field, both expressed as role lists.
24
+ - **Read-side filtering** — querying as `Employee` returns the row
25
+ without the `salary` and `internalNotes` keys; querying as
26
+ `Accounting` returns `salary` but not `internalNotes`.
27
+ - **Write-side enforcement** — a write payload that includes
28
+ `internalNotes` from a non-Admin role fails with
29
+ `field_access_denied` and the offending field path.
30
+ - **No handler-level branching** — the same `r.queryHandler` body
31
+ serves all three roles; the framework filters output per call.
32
+
33
+ ## Feature composition
34
+
35
+ ```
36
+ hr → employee entity with field-level access on salary + internalNotes
37
+ ```
38
+
39
+ Single feature, no bundled dependencies — field access is declared on
40
+ the field factories inside `createEntity`.
41
+
42
+ ## Flow
43
+
44
+ 1. Admin creates an employee with all fields populated.
45
+ 2. Accounting reads detail → sees `salary`, not `internalNotes`.
46
+ 3. Employee reads detail → sees `name`/`email` only.
47
+ 4. Accounting tries to write `salary` → `field_access_denied` (read-only).
48
+ 5. Employee updates `name` → succeeds (unrestricted field).
49
+
50
+ ## When to reach for it
51
+
52
+ Sensitive columns that a feature still needs to expose — phone numbers,
53
+ salary figures, internal compliance notes. The whole row stays usable
54
+ for everyone; specific fields disappear for callers without the
55
+ right role.
56
+
57
+ ## Tests
58
+
59
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
60
+
61
+ ```bash
62
+ bun kumiko test integration samples/field-access
63
+ ```
64
+
65
+ Or from the recipe directory:
66
+
67
+ ```bash
68
+ bun test src/__tests__/feature.integration.test.ts
69
+ ```
70
+
71
+ Covers all three roles reading and writing, including the expected
72
+ `field_access_denied` response shape.
73
+
74
+ ## Related samples
75
+
76
+ - [basic-entity](/en/samples/recipes-basic-entity/) — start here if you
77
+ need standard CRUD before adding field rules.
78
+ - [custom-handlers](/en/samples/recipes-custom-handlers/) — explicit
79
+ handlers when generated CRUD is not enough.
80
+
81
+ ## Source files
82
+
83
+ ### src/__tests__/feature.integration.test.ts
84
+
85
+ ```ts
86
+ // Field Access Sample — Integration Test
87
+ // Proves: field-level read/write restrictions per role
88
+
89
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
90
+ import type { EntityId, SaveContext } from "@cosmicdrift/kumiko-framework/engine";
91
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
92
+ import {
93
+ createTestUser,
94
+ setupTestStack,
95
+ type TestStack,
96
+ TestUsers,
97
+ unsafeCreateEntityTable,
98
+ } from "@cosmicdrift/kumiko-framework/stack";
99
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
100
+ import { employeeEntity, employeeFeature } from "../feature";
101
+
102
+ let stack: TestStack;
103
+
104
+ const adminUser = TestUsers.admin;
105
+ const accountingUser = createTestUser({ id: 2, roles: ["Accounting"] });
106
+ const employeeUser = createTestUser({ id: 3, roles: ["Employee"] });
107
+
108
+ beforeAll(async () => {
109
+ stack = await setupTestStack({ features: [employeeFeature] });
110
+ await unsafeCreateEntityTable(stack.db, employeeEntity);
111
+ await createEventsTable(stack.db);
112
+ });
113
+
114
+ afterAll(async () => {
115
+ await stack.cleanup();
116
+ });
117
+
118
+ async function seedEmployee(): Promise<EntityId> {
119
+ const data = await stack.http.writeOk<SaveContext>(
120
+ "hr:write:employee:create",
121
+ {
122
+ name: "Test Employee",
123
+ email: "test@company.de",
124
+ salary: 75000,
125
+ internalNotes: "Top performer",
126
+ },
127
+ adminUser,
128
+ );
129
+ return data.id;
130
+ }
131
+
132
+ describe("read access: field filtering per role", () => {
133
+ test("Admin sees all fields", async () => {
134
+ const id = await seedEmployee();
135
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
136
+ "hr:query:employee:detail",
137
+ { id },
138
+ adminUser,
139
+ );
140
+
141
+ expect(detail["name"]).toBe("Test Employee");
142
+ expect(detail["salary"]).toBe(75000);
143
+ expect(detail["internalNotes"]).toBe("Top performer");
144
+ });
145
+
146
+ test("Accounting sees salary but not internalNotes", async () => {
147
+ const id = await seedEmployee();
148
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
149
+ "hr:query:employee:detail",
150
+ { id },
151
+ accountingUser,
152
+ );
153
+
154
+ expect(detail["name"]).toBe("Test Employee");
155
+ expect(detail["salary"]).toBe(75000);
156
+ expect(detail["internalNotes"]).toBeUndefined();
157
+ });
158
+
159
+ test("Employee sees neither salary nor internalNotes", async () => {
160
+ const id = await seedEmployee();
161
+ const detail = await stack.http.queryOk<Record<string, unknown>>(
162
+ "hr:query:employee:detail",
163
+ { id },
164
+ employeeUser,
165
+ );
166
+
167
+ expect(detail["name"]).toBe("Test Employee");
168
+ expect(detail["salary"]).toBeUndefined();
169
+ expect(detail["internalNotes"]).toBeUndefined();
170
+ });
171
+ });
172
+
173
+ describe("write access: field-level write restrictions", () => {
174
+ test("Admin can write salary", async () => {
175
+ const id = await seedEmployee();
176
+ const updated = await stack.http.writeOk<SaveContext>(
177
+ "hr:write:employee:update",
178
+ {
179
+ id,
180
+ changes: { salary: 80000 },
181
+ version: 1,
182
+ },
183
+ adminUser,
184
+ );
185
+
186
+ expect(updated.data["salary"]).toBe(80000);
187
+ });
188
+
189
+ test("Accounting cannot write salary (read-only)", async () => {
190
+ const id = await seedEmployee();
191
+ const error = await stack.http.writeErr(
192
+ "hr:write:employee:update",
193
+ {
194
+ id,
195
+ changes: { salary: 99999 },
196
+ version: 1,
197
+ },
198
+ accountingUser,
199
+ );
200
+
201
+ expectErrorIncludes(error, "field_access_denied");
202
+ });
203
+
204
+ test("Employee cannot write salary", async () => {
205
+ const id = await seedEmployee();
206
+ const error = await stack.http.writeErr(
207
+ "hr:write:employee:update",
208
+ {
209
+ id,
210
+ changes: { salary: 1000000 },
211
+ version: 1,
212
+ },
213
+ employeeUser,
214
+ );
215
+
216
+ expectErrorIncludes(error, "field_access_denied");
217
+ });
218
+
219
+ test("Employee can write unrestricted fields", async () => {
220
+ const id = await seedEmployee();
221
+ const updated = await stack.http.writeOk<SaveContext>(
222
+ "hr:write:employee:update",
223
+ {
224
+ id,
225
+ changes: { name: "Updated Name" },
226
+ version: 1,
227
+ },
228
+ employeeUser,
229
+ );
230
+
231
+ expect(updated.data["name"]).toBe("Updated Name");
232
+ });
233
+ });
234
+ ```
235
+
236
+ ### src/feature.ts
237
+
238
+ ```ts
239
+ import {
240
+ createEntity,
241
+ createNumberField,
242
+ createTextField,
243
+ defineFeature,
244
+ } from "@cosmicdrift/kumiko-framework/engine";
245
+
246
+ export const employeeEntity = createEntity({
247
+ table: "read_sample_employees",
248
+ fields: {
249
+ name: createTextField({ personal: { of: "id" }, find: "none", required: true }),
250
+ email: createTextField({ personal: { of: "id" }, find: "none", required: true }),
251
+ salary: createNumberField({
252
+ access: { read: ["Admin", "Accounting"], write: ["Admin"] },
253
+ }),
254
+ internalNotes: createTextField({
255
+ personal: { of: "id" },
256
+ find: "none",
257
+ access: { read: ["Admin"], write: ["Admin"] },
258
+ }),
259
+ },
260
+ });
261
+
262
+ const allRoles = { access: { roles: ["Admin", "Accounting", "Employee"] } } as const;
263
+
264
+ export const employeeFeature = defineFeature("hr", (r) => {
265
+ r.crud("employee", employeeEntity, {
266
+ write: allRoles,
267
+ read: allRoles,
268
+ verbs: { delete: false, list: false, restore: false },
269
+ });
270
+ });
271
+ ```