@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,396 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/ownership. Do not edit. -->
2
+
3
+ # ownership
4
+
5
+ Entity- and Field-level Row-Ownership: from() helper, Straddle-safe multi-role checks, Field-level Read/Write filtering.
6
+
7
+ ## README
8
+
9
+ # Ownership
10
+
11
+ Row-level ownership for read and write.
12
+
13
+ ## What it shows
14
+
15
+ - Entity- and field-level ownership rules
16
+ - Straddle-safe checks across tenants/users
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/ownership
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Ownership Sample — Integration Test
37
+ //
38
+ // Drives the whole Read/Write × Entity/Field matrix against the contract
39
+ // feature. Each test maps directly to a cell in the design-doc matrix.
40
+
41
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
42
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
43
+ import {
44
+ createTestUser,
45
+ setupTestStack,
46
+ type TestStack,
47
+ TestUsers,
48
+ testTenantId,
49
+ unsafeCreateEntityTable,
50
+ } from "@cosmicdrift/kumiko-framework/stack";
51
+ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
52
+ import { contractEntity, contractsFeature, teamsFeature } from "../feature";
53
+
54
+ let stack: TestStack;
55
+ const tenant = testTenantId(1);
56
+
57
+ const admin = { ...TestUsers.admin, tenantId: tenant };
58
+ const teamMemberEng = createTestUser({
59
+ id: "11111111-0000-4000-8000-000000000001",
60
+ tenantId: tenant,
61
+ roles: ["TeamMember"],
62
+ claims: { "teams:teamId": "eng" },
63
+ });
64
+ const teamMemberOps = createTestUser({
65
+ id: "11111111-0000-4000-8000-000000000002",
66
+ tenantId: tenant,
67
+ roles: ["TeamMember"],
68
+ claims: { "teams:teamId": "ops" },
69
+ });
70
+ const driverAlice = createTestUser({
71
+ id: "22222222-0000-4000-8000-000000000001",
72
+ tenantId: tenant,
73
+ roles: ["Driver"],
74
+ });
75
+
76
+ beforeAll(async () => {
77
+ stack = await setupTestStack({ features: [teamsFeature, contractsFeature] });
78
+ await unsafeCreateEntityTable(stack.db, contractEntity, "contract");
79
+ });
80
+
81
+ afterAll(async () => {
82
+ await stack.cleanup();
83
+ });
84
+
85
+ let engRowId: string;
86
+ let engRowVersion: number;
87
+ let opsRowId: string;
88
+
89
+ beforeEach(async () => {
90
+ await asRawClient(stack.db).unsafe("DELETE FROM read_ownership_contracts");
91
+ const eng = await stack.http.writeOk<{ id: string; data: { version: number } }>(
92
+ "contracts:write:contract:create",
93
+ {
94
+ teamId: "eng",
95
+ assigneeId: driverAlice.id,
96
+ propA: "public-eng",
97
+ propB: "admin-eng",
98
+ propC: "team-eng",
99
+ },
100
+ admin,
101
+ );
102
+ engRowId = eng.id;
103
+ engRowVersion = eng.data.version;
104
+ const ops = await stack.http.writeOk<{ id: string }>(
105
+ "contracts:write:contract:create",
106
+ {
107
+ teamId: "ops",
108
+ propA: "public-ops",
109
+ propB: "admin-ops",
110
+ propC: "team-ops",
111
+ },
112
+ admin,
113
+ );
114
+ opsRowId = ops.id;
115
+ });
116
+
117
+ // --- Entity-level READ ---
118
+
119
+ describe("entity-level read: list / detail filtering", () => {
120
+ test("Admin lists both rows", async () => {
121
+ const res = await stack.http.queryOk<{ rows: Array<{ teamId: string }> }>(
122
+ "contracts:query:contract:list",
123
+ {},
124
+ admin,
125
+ );
126
+ expect(res.rows).toHaveLength(2);
127
+ });
128
+
129
+ test("TeamMember eng lists only eng's row", async () => {
130
+ const res = await stack.http.queryOk<{ rows: Array<{ teamId: string }> }>(
131
+ "contracts:query:contract:list",
132
+ {},
133
+ teamMemberEng,
134
+ );
135
+ expect(res.rows).toHaveLength(1);
136
+ expect(res.rows[0]?.teamId).toBe("eng");
137
+ });
138
+
139
+ test("Driver alice lists only rows assigned to her", async () => {
140
+ const res = await stack.http.queryOk<{ rows: Array<{ assigneeId: string }> }>(
141
+ "contracts:query:contract:list",
142
+ {},
143
+ driverAlice,
144
+ );
145
+ expect(res.rows).toHaveLength(1);
146
+ expect(res.rows[0]?.assigneeId).toBe(driverAlice.id);
147
+ });
148
+
149
+ test("TeamMember eng getting ops row via detail → null (indistinguishable from not-found)", async () => {
150
+ const res = await stack.http.queryOk(
151
+ "contracts:query:contract:detail",
152
+ {
153
+ id: opsRowId,
154
+ },
155
+ teamMemberEng,
156
+ );
157
+ expect(res).toBeNull();
158
+ });
159
+ });
160
+
161
+ // --- Field-level READ ---
162
+
163
+ describe("field-level read: response JSON strips unreadable fields", () => {
164
+ test("TeamMember eng sees propA + propC; propB missing", async () => {
165
+ const res = await stack.http.queryOk<Record<string, unknown>>(
166
+ "contracts:query:contract:detail",
167
+ { id: engRowId },
168
+ teamMemberEng,
169
+ );
170
+ expect(res["propA"]).toBe("public-eng");
171
+ expect(res["propC"]).toBe("team-eng");
172
+ expect(res).not.toHaveProperty("propB");
173
+ });
174
+
175
+ test("TeamMember ops reading eng's row — blocked at entity level (detail=null)", async () => {
176
+ const res = await stack.http.queryOk(
177
+ "contracts:query:contract:detail",
178
+ {
179
+ id: engRowId,
180
+ },
181
+ teamMemberOps,
182
+ );
183
+ expect(res).toBeNull();
184
+ });
185
+ });
186
+
187
+ // --- Entity-level WRITE ---
188
+
189
+ describe("entity-level write: create/update/delete blocked on foreign rows", () => {
190
+ test("TeamMember eng CANNOT create an ops-team contract", async () => {
191
+ const err = await stack.http.writeErr(
192
+ "contracts:write:contract:create",
193
+ { teamId: "ops", propA: "rogue" },
194
+ teamMemberEng,
195
+ );
196
+ expectErrorIncludes(err, "ownership_denied");
197
+ });
198
+
199
+ test("TeamMember eng CANNOT update the ops row (not visible → not modifiable)", async () => {
200
+ // The row exists but entity-level ownership rejects the update.
201
+ // version is guessed — doesn't matter, the ownership check fires first.
202
+ const err = await stack.http.writeErr(
203
+ "contracts:write:contract:update",
204
+ { id: opsRowId, version: 1, changes: { propA: "hack" } },
205
+ teamMemberEng,
206
+ );
207
+ expectErrorIncludes(err, "ownership_denied");
208
+ });
209
+
210
+ test("TeamMember eng CANNOT move their row to another team (teamId rewrite blocked)", async () => {
211
+ const err = await stack.http.writeErr(
212
+ "contracts:write:contract:update",
213
+ {
214
+ id: engRowId,
215
+ version: engRowVersion,
216
+ changes: { teamId: "ops" },
217
+ },
218
+ teamMemberEng,
219
+ );
220
+ expectErrorIncludes(err, "ownership_denied");
221
+ });
222
+ });
223
+
224
+ // --- Field-level WRITE ---
225
+
226
+ describe("field-level write: individual fields rejected fail-loud", () => {
227
+ test("TeamMember eng updating propB (Admin-only) → access_denied (role gate)", async () => {
228
+ const err = await stack.http.writeErr(
229
+ "contracts:write:contract:update",
230
+ {
231
+ id: engRowId,
232
+ version: engRowVersion,
233
+ changes: { propB: "sneak" },
234
+ },
235
+ teamMemberEng,
236
+ );
237
+ expectErrorIncludes(err, "access_denied");
238
+ });
239
+
240
+ test("TeamMember eng updating propA succeeds on their own row", async () => {
241
+ const res = await stack.http.writeOk<{ data: { propA: string } }>(
242
+ "contracts:write:contract:update",
243
+ {
244
+ id: engRowId,
245
+ version: engRowVersion,
246
+ changes: { propA: "my-edit" },
247
+ },
248
+ teamMemberEng,
249
+ );
250
+ expect(res.data.propA).toBe("my-edit");
251
+ });
252
+ });
253
+
254
+ // --- Admin short-circuit ---
255
+
256
+ describe("admin-all short-circuit skips every ownership predicate", () => {
257
+ test("Admin reads both rows with all fields intact", async () => {
258
+ const eng = await stack.http.queryOk<Record<string, unknown>>(
259
+ "contracts:query:contract:detail",
260
+ { id: engRowId },
261
+ admin,
262
+ );
263
+ expect(eng["propA"]).toBe("public-eng");
264
+ expect(eng["propB"]).toBe("admin-eng");
265
+ expect(eng["propC"]).toBe("team-eng");
266
+ });
267
+
268
+ test("Admin updates, deletes, restores on any team", async () => {
269
+ const updated = await stack.http.writeOk<{ data: { propB: string; version: number } }>(
270
+ "contracts:write:contract:update",
271
+ {
272
+ id: opsRowId,
273
+ version: 1,
274
+ changes: { propB: "admin-edit-across-teams" },
275
+ },
276
+ admin,
277
+ );
278
+ expect(updated.data.propB).toBe("admin-edit-across-teams");
279
+ const deleted = await stack.http.writeOk<{ id: string }>(
280
+ "contracts:write:contract:delete",
281
+ { id: opsRowId },
282
+ admin,
283
+ );
284
+ expect(deleted.id).toBe(opsRowId);
285
+ const restored = await stack.http.writeOk<{ id: string }>(
286
+ "contracts:write:contract:restore",
287
+ { id: opsRowId },
288
+ admin,
289
+ );
290
+ expect(restored.id).toBe(opsRowId);
291
+ });
292
+ });
293
+ ```
294
+
295
+ ### src/feature.ts
296
+
297
+ ```ts
298
+ // Ownership Sample
299
+ //
300
+ // Concrete contract-entity showing the full ownership matrix:
301
+ //
302
+ // Dimension Entity-level Field-level
303
+ // --------- ------------ -----------
304
+ // Read list/detail filter propX stripped from response JSON
305
+ // Write create/update/delete individual fields rejected loud
306
+ // Rule shape { Role: OwnershipRule } (same on both dimensions)
307
+ // Rule kinds "all" / from(ref, col?) / { where: (user, table) => SQL }
308
+ //
309
+ // The entity below is the "contract" example from the H.2 design
310
+ // discussion: propA is public, propB is Admin-only, propC requires a
311
+ // matching team claim. Integration tests prove each row/column path.
312
+ //
313
+ // Why fail-loud on write (not silent drop): Silent-drop makes bugs
314
+ // invisible at the reporting layer. "Save doesn't work" with no error
315
+ // becomes weeks of blame between frontend / API / infrastructure. A loud
316
+ // `field_ownership_denied` with the field name + role + userId in the
317
+ // details payload is a single log-line to a root cause.
318
+
319
+ import {
320
+ createEntity,
321
+ createTextField,
322
+ defineFeature,
323
+ from,
324
+ } from "@cosmicdrift/kumiko-framework/engine";
325
+
326
+ // The "teams" feature declares the teamId claim. Any other feature can
327
+ // reference it via the QN string "teams:teamId" in ownership rules — no
328
+ // cross-feature import needed. The boot-validator rejects a reference to a
329
+ // claim no feature declared.
330
+ export const teamsFeature = defineFeature("teams", (r) => {
331
+ r.claimKey("teamId", { type: "string" });
332
+ });
333
+
334
+ // Contract entity demonstrating the full access matrix.
335
+ export const contractEntity = createEntity({
336
+ table: "read_ownership_contracts",
337
+ softDelete: true,
338
+ fields: {
339
+ // teamId is the ownership-scoping column. It's referenced by claim-rules
340
+ // via the default column (rule's shortName = "teamId" matches).
341
+ teamId: createTextField({ personal: false, reason: "is_system_identifier", required: true }),
342
+ // Optional — when set, only that user (via user:id rule) can access.
343
+ assigneeId: createTextField({ personal: "ref" }),
344
+
345
+ // propA: public field. No access declared — every caller reads AND writes.
346
+ propA: createTextField({ personal: false, reason: "is_business_data" }),
347
+
348
+ // propB: Admin-only. Non-admins don't see it in responses; attempts
349
+ // to write it return access_denied (role gate in dispatcher).
350
+ propB: createTextField({
351
+ personal: false,
352
+ reason: "is_business_data",
353
+ access: {
354
+ read: { Admin: "all" },
355
+ write: { Admin: "all" },
356
+ },
357
+ }),
358
+
359
+ // propC: Admin + Matching-Team. Non-admins with the wrong team see no
360
+ // propC in responses; writes to propC on a foreign team's row return
361
+ // field_ownership_denied (executor ownership check).
362
+ propC: createTextField({
363
+ personal: false,
364
+ reason: "is_business_data",
365
+ access: {
366
+ read: { Admin: "all", TeamMember: from("claim:teams:teamId") },
367
+ write: { Admin: "all", TeamMember: from("claim:teams:teamId") },
368
+ },
369
+ }),
370
+ },
371
+
372
+ // Entity-level access: controls list/detail/update/delete/restore of the
373
+ // whole row. Admin sees everything; TeamMember scoped to their team;
374
+ // assigned Driver sees rows assigned to them.
375
+ access: {
376
+ read: {
377
+ Admin: "all",
378
+ TeamMember: from("claim:teams:teamId"),
379
+ Driver: from("user:id", "assigneeId"),
380
+ },
381
+ write: {
382
+ Admin: "all",
383
+ TeamMember: from("claim:teams:teamId"),
384
+ Driver: from("user:id", "assigneeId"),
385
+ },
386
+ },
387
+ });
388
+
389
+ export const contractsFeature = defineFeature("contracts", (r) => {
390
+ const contractAccess = { access: { roles: ["Admin", "TeamMember", "Driver"] } } as const;
391
+ r.crud("contract", contractEntity, {
392
+ write: contractAccess,
393
+ read: contractAccess,
394
+ });
395
+ });
396
+ ```
@@ -0,0 +1,270 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/pdf-audit-export. Do not edit. -->
2
+
3
+ # pdf-audit-export
4
+
5
+ Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes.
6
+
7
+ ## README
8
+
9
+ # PDF Audit Export
10
+
11
+ A write handler exports tenant data as a PDF and keeps it as a FileRef.
12
+
13
+ ## What it shows
14
+
15
+ - `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
16
+ - `createFileRefFromBytes` (`@cosmicdrift/kumiko-framework/files`) stores the bytes like an upload (validation, tenant-prefixed storage key, `fileRef.created` event); the tenant comes from the caller.
17
+ - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
18
+
19
+ ## Source
20
+
21
+ Feature entry point: `src/feature.ts`. The export reads at most 200 inspections per call.
22
+
23
+ ## Tests
24
+
25
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
26
+
27
+ ```bash
28
+ cd samples/recipes/pdf-audit-export
29
+ bun test
30
+ ```
31
+
32
+ ## Source files
33
+
34
+ ### src/__tests__/feature.integration.test.ts
35
+
36
+ ```ts
37
+ // PDF Audit Export Sample — Integration Test
38
+ //
39
+ // Proves the whole path through real HTTP calls: inspections are written,
40
+ // the export handler renders and stores a PDF, GET /api/files/:id serves it
41
+ // and its text contains exactly the inspections of the requested period.
42
+
43
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
44
+ import {
45
+ createFilesFeature,
46
+ createInMemoryFileProvider,
47
+ } from "@cosmicdrift/kumiko-framework/files";
48
+ import {
49
+ createTestUser,
50
+ setupTestStack,
51
+ type TestStack,
52
+ testTenantId,
53
+ unsafeCreateEntityTable,
54
+ } from "@cosmicdrift/kumiko-framework/stack";
55
+ import { extractText, getDocumentProxy } from "unpdf";
56
+ import { inspectionEntity, pdfAuditExportFeature } from "../feature";
57
+
58
+ let stack: TestStack;
59
+
60
+ const userA = createTestUser({ id: 1, tenantId: testTenantId(1), roles: ["Admin"] });
61
+ const userB = createTestUser({ id: 2, tenantId: testTenantId(2), roles: ["Admin"] });
62
+
63
+ beforeAll(async () => {
64
+ stack = await setupTestStack({
65
+ features: [createFilesFeature(), pdfAuditExportFeature],
66
+ files: { storageProvider: createInMemoryFileProvider() },
67
+ });
68
+ await unsafeCreateEntityTable(stack.db, inspectionEntity);
69
+ });
70
+
71
+ afterAll(async () => {
72
+ await stack.cleanup();
73
+ });
74
+
75
+ async function addInspection(
76
+ inspectedOn: string,
77
+ result: "ok" | "defect",
78
+ inspector: string,
79
+ assetName = "Kessel 3",
80
+ ): Promise<void> {
81
+ await stack.http.writeOk(
82
+ "pdf-audit-export:write:inspection:create",
83
+ { assetName, inspectedOn, result, inspector },
84
+ userA,
85
+ );
86
+ }
87
+
88
+ async function download(user: typeof userA, fileRefId: string): Promise<Response> {
89
+ const token = await stack.jwt.sign(user);
90
+ return stack.app.request(`/api/files/${fileRefId}`, {
91
+ headers: { Authorization: `Bearer ${token}` },
92
+ });
93
+ }
94
+
95
+ describe("export-audit", () => {
96
+ test("stores a PDF FileRef containing only the inspections of the period and asset", async () => {
97
+ await addInspection("2026-01-10", "ok", "Zeile-vor-Zeitraum");
98
+ await addInspection("2026-02-05", "defect", "Zeile-im-Zeitraum-Eins");
99
+ await addInspection("2026-03-20", "ok", "Zeile-im-Zeitraum-Zwei");
100
+ await addInspection("2026-05-02", "ok", "Zeile-nach-Zeitraum");
101
+ await addInspection("2026-02-06", "ok", "Zeile-anderer-Asset", "Pumpe 7");
102
+
103
+ const { fileRefId } = await stack.http.writeOk<{ fileRefId: string }>(
104
+ "pdf-audit-export:write:export-audit",
105
+ { assetName: "Kessel 3", from: "2026-02-01", to: "2026-03-31" },
106
+ userA,
107
+ );
108
+
109
+ const res = await download(userA, fileRefId);
110
+ expect(res.status).toBe(200);
111
+ expect(res.headers.get("Content-Type")).toBe("application/pdf");
112
+ const proxy = await getDocumentProxy(new Uint8Array(await res.arrayBuffer()));
113
+ const { text } = await extractText(proxy, { mergePages: true });
114
+
115
+ expect(text).toContain("Prüfprotokoll Kessel 3");
116
+ expect(text).toContain("05.02.2026");
117
+ expect(text).toContain("Mangel");
118
+ expect(text).toContain("Zeile-im-Zeitraum-Eins");
119
+ expect(text).toContain("20.03.2026");
120
+ expect(text).toContain("Zeile-im-Zeitraum-Zwei");
121
+ expect(text).not.toContain("Zeile-vor-Zeitraum");
122
+ expect(text).not.toContain("Zeile-nach-Zeitraum");
123
+ expect(text).not.toContain("Zeile-anderer-Asset");
124
+ expect(text).toContain("Seite 1 von 1");
125
+ });
126
+
127
+ test("a user of another tenant gets 404 for the exported file", async () => {
128
+ await addInspection("2026-02-05", "ok", "Zeile-Tenant-A");
129
+ const { fileRefId } = await stack.http.writeOk<{ fileRefId: string }>(
130
+ "pdf-audit-export:write:export-audit",
131
+ { assetName: "Kessel 3", from: "2026-02-01", to: "2026-03-31" },
132
+ userA,
133
+ );
134
+
135
+ expect((await download(userB, fileRefId)).status).toBe(404);
136
+ });
137
+
138
+ test("a period without inspections still yields a PDF with the empty hint", async () => {
139
+ const { fileRefId } = await stack.http.writeOk<{ fileRefId: string }>(
140
+ "pdf-audit-export:write:export-audit",
141
+ { assetName: "Unbekannte Anlage", from: "2026-02-01", to: "2026-03-31" },
142
+ userA,
143
+ );
144
+
145
+ const res = await download(userA, fileRefId);
146
+ const proxy = await getDocumentProxy(new Uint8Array(await res.arrayBuffer()));
147
+ const { text } = await extractText(proxy, { mergePages: true });
148
+ expect(text).toContain("Keine Prüfungen im Zeitraum");
149
+ });
150
+ });
151
+ ```
152
+
153
+ ### src/feature.ts
154
+
155
+ ```ts
156
+ // PDF Audit Export Sample
157
+ //
158
+ // A write handler turns tenant data into a PDF and keeps it as a regular
159
+ // FileRef: renderTablePdf draws the table (pure JS, no browser), then
160
+ // createFileRefFromBytes stores the bytes through the same validation,
161
+ // storage layout and fileRef.created event as an upload.
162
+
163
+ import { renderTablePdf } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
164
+ import {
165
+ createDateField,
166
+ createEntity,
167
+ createEntityExecutor,
168
+ createSelectField,
169
+ createTextField,
170
+ defineFeature,
171
+ } from "@cosmicdrift/kumiko-framework/engine";
172
+ import { createFileRefFromBytes } from "@cosmicdrift/kumiko-framework/files";
173
+ import * as z from "zod";
174
+
175
+ export const INSPECTION_RESULTS = ["ok", "defect"] as const;
176
+
177
+ export const inspectionEntity = createEntity({
178
+ table: "read_sample_inspections",
179
+ fields: {
180
+ assetName: createTextField({ personal: false, reason: "is_business_data", required: true }),
181
+ inspectedOn: createDateField({ required: true }),
182
+ result: createSelectField({ options: INSPECTION_RESULTS, required: true }),
183
+ inspector: createTextField({ personal: "ref" }),
184
+ },
185
+ });
186
+
187
+ const RESULT_LABELS: Readonly<Record<(typeof INSPECTION_RESULTS)[number], string>> = {
188
+ ok: "In Ordnung",
189
+ defect: "Mangel",
190
+ };
191
+
192
+ // The renderer takes ready-formatted strings, so date formatting stays with the caller.
193
+ function formatGermanDate(isoDate: string): string {
194
+ const [year, month, day] = isoDate.split("-");
195
+ return `${day}.${month}.${year}`;
196
+ }
197
+
198
+ function toFileNameSegment(text: string): string {
199
+ return text.replaceAll(/[^A-Za-z0-9_-]+/g, "-");
200
+ }
201
+
202
+ const AUDIT_ROW_LIMIT = 200;
203
+
204
+ const access = { access: { roles: ["Admin", "User"] } } as const;
205
+
206
+ export const pdfAuditExportFeature = defineFeature("pdf-audit-export", (r) => {
207
+ r.entity("inspection", inspectionEntity);
208
+ const { executor: inspectionExecutor } = createEntityExecutor("inspection", inspectionEntity);
209
+
210
+ r.writeHandler(
211
+ "inspection:create",
212
+ z.object({
213
+ assetName: z.string().min(1),
214
+ inspectedOn: z.iso.date(),
215
+ result: z.enum(INSPECTION_RESULTS),
216
+ inspector: z.string().optional(),
217
+ }),
218
+ async (event, ctx) => inspectionExecutor.create(event.payload, event.user, ctx.db),
219
+ access,
220
+ );
221
+
222
+ r.writeHandler(
223
+ "export-audit",
224
+ z.object({ assetName: z.string().min(1), from: z.iso.date(), to: z.iso.date() }),
225
+ async (event, ctx) => {
226
+ const { assetName, from, to } = event.payload;
227
+ const inspections = await inspectionExecutor.list(
228
+ {
229
+ filters: [
230
+ { field: "assetName", op: "eq", value: assetName },
231
+ { field: "inspectedOn", op: "gte", value: from },
232
+ { field: "inspectedOn", op: "lte", value: to },
233
+ ],
234
+ sort: "inspectedOn",
235
+ sortDirection: "asc",
236
+ limit: AUDIT_ROW_LIMIT,
237
+ },
238
+ event.user,
239
+ ctx.db,
240
+ );
241
+
242
+ const { pdfBytes } = await renderTablePdf({
243
+ title: `Prüfprotokoll ${assetName}`,
244
+ meta: [
245
+ { label: "Zeitraum", value: `${formatGermanDate(from)} bis ${formatGermanDate(to)}` },
246
+ { label: "Prüfungen", value: String(inspections.rows.length) },
247
+ ],
248
+ columns: [{ header: "Datum" }, { header: "Ergebnis" }, { header: "Prüfer", weight: 2 }],
249
+ rows: inspections.rows.map((row) => [
250
+ formatGermanDate(String(row["inspectedOn"])),
251
+ RESULT_LABELS[z.enum(INSPECTION_RESULTS).parse(row["result"])],
252
+ String(row["inspector"] ?? ""),
253
+ ]),
254
+ emptyText: "Keine Prüfungen im Zeitraum",
255
+ footerText: `Prüfbericht ${assetName}`,
256
+ pageLabel: "Seite {page} von {pages}",
257
+ });
258
+
259
+ const created = await createFileRefFromBytes(ctx, {
260
+ fileName: `audit-${toFileNameSegment(assetName)}.pdf`,
261
+ mimeType: "application/pdf",
262
+ data: pdfBytes,
263
+ });
264
+ if (!created.isSuccess) return created;
265
+ return { isSuccess: true as const, data: { fileRefId: created.data.id } };
266
+ },
267
+ access,
268
+ );
269
+ });
270
+ ```