@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,405 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/encrypted-tenant-config. Do not edit. -->
2
+
3
+ # encrypted-tenant-config
4
+
5
+ Per-tenant secrets via config-key encryption — Stripe-API-key pattern.
6
+
7
+ ## README
8
+
9
+ # Recipe: Encrypted per-tenant config
10
+
11
+ **What this shows:** how a SaaS customer stores their own API key
12
+ (Stripe, Slack webhook, SMTP password …) in their settings, without
13
+ the platform operator being able to read the plaintext from the DB.
14
+
15
+ ## Pattern
16
+
17
+ ```ts illustration
18
+ import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
19
+
20
+ createTenantConfig("text", {
21
+ encrypted: true, // ← ciphertext in the DB
22
+ write: access.admin, // ← the tenant admin writes their own
23
+ read: access.admin, // ← nobody else sees it
24
+ mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav
25
+ });
26
+ ```
27
+
28
+ The config resolver decrypts on the `ctx.config(handle)` call using
29
+ the `EncryptionProvider` from `extraContext.configEncryption`. Anyone
30
+ without the master key (= the `KUMIKO_SECRETS_MASTER_KEY_V<n>` env) only sees
31
+ base64 AES ciphertext in the DB.
32
+
33
+ The `mask` entry is all the UI needs: `buildConfigFeatureSchema` derives
34
+ the `configEdit` screen (pre-filled from `config:query:values`, where the
35
+ key comes back as `••••••`) **and** its Settings-Hub nav entry from the
36
+ registered key — no hand-written `r.screen`/`r.nav`. `mask.title` is the
37
+ i18n key of the field label, `mask.order` its position.
38
+
39
+ ## Use cases
40
+
41
+ - **Per-tenant API keys** — Customer A uses THEIR Stripe account,
42
+ Customer B uses THEIRS. The platform operator never makes Stripe
43
+ calls on behalf of customers.
44
+ - **Webhook secrets** — per-tenant Slack/Discord incoming webhook URL.
45
+ - **SMTP credentials** — per-tenant mail server (see
46
+ `samples/showcases/publicstatus` Phase 2).
47
+
48
+ ## Vs. `r.secret` / `samples/recipes/secrets-demo`
49
+
50
+ | Pattern | Scope | Use case |
51
+ |---|---|---|
52
+ | **`r.secret`** (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers). |
53
+ | **`encrypted: true` config key** (this recipe) | Per-tenant | Customer-owned secrets. Customer sets, rotates, and never sees another customer's. Same envelope encryption + KEK keyring under the hood. |
54
+
55
+ Combinable: the platform uses `r.secret` for its internal secrets,
56
+ and in parallel customers have their tenant-owned `encrypted: true`
57
+ config keys.
58
+
59
+ ## Boot wiring
60
+
61
+ None. `runProdApp` / `runDevApp` wire the envelope cipher automatically as
62
+ soon as a master key is present in the environment:
63
+
64
+ ```bash illustration
65
+ KUMIKO_SECRETS_MASTER_KEY_V1=$(openssl rand -base64 32)
66
+ ```
67
+
68
+ That single key drives `encrypted: true` config keys, `r.secret`, and
69
+ encrypted entity fields — one keyring, one rotation story. Only tests or
70
+ custom boots pass their own cipher:
71
+
72
+ ```ts illustration
73
+ import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
74
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
75
+
76
+ const cipher = createTestEnvelopeCipher();
77
+ const configResolver = createConfigResolver({ cipher });
78
+ // extraContext: { configResolver, configEncryption: cipher, ... }
79
+ ```
80
+
81
+ ## Flow
82
+
83
+ 1. Tenant admin sets `stripe-api-key` via `config:write:set` (scope tenant).
84
+ 2. DB row holds a JSON envelope (AES-GCM ciphertext + wrapped DEK + `kekVersion`) — backups and `SELECT` leak nothing.
85
+ 3. `config:query:values` returns `••••••` for the encrypted key in the UI.
86
+ 4. Domain handler calls `ctx.config(handle)` → decrypted value in-process.
87
+ 5. Tenant B cannot charge with Tenant A's key — per-tenant config row isolation.
88
+
89
+ ## Security guarantees
90
+
91
+ 1. **DB plaintext-free:** `SELECT value FROM config_values WHERE key = 'stripe-api-key'` returns ONLY ciphertext. Backup files, DB dumps, postgres eavesdropping → no plaintext leak.
92
+ 2. **UI mask:** `config:query:values` returns `"••••••"` for encrypted keys. Even the admin allowed to SET the value doesn't see it back. (If you need the value, go through `ctx.config(handle)` in the backend, not through the UI.)
93
+ 3. **Tenant isolation:** every tenant has its own entry — `(key, tenantId)` is unique in the config feature. Customer A NEVER sees Customer B's API key.
94
+
95
+ ## Tests
96
+
97
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
98
+
99
+ ```bash
100
+ bun test src/__tests__/feature.integration.test.ts
101
+ ```
102
+
103
+ Proves:
104
+
105
+ - DB stores ciphertext, not plaintext
106
+ - UI query masks encrypted values
107
+ - Charge handler reads decrypted key and succeeds only when set
108
+ - Tenant A's key does not leak to Tenant B
109
+ - `mask` derives the `configEdit` screen without hand-written `r.screen`
110
+
111
+ ## Key rotation
112
+
113
+ Config values carry their `kekVersion`, so rotation is operational, not
114
+ cryptographic surgery:
115
+
116
+ 1. Add `KUMIKO_SECRETS_MASTER_KEY_V2` to the environment (old key stays).
117
+ 2. Set `KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION=2` — new writes use V2,
118
+ old rows still decrypt via the keyring.
119
+ 3. Trigger the manual `config:reencrypt` job — it re-encrypts every
120
+ encrypted config row onto the current version (and migrates any
121
+ pre-envelope legacy rows). Idempotent, chunked, circuit-breaker on
122
+ repeated failures.
123
+ 4. Only after the job reports `failed: 0` remove V1 from the environment.
124
+
125
+ The same job doubles as the migration path away from the deprecated
126
+ single-key `CONFIG_ENCRYPTION_KEY` format: keep the old env var as the
127
+ decrypt fallback until the job has run once, then delete it.
128
+
129
+ ## What's not in this recipe
130
+
131
+ - **Audit trail:** secrets-demo tracks every secret read as an event. Not here — config keys are meant as "settings" (read frequent, audit overkill).
132
+ - **Backup encryption:** the `KUMIKO_SECRETS_MASTER_KEY_V<n>` keyring must be backed up alongside every backup, otherwise the DB is worthless after restore. Operator's job.
133
+
134
+ ## Related samples
135
+
136
+ - [managed-config](/en/samples/recipes-managed-config/) — `backing: "secrets"`
137
+ for platform-owned keys + tenant cascade for SMTP defaults.
138
+ - [apps-cap-billing-demo](/en/samples/apps-cap-billing-demo/) — billing
139
+ handlers that read per-tenant Stripe keys.
140
+
141
+ ## Source files
142
+
143
+ ### src/__tests__/feature.integration.test.ts
144
+
145
+ ```ts
146
+ // End-to-End: Tenant-Admin setzt Stripe-API-Key → DB hat ciphertext →
147
+ // charge-handler liest entschlüsselt → caller sieht charge-id, NIE den
148
+ // Key. Plus: zwei Tenants haben getrennte Keys.
149
+
150
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
151
+ import { randomBytes } from "node:crypto";
152
+ import {
153
+ configValuesTable,
154
+ createConfigAccessorFactory,
155
+ createConfigFeature,
156
+ createConfigResolver,
157
+ } from "@cosmicdrift/kumiko-bundled-features/config";
158
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
159
+ import {
160
+ setupTestStack,
161
+ type TestStack,
162
+ TestUsers,
163
+ testTenantId,
164
+ unsafePushTables,
165
+ } from "@cosmicdrift/kumiko-framework/stack";
166
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
167
+ import { billingFeature, stripeApiKeyHandle } from "../feature";
168
+
169
+ const TENANT_A = testTenantId(1);
170
+ const TENANT_B = testTenantId(2);
171
+ const acmeAdmin = { ...TestUsers.admin, tenantId: TENANT_A };
172
+ const globexAdmin = { ...TestUsers.admin, tenantId: TENANT_B, id: "globex-admin" };
173
+
174
+ let stack: TestStack;
175
+
176
+ beforeAll(async () => {
177
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
178
+ const resolver = createConfigResolver({ cipher: encryption });
179
+
180
+ stack = await setupTestStack({
181
+ features: [createConfigFeature(), billingFeature],
182
+ extraContext: ({ registry }) => ({
183
+ configResolver: resolver,
184
+ configEncryption: encryption,
185
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
186
+ }),
187
+ });
188
+ await unsafePushTables(stack.db, { configValuesTable });
189
+ });
190
+
191
+ afterAll(async () => stack?.cleanup());
192
+
193
+ beforeEach(async () => {
194
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${configValuesTable.tableName}"`);
195
+ });
196
+
197
+ describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
198
+ test("DB hält ciphertext, NICHT plaintext", async () => {
199
+ const plaintextKey = "sk_live_super_secret_stripe_key_12345";
200
+ await stack.http.writeOk(
201
+ "config:write:set",
202
+ { key: stripeApiKeyHandle.name, value: plaintextKey, scope: "tenant" },
203
+ acmeAdmin,
204
+ );
205
+
206
+ const rows = await selectMany(stack.db, configValuesTable, { key: stripeApiKeyHandle.name });
207
+ expect(rows.length).toBe(1);
208
+ const stored = rows[0]?.["value"];
209
+ expect(typeof stored).toBe("string");
210
+ expect(stored).not.toContain("sk_live");
211
+ expect(stored).not.toContain("12345");
212
+ expect(stored).not.toContain("super_secret");
213
+ });
214
+
215
+ test("config:query:values maskt encrypted-key mit ••••••", async () => {
216
+ await stack.http.writeOk(
217
+ "config:write:set",
218
+ { key: stripeApiKeyHandle.name, value: "sk_live_xyz", scope: "tenant" },
219
+ acmeAdmin,
220
+ );
221
+ const cfg = await stack.http.queryOk<Record<string, { value: unknown; scope: string }>>(
222
+ "config:query:values",
223
+ {},
224
+ acmeAdmin,
225
+ );
226
+ expect(cfg[stripeApiKeyHandle.name]?.value).toBe("••••••");
227
+ });
228
+
229
+ test("charge-handler liest den entschlüsselten Key + nutzt ihn", async () => {
230
+ // Charge ohne gesetzten Key → schlägt mit klarer Error fehl
231
+ const noKey = await stack.http.write(
232
+ "billing:write:charge",
233
+ { amount: 1000, customerRef: "cust_42" },
234
+ acmeAdmin,
235
+ );
236
+ expect(noKey.status).toBeGreaterThanOrEqual(400);
237
+
238
+ // Key setzen → charge geht durch (mock-impl returnt charge-id)
239
+ await stack.http.writeOk(
240
+ "config:write:set",
241
+ { key: stripeApiKeyHandle.name, value: "sk_live_acme", scope: "tenant" },
242
+ acmeAdmin,
243
+ );
244
+ const ok = await stack.http.writeOk<{ chargeId: string }>(
245
+ "billing:write:charge",
246
+ { amount: 1000, customerRef: "cust_42" },
247
+ acmeAdmin,
248
+ );
249
+ expect(ok.chargeId).toContain("cust_42");
250
+ });
251
+
252
+ test("Tenant-Isolation: Tenant-A's Key leakt NICHT zu Tenant-B", async () => {
253
+ await stack.http.writeOk(
254
+ "config:write:set",
255
+ { key: stripeApiKeyHandle.name, value: "sk_live_acme_only", scope: "tenant" },
256
+ acmeAdmin,
257
+ );
258
+
259
+ // Tenant-B macht charge OHNE eigenen Key → fehlschlägt (sieht
260
+ // den acme-Key NICHT). Beweis: per-tenant config-row, nicht globale
261
+ // Variable.
262
+ const res = await stack.http.write(
263
+ "billing:write:charge",
264
+ { amount: 500, customerRef: "globex_cust" },
265
+ globexAdmin,
266
+ );
267
+ expect(res.status).toBeGreaterThanOrEqual(400);
268
+ });
269
+ });
270
+ ```
271
+
272
+ ### src/__tests__/feature.test.ts
273
+
274
+ ```ts
275
+ import { describe, expect, test } from "bun:test";
276
+ import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
277
+ import { buildConfigFeatureSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
278
+ import { billingFeature, stripeApiKeyHandle } from "../feature";
279
+
280
+ // The recipe's headline claim: the `mask` entry alone (no hand-written
281
+ // r.screen/r.nav) makes buildConfigFeatureSchema derive the configEdit screen
282
+ // for the key. If that derivation broke for this key name (e.g. after a
283
+ // framework refactor), the recipe would silently stop showing the field — pin
284
+ // it. Pure schema derivation, no DB/HTTP — moved out of feature.integration.test.ts (482/2).
285
+ describe("encrypted tenant-config: mask derives the configEdit screen", () => {
286
+ test("buildConfigFeatureSchema produces billing-tenant with the qualified stripe key", () => {
287
+ const schema = buildConfigFeatureSchema(
288
+ createRegistry([createConfigFeature(), billingFeature]),
289
+ );
290
+ const screen = schema.screens.find((s) => s.id === "billing-tenant");
291
+ expect(screen).toBeDefined();
292
+ expect(screen?.type).toBe("configEdit");
293
+ if (screen?.type === "configEdit") {
294
+ expect(screen.configKeys).toEqual({ "stripe-api-key": stripeApiKeyHandle.name });
295
+ // mask.title flows through to the per-field label override.
296
+ expect(screen.fieldLabels).toEqual({ "stripe-api-key": "billing.stripe-api-key" });
297
+ }
298
+ });
299
+ });
300
+ ```
301
+
302
+ ### src/feature.ts
303
+
304
+ ```ts
305
+ // Encrypted per-tenant config — Stripe-API-key pattern.
306
+ //
307
+ // Minimal-Showcase: ein dummy "billing"-feature mit einem per-tenant
308
+ // Stripe-API-Key. Der `mask`-Eintrag lässt den configEdit-Screen +
309
+ // Settings-Hub-Nav automatisch entstehen (kein handgeschriebenes
310
+ // r.screen/r.nav mehr). charge-handler liest den Key über ctx.config
311
+ // (entschlüsselt automatisch).
312
+ //
313
+ // Production: nicht aufrufen — der echte Stripe-Call ist hier ein Mock.
314
+ // Pattern in produktiven Apps: Strip-API-Key in tenantBillingConfig.key,
315
+ // charge-handler lädt key + ruft tatsächlich Stripe.
316
+
317
+ import {
318
+ access,
319
+ type ConfigKeyHandle,
320
+ createTenantConfig,
321
+ defineFeature,
322
+ defineWriteHandler,
323
+ } from "@cosmicdrift/kumiko-framework/engine";
324
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
325
+ import { z } from "zod";
326
+
327
+ const FEATURE = "billing";
328
+
329
+ // Config-Key-Definition: encrypted=true → ciphertext in der DB. write/
330
+ // read: access.admin damit nur Tenant-Admin den Key setzt + reads sind
331
+ // nur backend-side via ctx.config (frontend sieht "••••••"). `mask` →
332
+ // buildConfigFeatureSchema derivt Screen + Settings-Hub-Nav.
333
+ const stripeApiKeyDef = createTenantConfig("text", {
334
+ encrypted: true,
335
+ write: access.admin,
336
+ read: access.admin,
337
+ // mask-derived screens carry no field constraints (maxLength, pattern) —
338
+ // set those separately in a configEdit schema if you need them enforced.
339
+ mask: { title: "billing.stripe-api-key", order: 1 },
340
+ });
341
+
342
+ const stripeApiKeyHandle: ConfigKeyHandle<"text"> = {
343
+ name: `${FEATURE}:config:stripe-api-key`,
344
+ type: "text",
345
+ };
346
+
347
+ // Charge-Handler — nutzt den entschlüsselten API-Key. Caller sieht NUR
348
+ // die charge-id zurück, der Key bleibt server-side.
349
+ const chargeHandler = defineWriteHandler({
350
+ name: "charge",
351
+ schema: z.object({
352
+ amount: z.number().positive(),
353
+ customerRef: z.string().min(1),
354
+ }),
355
+ access: { roles: ["Admin"] },
356
+ async handler(event, ctx) {
357
+ if (!ctx.config) {
358
+ return writeFailure(
359
+ new UnprocessableError("config_unavailable", {
360
+ i18nKey: "billing.errors.configUnavailable",
361
+ }),
362
+ );
363
+ }
364
+ const apiKey = await ctx.config(stripeApiKeyHandle);
365
+ if (!apiKey || apiKey.length === 0) {
366
+ return writeFailure(
367
+ new UnprocessableError("stripe_key_missing", {
368
+ i18nKey: "billing.errors.stripeKeyMissing",
369
+ }),
370
+ );
371
+ }
372
+
373
+ // Mock: real impl würde fetch("https://api.stripe.com/v1/charges",
374
+ // { headers: { Authorization: `Bearer ${apiKey}` }, ... }) ausführen.
375
+ // Wichtig: apiKey verlässt den server NICHT — kein log, kein
376
+ // response-field, kein error-detail.
377
+ const chargeId = `ch_${Date.now()}_${event.payload.customerRef}`;
378
+
379
+ return {
380
+ isSuccess: true as const,
381
+ data: { chargeId },
382
+ };
383
+ },
384
+ });
385
+
386
+ export const billingFeature = defineFeature(FEATURE, (r) => {
387
+ r.requires("config");
388
+ r.config("stripe-api-key", stripeApiKeyDef);
389
+ r.writeHandler(chargeHandler);
390
+ // Kein r.screen/r.nav: der `mask`-Eintrag auf dem Key lässt
391
+ // buildConfigFeatureSchema den configEdit-Screen (••••••-maskiert,
392
+ // config:write:set verschlüsselt vor dem write) + den Settings-Hub-Nav
393
+ // automatisch ableiten.
394
+
395
+ r.translations({
396
+ keys: {
397
+ "billing.settings": { de: "Abrechnung", en: "Billing" },
398
+ "billing.stripe-api-key": { de: "Stripe-API-Schlüssel", en: "Stripe API Key" },
399
+ },
400
+ });
401
+ });
402
+
403
+ // Re-exports damit tests die handles ohne re-typing nutzen können.
404
+ export { stripeApiKeyHandle };
405
+ ```