@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,433 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/files-post-processing. Do not edit. -->
2
+
3
+ # files-post-processing
4
+
5
+ The three ways to generate an image variant: declarative field variants, imperative ctx.derivatives.variant() with region-blur, and the public route behind a default-deny predicate.
6
+
7
+ ## README
8
+
9
+ # Files Post Processing
10
+
11
+ File upload plus post-processing hooks.
12
+
13
+ ## What it shows
14
+
15
+ - Declarative `variants` on `createImageField` (file-derivatives)
16
+ - Imperative `ctx.derivatives.variant()` path and the public variant route (`EXT_DERIVATIVE_PUBLIC_PREDICATE`)
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/files-post-processing
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Files Post-Processing Sample — Integration Test
37
+ //
38
+ // Proves each of the three variant entry points end-to-end, through real
39
+ // HTTP calls and a real sharp render (no identity-transform, no mocks):
40
+ //
41
+ // 1. declarative — GET /api/files/:id/variant/:name resolves a field's
42
+ // declared variant.
43
+ // 2. imperative — the `set-cover` write-handler calls
44
+ // ctx.derivatives.variant() directly, with a region-blur.
45
+ // 3. public route — GET /media/:fileRefId/:variant, default-deny until a
46
+ // photo is explicitly published.
47
+
48
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
49
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
50
+ import { derivativesSharpFeature } from "@cosmicdrift/kumiko-bundled-features/derivatives-sharp";
51
+ import { createFileDerivativesFeature } from "@cosmicdrift/kumiko-bundled-features/file-derivatives";
52
+ import { fileFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/file-foundation";
53
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
54
+ import { entityEventName } from "@cosmicdrift/kumiko-framework/db";
55
+ import {
56
+ createFilesFeature,
57
+ createInMemoryFileProvider,
58
+ type InMemoryFileProvider,
59
+ } from "@cosmicdrift/kumiko-framework/files";
60
+ import {
61
+ createTestUser,
62
+ setupTestStack,
63
+ type TestStack,
64
+ testTenantId,
65
+ unsafeCreateEntityTable,
66
+ } from "@cosmicdrift/kumiko-framework/stack";
67
+ import {
68
+ buildMultipartBody,
69
+ patchFileInstanceofForBunTest,
70
+ } from "@cosmicdrift/kumiko-framework/testing";
71
+ import sharp from "sharp";
72
+ import { coverOrder, filesPostProcessingFeature, photoEntity, publishedPhotoIds } from "../feature";
73
+
74
+ const HOST = "photos.example.com";
75
+ const HOST_B = "photos-b.example.com";
76
+ const FILE_REF_CREATED = entityEventName("fileRef", "created");
77
+
78
+ let stack: TestStack;
79
+ let provider: InMemoryFileProvider;
80
+
81
+ const tenantId = testTenantId(1);
82
+ const tenantIdB = testTenantId(2);
83
+ const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
84
+ const userB = createTestUser({ id: 2, tenantId: tenantIdB, roles: ["Admin"] });
85
+
86
+ async function jpegBytes(width: number, height: number): Promise<Uint8Array> {
87
+ return sharp({
88
+ create: { width, height, channels: 3, background: { r: 90, g: 140, b: 200 } },
89
+ })
90
+ .jpeg()
91
+ .toBuffer();
92
+ }
93
+
94
+ beforeAll(async () => {
95
+ patchFileInstanceofForBunTest();
96
+ provider = createInMemoryFileProvider();
97
+ stack = await setupTestStack({
98
+ features: [
99
+ createConfigFeature(),
100
+ fileFoundationFeature,
101
+ createFilesFeature(),
102
+ createFileDerivativesFeature({
103
+ resolveApexTenant: (host) => {
104
+ if (host === HOST) return tenantId;
105
+ if (host === HOST_B) return tenantIdB;
106
+ return null;
107
+ },
108
+ }),
109
+ derivativesSharpFeature,
110
+ filesPostProcessingFeature,
111
+ ],
112
+ files: { storageProvider: provider },
113
+ });
114
+ await unsafeCreateEntityTable(stack.db, photoEntity, "photo");
115
+ });
116
+
117
+ async function createPhoto(asUser: typeof user = user): Promise<string> {
118
+ const created = await stack.http.writeOk<{ id: string }>(
119
+ "files-post-processing:write:photo:create",
120
+ {},
121
+ asUser,
122
+ );
123
+ return created.id;
124
+ }
125
+
126
+ afterAll(async () => {
127
+ await stack.cleanup();
128
+ });
129
+
130
+ beforeEach(() => {
131
+ provider.clear();
132
+ publishedPhotoIds.clear();
133
+ coverOrder.length = 0;
134
+ });
135
+
136
+ async function uploadPhoto(
137
+ attach: { entityType: string; entityId: string; fieldName: string } | null,
138
+ asUser: typeof user = user,
139
+ ): Promise<string> {
140
+ const token = await stack.jwt.sign(asUser);
141
+ const bytes = await jpegBytes(400, 300);
142
+ const fd = new FormData();
143
+ fd.append("file", new File([Buffer.from(bytes)], "photo.jpg", { type: "image/jpeg" }));
144
+ if (attach) {
145
+ fd.append("entityType", attach.entityType);
146
+ fd.append("entityId", attach.entityId);
147
+ fd.append("fieldName", attach.fieldName);
148
+ }
149
+ const { body, contentType } = await buildMultipartBody(fd);
150
+ const res = await stack.app.request("/api/files", {
151
+ method: "POST",
152
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
153
+ body,
154
+ });
155
+ expect(res.status).toBe(201);
156
+ const json = (await res.json()) as { id: string };
157
+ return json.id;
158
+ }
159
+
160
+ describe("entry point 1 — declarative field variant", () => {
161
+ test("GET /api/files/:id/variant/:name renders a real resized webp", async () => {
162
+ const photoId = await createPhoto();
163
+ const fileId = await uploadPhoto({
164
+ entityType: "photo",
165
+ entityId: photoId,
166
+ fieldName: "original",
167
+ });
168
+ const token = await stack.jwt.sign(user);
169
+
170
+ const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
171
+ headers: { Authorization: `Bearer ${token}` },
172
+ });
173
+
174
+ expect(res.status).toBe(200);
175
+ expect(res.headers.get("Content-Type")).toBe("image/webp");
176
+ const meta = await sharp(Buffer.from(await res.arrayBuffer())).metadata();
177
+ expect(meta.format).toBe("webp");
178
+ expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(200);
179
+ });
180
+ });
181
+
182
+ describe("entry point 2 — imperative ctx.derivatives.variant() with region-blur", () => {
183
+ test("set-cover derives a variant with no field declaration involved, and tracks order", async () => {
184
+ const fileId = await uploadPhoto(null);
185
+
186
+ const result = await stack.http.writeOk<{ fileRefId: string; storageKey: string }>(
187
+ "files-post-processing:write:set-cover",
188
+ { fileRefId: fileId, blurRegion: { x: 0, y: 0, width: 50, height: 50 } },
189
+ user,
190
+ );
191
+
192
+ expect(coverOrder).toEqual([fileId]);
193
+ const bytes = await provider.read(result.storageKey);
194
+ const meta = await sharp(Buffer.from(bytes)).metadata();
195
+ expect(meta.format).toBe("webp");
196
+ expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(800);
197
+ });
198
+ });
199
+
200
+ describe("entry point 3 — public route, default-deny", () => {
201
+ test("unpublished photo → 404", async () => {
202
+ const photoId = await createPhoto();
203
+ const fileId = await uploadPhoto({
204
+ entityType: "photo",
205
+ entityId: photoId,
206
+ fieldName: "original",
207
+ });
208
+
209
+ const res = await stack.app.request(`http://${HOST}/media/${fileId}/thumb`);
210
+
211
+ expect(res.status).toBe(404);
212
+ });
213
+
214
+ test("photo published via the write-handler → 200 with rendered bytes", async () => {
215
+ const photoId = await createPhoto();
216
+ const fileId = await uploadPhoto({
217
+ entityType: "photo",
218
+ entityId: photoId,
219
+ fieldName: "original",
220
+ });
221
+ await stack.http.writeOk("files-post-processing:write:publish", { entityId: photoId }, user);
222
+
223
+ const res = await stack.app.request(`http://${HOST}/media/${fileId}/thumb`);
224
+
225
+ expect(res.status).toBe(200);
226
+ expect(res.headers.get("Content-Type")).toBe("image/webp");
227
+ });
228
+
229
+ test("publishing tenant A's photo does not publish tenant B's photo (#1983/1)", async () => {
230
+ const photoIdA = await createPhoto(user);
231
+ const photoIdB = await createPhoto(userB);
232
+ const fileIdA = await uploadPhoto(
233
+ { entityType: "photo", entityId: photoIdA, fieldName: "original" },
234
+ user,
235
+ );
236
+ const fileIdB = await uploadPhoto(
237
+ { entityType: "photo", entityId: photoIdB, fieldName: "original" },
238
+ userB,
239
+ );
240
+
241
+ // publish accepts any entityId: tenant A names B's photo too, which must not publish it.
242
+ await stack.http.writeOk("files-post-processing:write:publish", { entityId: photoIdB }, user);
243
+ await stack.http.writeOk("files-post-processing:write:publish", { entityId: photoIdA }, user);
244
+
245
+ const resA = await stack.app.request(`http://${HOST}/media/${fileIdA}/thumb`);
246
+ const resB = await stack.app.request(`http://${HOST_B}/media/${fileIdB}/thumb`);
247
+
248
+ expect(resA.status).toBe(200);
249
+ expect(resB.status).toBe(404);
250
+ });
251
+ });
252
+
253
+ describe("event emission contract", () => {
254
+ test("fileRef.created event payload carries storageKey, not the binary", async () => {
255
+ const photoId = await createPhoto();
256
+ const fileId = await uploadPhoto({
257
+ entityType: "photo",
258
+ entityId: photoId,
259
+ fieldName: "original",
260
+ });
261
+
262
+ // Inspect the raw event row. The derivatives path already proves the
263
+ // payload's pointer-shape by reading through it, but this test guards
264
+ // the contract directly: no one accidentally adds a binary field later
265
+ // and explodes the events table.
266
+ const rows = await asRawClient(stack.db).unsafe(
267
+ `SELECT payload FROM kumiko_events WHERE aggregate_id = $1 AND type = $2`,
268
+ [fileId, FILE_REF_CREATED],
269
+ );
270
+ expect(rows.length).toBe(1);
271
+ const row = rows[0] as { payload: Record<string, unknown> };
272
+ const payload = row.payload;
273
+ expect(typeof payload["storageKey"]).toBe("string");
274
+ expect(payload["mimeType"]).toBe("image/jpeg");
275
+ expect(payload["data"]).toBeUndefined();
276
+ expect(payload["binary"]).toBeUndefined();
277
+ expect(payload["bytes"]).toBeUndefined();
278
+ });
279
+ });
280
+ ```
281
+
282
+ ### src/feature.ts
283
+
284
+ ```ts
285
+ // Files Post-Processing Sample
286
+ //
287
+ // The three ways to get an image variant out of a FileRef — see
288
+ // docs/reference/core-files.md for the full write-up of the contract and
289
+ // security model behind each.
290
+ //
291
+ // 1. Declarative — `original`'s `variants` map on the `photo` entity. The
292
+ // normal case (profile picture, hero image, floor plan): the framework
293
+ // resolves + caches the variant behind the auth-gated
294
+ // `GET /api/files/:id/variant/:name` route, no handler code needed.
295
+ // 2. Imperative — the `set-cover` write-handler calls
296
+ // `ctx.derivatives.variant()` directly. This is for apps that track
297
+ // images as their own records (ordering, a chosen cover image) and for
298
+ // region-blur, whose coordinates are runtime data that can't live in a
299
+ // static field declaration.
300
+ // 3. The public route with a predicate — the part people get wrong. Only
301
+ // entityIds explicitly marked published resolve; everything else 404s,
302
+ // indistinguishable from "doesn't exist".
303
+
304
+ import type { DerivativePublicPredicateArgs } from "@cosmicdrift/kumiko-bundled-features/file-derivatives";
305
+ import {
306
+ createEntity,
307
+ createImageField,
308
+ defineEntityCreateHandler,
309
+ defineEntityDetailHandler,
310
+ defineEntityUpdateHandler,
311
+ defineFeature,
312
+ EXT_DERIVATIVE_PUBLIC_PREDICATE,
313
+ } from "@cosmicdrift/kumiko-framework/engine";
314
+ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
315
+ import { z } from "zod";
316
+
317
+ // The `variants` map IS the whitelist the auth-gated variant route resolves
318
+ // against — no separate registration step. Named `thumb`/`hero` on purpose:
319
+ // the public route's fixed presets (thumb/card/hero/full, entry point 3)
320
+ // share the name `thumb` but are a completely separate spec — same label,
321
+ // different whitelist, different actual pixels.
322
+ export const photoEntity = createEntity({
323
+ table: "files_post_processing_photos",
324
+ fields: {
325
+ original: createImageField({
326
+ variants: {
327
+ thumb: { maxEdge: 200, fit: "cover", format: "webp" },
328
+ hero: { maxEdge: 1200, fit: "inside", format: "webp" },
329
+ },
330
+ }),
331
+ },
332
+ });
333
+
334
+ // A real app would check a `published` column via the entity's read model.
335
+ // This recipe tracks it in memory to stay a single file — the predicate
336
+ // below is what matters, not where the flag is stored. Keyed by
337
+ // `${tenantId}:${entityId}`, not entityId alone — two tenants can each have
338
+ // their own entityId "p3", and an entityId-only key would leak tenant A's
339
+ // publish state onto tenant B's photo.
340
+ export const publishedPhotoIds = new Set<string>();
341
+
342
+ // Exported so the integration test can assert the imperative path ran.
343
+ export const coverOrder: string[] = [];
344
+
345
+ export const filesPostProcessingFeature = defineFeature("files-post-processing", (r) => {
346
+ r.requires("file-derivatives");
347
+ r.entity("photo", photoEntity);
348
+
349
+ // Uploading a file to a photo's field needs the same rights as saving the
350
+ // photo, so the photo must exist and the uploader needs update access.
351
+ const photoAccess = { access: { roles: ["Admin"] } } as const;
352
+ r.writeHandler(defineEntityCreateHandler("photo", photoEntity, photoAccess));
353
+ r.writeHandler(defineEntityUpdateHandler("photo", photoEntity, photoAccess));
354
+ r.queryHandler(defineEntityDetailHandler("photo", photoEntity, photoAccess));
355
+
356
+ // Entry point 2 — imperative. A real app would gate this behind real
357
+ // roles; `openToAll` keeps the recipe's HTTP wiring to one line.
358
+ r.writeHandler(
359
+ "set-cover",
360
+ z.object({
361
+ fileRefId: z.string(),
362
+ // Relative to the ORIGINAL image's pixels, clamped before resize —
363
+ // runtime data (e.g. a detected face box), which is exactly why this
364
+ // can only be expressed imperatively, not in a static field def.
365
+ blurRegion: z
366
+ .object({
367
+ x: z.number().nonnegative(),
368
+ y: z.number().nonnegative(),
369
+ width: z.number().positive(),
370
+ height: z.number().positive(),
371
+ })
372
+ .optional(),
373
+ }),
374
+ async (event, ctx) => {
375
+ if (!ctx.derivatives) {
376
+ return writeFailure(
377
+ new InternalError({ message: "ctx.derivatives missing — is file-derivatives mounted?" }),
378
+ );
379
+ }
380
+ const { fileRefId, blurRegion } = event.payload;
381
+ const result = await ctx.derivatives.variant(
382
+ fileRefId,
383
+ {
384
+ maxEdge: 800,
385
+ fit: "cover",
386
+ format: "webp",
387
+ ...(blurRegion ? { blurRegions: [blurRegion] } : {}),
388
+ },
389
+ "cover",
390
+ );
391
+ coverOrder.push(fileRefId);
392
+ return { isSuccess: true as const, data: { fileRefId, storageKey: result.storageKey } };
393
+ },
394
+ {
395
+ access: {
396
+ openToAll: {
397
+ reason:
398
+ "demo recipe: any signed-in user may generate a cover-image derivative for " +
399
+ "a file; there is no per-user file ownership check in this sample",
400
+ },
401
+ },
402
+ },
403
+ );
404
+
405
+ // A real app would gate this behind real roles; `openToAll` keeps the
406
+ // recipe's HTTP wiring to one line.
407
+ r.writeHandler(
408
+ "publish",
409
+ z.object({ entityId: z.string() }),
410
+ async (event) => {
411
+ publishedPhotoIds.add(`${event.user.tenantId}:${event.payload.entityId}`);
412
+ return { isSuccess: true as const, data: { entityId: event.payload.entityId } };
413
+ },
414
+ {
415
+ access: {
416
+ openToAll: {
417
+ reason:
418
+ "demo recipe: any signed-in user may mark a photo entity published; a real " +
419
+ "app would replace this with a role check as noted above",
420
+ },
421
+ },
422
+ },
423
+ );
424
+
425
+ // Entry point 3 — default-deny. No registration here means every entityId
426
+ // 404s; a registration that returns false does the same. Only an explicit
427
+ // `true` serves bytes.
428
+ r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "photo", {
429
+ isPublic: ({ entityId, tenantId }: DerivativePublicPredicateArgs) =>
430
+ publishedPhotoIds.has(`${tenantId}:${entityId}`),
431
+ });
432
+ });
433
+ ```
@@ -0,0 +1,166 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/i18n. Do not edit. -->
2
+
3
+ # i18n
4
+
5
+ Feature-scoped translations. 'Hallo' and 'Hello' in the same app.
6
+
7
+ ## README
8
+
9
+ # i18n
10
+
11
+ Feature-scoped translations — 'Hallo' and 'Hello' in the same app.
12
+
13
+ ## What it shows
14
+
15
+ - `r.translations()` for multi-language feature keys
16
+ - Plurals
17
+
18
+ ## Plurals
19
+
20
+ A translation value can be a CLDR plural-forms object instead of a plain
21
+ string. `other` is required; the rest (`zero`/`one`/`two`/`few`/`many`) only
22
+ exist where the locale's grammar needs them:
23
+
24
+ ```ts illustration
25
+ "greeting.unread_count": {
26
+ de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
27
+ en: { one: "{count} unread message", other: "{count} unread messages" },
28
+ },
29
+ ```
30
+
31
+ `t(key, locale, { count })` picks the CLDR category for `count` via
32
+ `Intl.PluralRules(locale)`, then interpolates `{count}` (and any other
33
+ `{name}` placeholder) into the chosen form.
34
+
35
+ ## Source
36
+
37
+ Feature entry point: `src/feature.ts`.
38
+
39
+ ## Tests
40
+
41
+ ```bash
42
+ cd samples/recipes/i18n
43
+ bun test
44
+ ```
45
+
46
+ ## Source files
47
+
48
+ ### src/__tests__/feature.test.ts
49
+
50
+ ```ts
51
+ // i18n Sample — Unit Test (no DB needed)
52
+ // Proves: translations registered, locale resolution, fallback to default
53
+ // Note: keys are prefixed with feature name → "featureName:key"
54
+
55
+ import { describe, expect, test } from "bun:test";
56
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
57
+ import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
58
+ import { errorFeature, greetingFeature } from "../feature";
59
+
60
+ const registry = createRegistry([greetingFeature, errorFeature]);
61
+ const i18n = createI18n(registry, { defaultLocale: "de" });
62
+
63
+ describe("translation lookup", () => {
64
+ test("returns translation for default locale", () => {
65
+ expect(i18n.t("greeting:greeting.welcome")).toBe("Willkommen");
66
+ expect(i18n.t("greeting:greeting.goodbye")).toBe("Auf Wiedersehen");
67
+ });
68
+
69
+ test("returns translation for specific locale", () => {
70
+ expect(i18n.t("greeting:greeting.welcome", "en")).toBe("Welcome");
71
+ expect(i18n.t("greeting:greeting.welcome", "fr")).toBe("Bienvenue");
72
+ });
73
+
74
+ test("falls back to default locale for unknown locale", () => {
75
+ expect(i18n.t("greeting:greeting.welcome", "ja")).toBe("Willkommen");
76
+ });
77
+
78
+ test("returns key itself for unknown translation key", () => {
79
+ expect(i18n.t("nonexistent.key")).toBe("nonexistent.key");
80
+ });
81
+ });
82
+
83
+ describe("plural forms", () => {
84
+ test("selects the CLDR category for the count and interpolates {count}", () => {
85
+ expect(i18n.t("greeting:greeting.unread_count", "en", { count: 1 })).toBe("1 unread message");
86
+ expect(i18n.t("greeting:greeting.unread_count", "en", { count: 5 })).toBe("5 unread messages");
87
+ expect(i18n.t("greeting:greeting.unread_count", "de", { count: 1 })).toBe(
88
+ "1 ungelesene Nachricht",
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("multiple features merge translations", () => {
94
+ test("keys from both features available", () => {
95
+ expect(i18n.t("greeting:greeting.welcome")).toBe("Willkommen");
96
+ expect(i18n.t("errors:errors.not_found")).toBe("Nicht gefunden");
97
+ });
98
+
99
+ test("getAllKeys returns all registered keys", () => {
100
+ const keys = i18n.getAllKeys();
101
+ expect(keys).toContain("greeting:greeting.welcome");
102
+ expect(keys).toContain("greeting:greeting.goodbye");
103
+ expect(keys).toContain("greeting:greeting.hello_name");
104
+ expect(keys).toContain("errors:errors.not_found");
105
+ expect(keys).toContain("errors:errors.access_denied");
106
+ expect(keys).toHaveLength(6);
107
+ });
108
+ });
109
+ ```
110
+
111
+ ### src/feature.ts
112
+
113
+ ```ts
114
+ // i18n Sample
115
+ // Shows: r.translations() for multi-language feature keys, and plural forms
116
+ // (CLDR categories) for a count-dependent key
117
+
118
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
119
+
120
+ export const greetingFeature = defineFeature("greeting", (r) => {
121
+ r.translations({
122
+ keys: {
123
+ "greeting.welcome": {
124
+ de: "Willkommen",
125
+ en: "Welcome",
126
+ fr: "Bienvenue",
127
+ },
128
+ "greeting.goodbye": {
129
+ de: "Auf Wiedersehen",
130
+ en: "Goodbye",
131
+ fr: "Au revoir",
132
+ },
133
+ "greeting.hello_name": {
134
+ de: "Hallo, {name}!",
135
+ en: "Hello, {name}!",
136
+ fr: "Bonjour, {name}!",
137
+ },
138
+ "greeting.unread_count": {
139
+ de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
140
+ en: { one: "{count} unread message", other: "{count} unread messages" },
141
+ pl: {
142
+ one: "{count} nieprzeczytana wiadomość",
143
+ few: "{count} nieprzeczytane wiadomości",
144
+ many: "{count} nieprzeczytanych wiadomości",
145
+ other: "{count} nieprzeczytanej wiadomości",
146
+ },
147
+ },
148
+ },
149
+ });
150
+ });
151
+
152
+ export const errorFeature = defineFeature("errors", (r) => {
153
+ r.translations({
154
+ keys: {
155
+ "errors.not_found": {
156
+ de: "Nicht gefunden",
157
+ en: "Not found",
158
+ },
159
+ "errors.access_denied": {
160
+ de: "Zugriff verweigert",
161
+ en: "Access denied",
162
+ },
163
+ },
164
+ });
165
+ });
166
+ ```