@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,233 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/search. Do not edit. -->
2
+
3
+ # search
4
+
5
+ Tenant-scoped Meilisearch. Because ILIKE is not search.
6
+
7
+ ## README
8
+
9
+ # Search
10
+
11
+ Tenant-scoped full-text search (Meilisearch / in-memory adapter).
12
+
13
+ ## What it shows
14
+
15
+ - `searchable` / `searchWeight` on fields
16
+ - Handler reads via `ctx.searchAdapter`
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/search
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Search Sample — Integration Test
37
+ // Proves: searchable fields indexed via system hook, search returns matches
38
+
39
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
40
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
41
+ import {
42
+ setupTestStack,
43
+ type TestStack,
44
+ TestUsers,
45
+ unsafeCreateEntityTable,
46
+ } from "@cosmicdrift/kumiko-framework/stack";
47
+ import { productEntity, productFeature } from "../feature";
48
+
49
+ let stack: TestStack;
50
+
51
+ const admin = TestUsers.admin;
52
+
53
+ beforeAll(async () => {
54
+ stack = await setupTestStack({ features: [productFeature] });
55
+ await unsafeCreateEntityTable(stack.db, productEntity);
56
+ await createEventsTable(stack.db);
57
+ });
58
+
59
+ afterAll(async () => {
60
+ await stack.cleanup();
61
+ });
62
+
63
+ beforeEach(async () => {
64
+ // Advance the dispatcher cursor past previous-test events so each test
65
+ // starts with a clean horizon — same pattern as realtime-sse sample.
66
+ await stack.eventDispatcher?.runOnce();
67
+ stack.events.reset();
68
+ });
69
+
70
+ describe("search indexing", () => {
71
+ test("created product is findable via search", async () => {
72
+ await stack.http.writeOk(
73
+ "shop:write:product:create",
74
+ {
75
+ name: "Wireless Mouse",
76
+ brand: "Logitech",
77
+ sku: "WM-001",
78
+ category: "peripherals",
79
+ },
80
+ admin,
81
+ );
82
+
83
+ // Since D.4 search indexing runs as an async EventConsumer — drain the
84
+ // dispatcher before asserting.
85
+ await stack.eventDispatcher?.runOnce();
86
+
87
+ // Search via SearchAdapter directly
88
+ const results = await stack.search.search("00000000-0000-4000-8000-000000000001", "wireless", {
89
+ filterType: "product",
90
+ });
91
+ expect(results.some((r) => r.entityType === "product")).toBe(true);
92
+ });
93
+
94
+ test("search via list handler returns matching rows", async () => {
95
+ await stack.http.writeOk(
96
+ "shop:write:product:create",
97
+ {
98
+ name: "Mechanical Keyboard",
99
+ brand: "Cherry",
100
+ sku: "MK-002",
101
+ },
102
+ admin,
103
+ );
104
+ await stack.eventDispatcher?.runOnce();
105
+
106
+ const list = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
107
+ "shop:query:product:list",
108
+ { search: "mechanical" },
109
+ admin,
110
+ );
111
+
112
+ expect(list.rows.some((r) => r["name"] === "Mechanical Keyboard")).toBe(true);
113
+ });
114
+
115
+ test("search by brand field", async () => {
116
+ await stack.http.writeOk(
117
+ "shop:write:product:create",
118
+ {
119
+ name: "Budget Mouse",
120
+ brand: "UniqueTestBrand",
121
+ sku: "BM-003",
122
+ },
123
+ admin,
124
+ );
125
+ await stack.eventDispatcher?.runOnce();
126
+
127
+ const results = await stack.search.search(
128
+ "00000000-0000-4000-8000-000000000001",
129
+ "UniqueTestBrand",
130
+ { filterType: "product" },
131
+ );
132
+ expect(results.length).toBeGreaterThan(0);
133
+ });
134
+
135
+ test("non-searchable field not indexed", async () => {
136
+ await stack.http.writeOk(
137
+ "shop:write:product:create",
138
+ {
139
+ name: "Hidden SKU Product",
140
+ sku: "SECRET-SKU-999",
141
+ category: "test",
142
+ },
143
+ admin,
144
+ );
145
+ await stack.eventDispatcher?.runOnce();
146
+
147
+ // SKU is not searchable, should not find via SKU search
148
+ const results = await stack.search.search(
149
+ "00000000-0000-4000-8000-000000000001",
150
+ "SECRET-SKU-999",
151
+ { filterType: "product" },
152
+ );
153
+ expect(results).toHaveLength(0);
154
+ });
155
+ });
156
+ ```
157
+
158
+ ### src/feature.ts
159
+
160
+ ```ts
161
+ // Search Sample
162
+ // Shows: searchable fields, searchWeight, search via InMemory SearchAdapter.
163
+ // Each handler builds its executor inline so `ctx.searchAdapter` is read fresh
164
+ // per call — tests swap the adapter between runs.
165
+
166
+ import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
167
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
168
+ import { z } from "zod";
169
+
170
+ export const productEntity = createEntity({
171
+ table: "read_sample_products",
172
+ fields: {
173
+ name: createTextField({
174
+ personal: false,
175
+ reason: "is_business_data",
176
+ required: true,
177
+ searchable: true,
178
+ }),
179
+ brand: createTextField({ personal: false, reason: "is_business_data", searchable: true }),
180
+ sku: createTextField({ personal: false, reason: "is_business_data", required: true }),
181
+ category: createTextField({ personal: false, reason: "is_business_data" }),
182
+ },
183
+ searchWeight: 10,
184
+ });
185
+
186
+ const productTable = buildEntityTable("product", productEntity);
187
+
188
+ export const productFeature = defineFeature("shop", (r) => {
189
+ r.entity("product", productEntity);
190
+
191
+ r.writeHandler(
192
+ "product:create",
193
+ z.object({
194
+ name: z.string().min(1),
195
+ brand: z.string().optional(),
196
+ sku: z.string().min(1),
197
+ category: z.string().optional(),
198
+ }),
199
+ async (event, ctx) => {
200
+ const crud = createEventStoreExecutor(productTable, productEntity, {
201
+ searchAdapter: ctx.searchAdapter,
202
+ entityName: "product",
203
+ });
204
+ return crud.create(event.payload, event.user, ctx.db);
205
+ },
206
+ { access: { roles: ["Admin"] } },
207
+ );
208
+
209
+ r.queryHandler(
210
+ "product:list",
211
+ z.object({
212
+ search: z.string().optional(),
213
+ limit: z.number().optional(),
214
+ }),
215
+ async (query, ctx) => {
216
+ const crud = createEventStoreExecutor(productTable, productEntity, {
217
+ searchAdapter: ctx.searchAdapter,
218
+ entityName: "product",
219
+ });
220
+ return crud.list(query.payload, query.user, ctx.db);
221
+ },
222
+ {
223
+ access: {
224
+ openToAll: {
225
+ reason:
226
+ "any signed-in user may search and list products in this recipe; creating " +
227
+ "a product still requires the Admin role above",
228
+ },
229
+ },
230
+ },
231
+ );
232
+ });
233
+ ```
@@ -0,0 +1,417 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/secrets-demo. Do not edit. -->
2
+
3
+ # secrets-demo
4
+
5
+ Tenant-owned secrets with envelope encryption, DEK cache, and KEK rotation.
6
+
7
+ ## README
8
+
9
+ # Secrets Demo
10
+
11
+ Tenant-owned secrets with envelope encryption, DEK cache, and KEK rotation.
12
+
13
+ ## What it shows
14
+
15
+ - `r.secret` + `ctx.secrets.get` (audited reads)
16
+ - Charge-style handler that never returns the plaintext key
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/secrets-demo
28
+ bun test
29
+ ```
30
+
31
+ ## Source files
32
+
33
+ ### src/__tests__/feature.integration.test.ts
34
+
35
+ ```ts
36
+ // Secrets Demo — End-to-End Proof
37
+ //
38
+ // What we're proving (all via real HTTP + BullMQ dispatch — no direct
39
+ // function calls into framework internals):
40
+ // 1. A TenantAdmin sets a secret over HTTP → DB holds only an envelope.
41
+ // 2. list handler returns a redacted preview, never plaintext.
42
+ // 3. Feature code (ctx.secrets.get) decrypts through the audited wrapper.
43
+ // Every read writes a tenantSecretRead event to the events-table.
44
+ // 4. The response-guard catches accidental Secret<> leaks: a handler
45
+ // that tries to return the branded value triggers a 500 at the
46
+ // dispatcher boundary (not a silent exfiltration).
47
+ // 5. KEK rotation works end-to-end via the core rotate job, triggered
48
+ // through BullMQ — app code writes zero rotation logic.
49
+
50
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
51
+ import { randomBytes } from "node:crypto";
52
+ import { createJobsFeature } from "@cosmicdrift/kumiko-bundled-features/jobs";
53
+ import {
54
+ createSecretsContext,
55
+ createSecretsFeature,
56
+ type StoredEnvelope,
57
+ TENANT_SECRET_READ_EVENT,
58
+ tenantSecretsTable,
59
+ } from "@cosmicdrift/kumiko-bundled-features/secrets";
60
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
61
+ import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
62
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
63
+ import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
64
+ import { createEnvMasterKeyProvider, createSecret } from "@cosmicdrift/kumiko-framework/secrets";
65
+ import {
66
+ createTestUser,
67
+ setupTestStack,
68
+ type TestStack,
69
+ unsafePushTables,
70
+ } from "@cosmicdrift/kumiko-framework/stack";
71
+ import {
72
+ createMutableMasterKeyProvider,
73
+ type MutableMasterKeyProvider,
74
+ waitFor,
75
+ } from "@cosmicdrift/kumiko-framework/testing";
76
+ import { z } from "zod";
77
+ import { createBillingFeature, STRIPE_API_KEY } from "../feature";
78
+
79
+ // Stable KEK bytes across provider rebuilds — mimics ops flipping env vars
80
+ // across a redeploy without rotating the actual key material.
81
+ const KEK_V1 = randomBytes(32);
82
+ const KEK_V2 = randomBytes(32);
83
+
84
+ function keyringEnv(current: number, withV2 = false): Record<string, string> {
85
+ const env: Record<string, string> = {
86
+ KUMIKO_SECRETS_MASTER_KEY_V1: KEK_V1.toString("base64"),
87
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: String(current),
88
+ };
89
+ if (withV2) env["KUMIKO_SECRETS_MASTER_KEY_V2"] = KEK_V2.toString("base64");
90
+ return env;
91
+ }
92
+
93
+ // Buggy handler for the response-guard test. Deliberately returns a Secret<>
94
+ // branded value in its data payload. The R6 compile-time guard now makes that a
95
+ // type error, so `as unknown as string` deliberately erodes the type — the
96
+ // `any`-hole R6 cannot see — to prove the RUNTIME guard is the backstop that
97
+ // still intercepts the leak before the response leaves the wire.
98
+ const leakyFeature = defineFeature("leaky", (r) => {
99
+ r.writeHandler(
100
+ defineWriteHandler({
101
+ name: "leak",
102
+ schema: z.object({}),
103
+ access: { roles: ["TenantAdmin"] },
104
+ handler: async () => ({
105
+ isSuccess: true,
106
+ data: { apiKey: createSecret("would-have-been-leaked") as unknown as string },
107
+ }),
108
+ }),
109
+ );
110
+ });
111
+
112
+ const tenantAdmin = createTestUser({
113
+ id: "00000000-0000-4000-8000-000000000010",
114
+ tenantId: "00000000-0000-4000-8000-000000000001",
115
+ roles: ["TenantAdmin"],
116
+ });
117
+
118
+ let stack: TestStack;
119
+ let jobRunner: JobRunner;
120
+ let providerRef: MutableMasterKeyProvider;
121
+
122
+ beforeAll(async () => {
123
+ const initialProvider = createEnvMasterKeyProvider({ env: keyringEnv(1) });
124
+ providerRef = createMutableMasterKeyProvider(initialProvider);
125
+
126
+ stack = await setupTestStack({
127
+ features: [createJobsFeature(), createSecretsFeature(), createBillingFeature(), leakyFeature],
128
+ // Typed masterKeyProvider option: lands in AppContext as a typed field,
129
+ // no Record<string, unknown>-via-extraContext. The rotation job reads
130
+ // it from ctx.masterKeyProvider directly.
131
+ masterKeyProvider: providerRef,
132
+ extraContext: ({ db, registry }) => ({
133
+ // SecretsContext sees the mutable ref — any replace() flips what
134
+ // set/get/rotate use on the NEXT call.
135
+ secrets: createSecretsContext({ db, masterKeyProvider: providerRef, registry }),
136
+ }),
137
+ });
138
+
139
+ await unsafePushTables(stack.db, {
140
+ tenant_secrets: tenantSecretsTable,
141
+ });
142
+ await createEventsTable(stack.db);
143
+
144
+ // BullMQ-backed job runner. Triggering rotate via dispatch() routes
145
+ // through the queue + worker just like production — not a direct call
146
+ // into the handler function.
147
+ const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`;
148
+ jobRunner = createJobRunner({
149
+ registry: stack.registry,
150
+ context: {
151
+ db: stack.db,
152
+ registry: stack.registry,
153
+ masterKeyProvider: providerRef,
154
+ },
155
+ redisUrl,
156
+ consumerLane: "worker",
157
+ queueNamePrefix: `kumiko-secrets-demo-${Date.now()}`,
158
+ });
159
+ await jobRunner.start();
160
+ });
161
+
162
+ afterAll(async () => {
163
+ await jobRunner.stop();
164
+ await stack.cleanup();
165
+ });
166
+
167
+ describe("1. at-rest representation", () => {
168
+ test("set stores envelope only — plaintext nowhere in the row", async () => {
169
+ await stack.http.writeOk(
170
+ "secrets:write:set",
171
+ { key: STRIPE_API_KEY.name, value: "sk_test_SuperSecretLivePlatz12345" },
172
+ tenantAdmin,
173
+ );
174
+
175
+ const [row] = await selectMany(stack.db, tenantSecretsTable, {
176
+ tenantId: tenantAdmin.tenantId,
177
+ key: STRIPE_API_KEY.name,
178
+ });
179
+ if (!row) throw new Error("row missing");
180
+
181
+ const env = row.envelope as StoredEnvelope;
182
+ expect(env.ciphertext).toBeTruthy();
183
+ expect(env.kekVersion).toBe(1);
184
+ expect(row.kekVersion).toBe(1);
185
+ expect(JSON.stringify(row)).not.toContain("sk_test_SuperSecretLivePlatz12345");
186
+ });
187
+ });
188
+
189
+ describe("2. list shows preview only", () => {
190
+ test("redactedPreview uses billing's domain-aware redact function", async () => {
191
+ const list = await stack.http.queryOk<
192
+ Array<{ key: string; redactedPreview: string | null; kekVersion: number }>
193
+ >("secrets:query:list", {}, tenantAdmin);
194
+
195
+ const stripe = list.find((r) => r.key === STRIPE_API_KEY.name);
196
+ expect(stripe).toBeDefined();
197
+ expect(stripe?.redactedPreview).toMatch(/^sk_test\.\.\..+$/);
198
+ expect(stripe?.redactedPreview).not.toContain("SuperSecretLive");
199
+ });
200
+ });
201
+
202
+ describe("3. feature code decrypts via audited path", () => {
203
+ test("billing:charge reads plaintext; audit row is appended", async () => {
204
+ // Post-ES: read-audit rides on the events-table as tenantSecretRead
205
+ // domain-events (one per get() call, fresh aggregate-id each). Filter
206
+ // by tenantId so parallel tests don't skew the count.
207
+ const before = await selectMany(stack.db, eventsTable, {
208
+ tenantId: tenantAdmin.tenantId,
209
+ type: TENANT_SECRET_READ_EVENT,
210
+ });
211
+
212
+ const result = await stack.http.writeOk<{
213
+ chargeId: string;
214
+ amount: number;
215
+ keyFingerprint: string;
216
+ }>("billing:write:charge", { amount: 4999, customerRef: "cust_42" }, tenantAdmin);
217
+
218
+ expect(result.keyFingerprint).toBe("sk_te...2345");
219
+ expect(result.chargeId).toContain("cust_42");
220
+ expect(JSON.stringify(result)).not.toContain("sk_test_SuperSecretLivePlatz12345");
221
+
222
+ const after = await selectMany(stack.db, eventsTable, {
223
+ tenantId: tenantAdmin.tenantId,
224
+ type: TENANT_SECRET_READ_EVENT,
225
+ });
226
+ expect(after.length).toBe(before.length + 1);
227
+
228
+ const newest = after[after.length - 1];
229
+ if (!newest) throw new Error("read-audit event missing");
230
+ const payload = newest.payload as {
231
+ key: string;
232
+ userId: string;
233
+ handlerName: string;
234
+ };
235
+ expect(payload.key).toBe(STRIPE_API_KEY.name);
236
+ expect(payload.userId).toBe(tenantAdmin.id);
237
+ expect(payload.handlerName).toBe("billing:write:charge");
238
+ });
239
+ });
240
+
241
+ describe("4. response guard via the real dispatcher", () => {
242
+ test("a handler that returns Secret<> is rejected at the dispatcher boundary", async () => {
243
+ // Full HTTP path: the leakyFeature's handler returns Secret<> in its
244
+ // data. The dispatcher's post-handler guard catches it and converts
245
+ // to a 500 with a "leaked" message — the client never sees the secret.
246
+ const res = await stack.http.raw(
247
+ "POST",
248
+ "/api/write",
249
+ { type: "leaky:write:leak", payload: {} },
250
+ { Authorization: `Bearer ${await stack.jwt.sign(tenantAdmin)}` },
251
+ );
252
+ expect(res.status).toBe(500);
253
+ const body = (await res.json()) as { error?: { message?: string } };
254
+ expect(body.error?.message).toMatch(/leaked/);
255
+ // Response must not echo the branded value — ever.
256
+ expect(JSON.stringify(body)).not.toContain("would-have-been-leaked");
257
+ });
258
+ });
259
+
260
+ describe("5. KEK rotation via the core job + real BullMQ dispatch", () => {
261
+ test("rotate job triggered over BullMQ migrates the V1 row onto V2", async () => {
262
+ // Sanity: row is still on V1 before we flip the ring.
263
+ const beforeRow = await fetchOne<{ kekVersion: number }>(stack.db, tenantSecretsTable, {
264
+ key: STRIPE_API_KEY.name,
265
+ });
266
+ expect(beforeRow?.kekVersion).toBe(1);
267
+
268
+ // Ops flipped CURRENT to V2 (and keyring has V1+V2). In production
269
+ // this is an env swap + redeploy; here we replace the inner provider
270
+ // of the mutable ref so every downstream caller — SecretsContext AND
271
+ // the rotate job — sees the new current.
272
+ providerRef.replace(createEnvMasterKeyProvider({ env: keyringEnv(2, /* withV2 */ true) }));
273
+
274
+ // Trigger the rotate job through the SAME path production uses:
275
+ // jobRunner.dispatch → BullMQ queue → worker → registered handler.
276
+ // Nothing calls rotateJob directly; the r.job wire has to be intact
277
+ // for this to produce the expected DB change.
278
+ await jobRunner.dispatch("secrets:job:rotate", { batchSize: 10 });
279
+
280
+ // Wait for the BullMQ worker to finish. waitFor retries with escalating
281
+ // delays (250ms → 1s → 3s) and throws the assertion error from the
282
+ // final attempt — so a real rotation failure surfaces as a clear
283
+ // "expected 2, got 1" instead of a "polling timed out" noise message.
284
+ await waitFor(async () => {
285
+ const row = await fetchOne<{ kekVersion: number }>(stack.db, tenantSecretsTable, {
286
+ key: STRIPE_API_KEY.name,
287
+ });
288
+ expect(row?.kekVersion).toBe(2);
289
+ });
290
+
291
+ // Ciphertext byte-identical — only the DEK wrapper changed. Proves
292
+ // rewrapDek's promise (cheap rotation on large tables).
293
+ const [full] = await selectMany(stack.db, tenantSecretsTable, { key: STRIPE_API_KEY.name });
294
+ if (!full) throw new Error("row missing");
295
+ expect((full.envelope as StoredEnvelope).kekVersion).toBe(2);
296
+ });
297
+
298
+ test("subsequent ctx.secrets.get after rotation still returns the original plaintext", async () => {
299
+ // Goes through the whole real stack: HTTP → dispatcher → billing
300
+ // handler → ctx.secrets.get → cached DEK-unwrap with V2 → decrypt.
301
+ // If rotation broke anything, fingerprint would differ or the call
302
+ // would throw.
303
+ const result = await stack.http.writeOk<{ keyFingerprint: string }>(
304
+ "billing:write:charge",
305
+ { amount: 100, customerRef: "post_rotation_check" },
306
+ tenantAdmin,
307
+ );
308
+ expect(result.keyFingerprint).toBe("sk_te...2345");
309
+ });
310
+
311
+ test("re-dispatching the job is a no-op once all rows are on current", async () => {
312
+ await jobRunner.dispatch("secrets:job:rotate", { batchSize: 10 });
313
+ // Row state already at V2 from the previous test; waitFor only gates
314
+ // on the assertion succeeding, so if the job worker stayed idle (no
315
+ // work to do) this returns on the first check.
316
+ await waitFor(async () => {
317
+ const row = await fetchOne<{ kekVersion: number }>(stack.db, tenantSecretsTable, {
318
+ key: STRIPE_API_KEY.name,
319
+ });
320
+ expect(row?.kekVersion).toBe(2);
321
+ });
322
+ });
323
+ });
324
+ ```
325
+
326
+ ### src/feature.ts
327
+
328
+ ```ts
329
+ // A miniature "billing" feature that reads a Stripe API key from ctx.secrets
330
+ // and simulates a charge. The point is to show how feature code uses the
331
+ // plaintext secret without ever exposing it to clients — the charge endpoint
332
+ // returns { chargeId } but never the apiKey.
333
+
334
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
335
+ import {
336
+ defineFeature,
337
+ defineWriteHandler,
338
+ type FeatureDefinition,
339
+ type SecretKeyHandle,
340
+ } from "@cosmicdrift/kumiko-framework/engine";
341
+ import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
342
+ import { z } from "zod";
343
+
344
+ // Typed handle exported by the feature's setup. Bound to what r.secret
345
+ // returns below — downstream code imports STRIPE_API_KEY.name for the
346
+ // qualified string, but never has to retype "billing:secret:stripe-api-key".
347
+ // Renaming the r.secret call updates every call-site through the import graph.
348
+ export let STRIPE_API_KEY: SecretKeyHandle;
349
+
350
+ const chargeWrite = defineWriteHandler({
351
+ name: "charge",
352
+ schema: z.object({
353
+ amount: z.number().positive(),
354
+ customerRef: z.string().min(1),
355
+ }),
356
+ access: { roles: ["TenantAdmin", "User"] },
357
+ handler: async (event, ctx) => {
358
+ const secrets = requireSecretsContext(ctx, "billing:write:charge");
359
+ // requireSecretsContext returned a wrapper that auto-fills the audit
360
+ // context (userId + "billing:write:charge") on every .get — the read
361
+ // lands in tenant_secret_reads without the handler having to remember.
362
+ const branded = await secrets.get(event.user.tenantId, STRIPE_API_KEY);
363
+ if (!branded) {
364
+ return failUnprocessable("stripe_key_missing", {
365
+ hint: `TenantAdmin must first set ${STRIPE_API_KEY} via /api/write/secrets:write:set`,
366
+ });
367
+ }
368
+ // reveal() is the explicit un-brand. Everything that comes out stays
369
+ // plaintext string — we're now responsible for not letting it escape
370
+ // into the response. Fingerprint is the deliberate, audited leak.
371
+ const apiKey = branded.reveal();
372
+
373
+ // Fake the external call — in real code this would be
374
+ // stripe = new Stripe(apiKey)
375
+ // await stripe.paymentIntents.create({...})
376
+ // The key is consumed in-memory only; the response to the client never
377
+ // mentions it.
378
+ const chargeId = `ch_${Date.now()}_${event.payload.customerRef}`;
379
+ const keyFingerprint = `${apiKey.slice(0, 5)}...${apiKey.slice(-4)}`;
380
+
381
+ return {
382
+ isSuccess: true,
383
+ data: {
384
+ chargeId,
385
+ amount: event.payload.amount,
386
+ // Include a harmless fingerprint so the test can assert the charge
387
+ // used the correct key without revealing the full plaintext.
388
+ keyFingerprint,
389
+ },
390
+ };
391
+ },
392
+ });
393
+
394
+ export function createBillingFeature(): FeatureDefinition {
395
+ return defineFeature("billing", (r) => {
396
+ // Typed declaration of the secret this feature needs. Admin UIs can
397
+ // list known secrets (label + hint) so tenant-admin doesn't have to
398
+ // guess the key name; rotation / audit work by qualified name. The
399
+ // handle's .name is the QN the registrar built for us.
400
+ STRIPE_API_KEY = r.secret("stripe.apiKey", {
401
+ label: { de: "Stripe API-Schlüssel", en: "Stripe API Key" },
402
+ hint: {
403
+ de: "Aus dem Stripe-Dashboard unter Developers → API Keys.",
404
+ en: "From the Stripe dashboard under Developers → API Keys.",
405
+ },
406
+ // Domain-aware redaction: Stripe keys have a documented prefix+last4
407
+ // shape, so show that instead of the generic first-3 policy.
408
+ redact: (plaintext) => {
409
+ if (plaintext.length < 10) return "•".repeat(plaintext.length);
410
+ return `${plaintext.slice(0, 7)}...${plaintext.slice(-4)}`;
411
+ },
412
+ scope: "tenant",
413
+ });
414
+ r.writeHandler(chargeWrite);
415
+ });
416
+ }
417
+ ```