@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,428 @@
1
+ <!-- Generated by `bun run gen:agent-docs` from samples/recipes/managed-config. Do not edit. -->
2
+
3
+ # managed-config
4
+
5
+ Managed config: one declaration provisions storage + masking + settings-hub, across backings (secrets vs config) and scopes (system vs tenant).
6
+
7
+ ## README
8
+
9
+ # Recipe: Managed config
10
+
11
+ **What this shows:** how one declarative config surface provisions everything a
12
+ key needs — storage, masking, cascade, and a settings-hub entry — across two
13
+ backings and two scopes, without a hand-written screen, nav, or env-wiring map.
14
+
15
+ Two keys, deliberately different:
16
+
17
+ | Key | Scope | Backing | Story |
18
+ |---|---|---|---|
19
+ | `payment-api-key` | `system` | `secrets` | Platform-owned secret (one Stripe key bills all tenants). Stored envelope-encrypted in the secrets store, masked in every query, revealed only for the owning feature's `ctx.config` read. |
20
+ | `smtp-host` | `tenant` | config | Platform default that a tenant admin overrides. Cascade resolves `tenant-row → system-row → default`; one tenant's override never leaks to another. |
21
+
22
+ ## Pattern
23
+
24
+ ```ts illustration
25
+ import { access, createSystemConfig, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
26
+
27
+ r.config({
28
+ keys: {
29
+ // System-only secret: storage routes to the secrets envelope (KEK rotation
30
+ // + audit-on-read), never config_values. backing:"secrets" is system-only —
31
+ // the boot-guard rejects it on tenant/user scope (secrets has no cascade).
32
+ "payment-api-key": createSystemConfig("text", {
33
+ backing: "secrets",
34
+ write: access.systemAdmin,
35
+ read: access.admin,
36
+ mask: { title: "integrations.payment-api-key", icon: "credit-card", order: 1 },
37
+ }),
38
+ // Tenant override with a platform default sourced from an env var at boot.
39
+ "smtp-host": createTenantConfig("text", {
40
+ env: "SMTP_HOST",
41
+ default: "smtp.platform.example",
42
+ write: access.roles("SystemAdmin", "Admin"),
43
+ read: access.admin,
44
+ mask: { title: "integrations.smtp-host", icon: "mail", order: 2 },
45
+ }),
46
+ },
47
+ });
48
+ ```
49
+
50
+ ## What each option provisions
51
+
52
+ - **`backing: "secrets"`** — the value lives in the secrets store (envelope
53
+ encryption, KEK rotation, audit-on-read) instead of `config_values`. Set/read/
54
+ reset dispatch through `ctx.secrets`; the query handlers mask the value while
55
+ the owning feature still reads the revealed plaintext via `ctx.config`. Only
56
+ valid for `scope: "system"` — secrets are flat per `(tenant, key)` with no
57
+ cascade, so a tenant override would be incoherent and is rejected at boot.
58
+ - **`env: "SMTP_HOST"`** — at boot (`runProdApp`) the env var seeds the key's
59
+ default as an app-override, below any `system-row`. The env var *is* the
60
+ default; no manual `AppConfigOverrides` map, no re-typing in the admin UI.
61
+ - **`mask`** — the key surfaces automatically in the self-populating settings
62
+ hub: `buildConfigFeatureSchema` derives a `configEdit` screen + a child nav
63
+ under the audience hub (Platform / Organisation / Personal) from the key's
64
+ scope and type. No `r.screen`, no `r.nav`. `mask.title` is the i18n label key.
65
+
66
+ ## Boot wiring
67
+
68
+ ```ts illustration
69
+ import {
70
+ createConfigAccessorFactory,
71
+ createConfigResolver,
72
+ } from "@cosmicdrift/kumiko-bundled-features/config";
73
+ import { createSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
74
+ import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
75
+
76
+ const masterKeyProvider = createEnvMasterKeyProvider({ env: process.env });
77
+ const resolver = createConfigResolver();
78
+
79
+ await runProdApp({
80
+ extraContext: ({ db, registry }) => ({
81
+ configResolver: resolver,
82
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
83
+ // Required whenever a key declares backing:"secrets" — without it the set
84
+ // and read paths fail loud, never silently miss.
85
+ secrets: createSecretsContext({ db, masterKeyProvider, registry }),
86
+ }),
87
+ // ...
88
+ });
89
+ ```
90
+
91
+ Register the `secrets` feature too so its `tenant_secrets` table is migrated;
92
+ the config backing only needs the `ctx.secrets` context shown above.
93
+
94
+ ## Vs. the neighbouring recipes
95
+
96
+ | Pattern | Storage | Cascade | Use case |
97
+ |---|---|---|---|
98
+ | **`encrypted: true` config key** (`encrypted-tenant-config`) | `config_values`, AES ciphertext | system → tenant | Per-tenant customer secret, no KEK rotation/audit. |
99
+ | **`backing: "secrets"` config key** (this recipe) | secrets envelope, system tenant | none (system-only) | Platform-owned secret needing rotation + audit-on-read, declared as a config key. |
100
+ | **`r.secret`** (`secrets-demo`) | secrets envelope | none | Same storage, but addressed directly — no config key, no settings-hub entry. |
101
+
102
+ `backing: "secrets"` is the bridge: you get the secrets store's guarantees
103
+ *and* the config feature's declarative surface (masking, settings hub, the same
104
+ `config:write:set` write path).
105
+
106
+ ## Flow
107
+
108
+ 1. SystemAdmin sets `payment-api-key` (`backing: "secrets"`) → value lands in
109
+ the secrets envelope, masked in every query.
110
+ 2. Owning feature reads plaintext via `ctx.config` — audit-on-read from secrets.
111
+ 3. SystemAdmin sets platform `smtp-host` default (system scope).
112
+ 4. Tenant A overrides `smtp-host` → cascade resolves tenant-row for A only.
113
+ 5. Tenant B still sees the system default — no cross-tenant leak.
114
+
115
+ ## Tests
116
+
117
+ Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
118
+
119
+ ```bash
120
+ bun test src/__tests__/feature.integration.test.ts
121
+ ```
122
+
123
+ Proves:
124
+
125
+ - `backing: "secrets"` routes to secrets store, not `config_values`
126
+ - Owning feature reads revealed plaintext; cascade queries stay masked
127
+ - Tenant SMTP override wins locally and never leaks to another tenant
128
+ - Boot rejects `integrationsFeature` without the `secrets` feature mounted
129
+
130
+ ## What's not in this recipe
131
+
132
+ - **Tenant-scoped secrets** — forbidden by declaration. `backing: "secrets"` +
133
+ `scope: "tenant"`/`"user"` fails at boot; use `encrypted: true` for a
134
+ cascading per-tenant secret.
135
+ - **The env→default bridge at runtime** — `env:` is wired by `runProdApp`, not
136
+ by `setupTestStack`, so the integration test sets the platform default
137
+ explicitly via a `scope: "system"` write rather than through the env var.
138
+
139
+ ## Related samples
140
+
141
+ - [encrypted-tenant-config](/en/samples/recipes-encrypted-tenant-config/) —
142
+ per-tenant customer secrets with `encrypted: true` on config keys.
143
+ - [apps-cap-billing-demo](/en/samples/apps-cap-billing-demo/) — mail transport
144
+ selected per tenant via config.
145
+
146
+ ## Source files
147
+
148
+ ### src/__tests__/feature.integration.test.ts
149
+
150
+ ```ts
151
+ // End-to-end over real HTTP: one declarative config surface, two backings.
152
+ //
153
+ // payment-api-key (backing:"secrets") → set routes into the secrets envelope,
154
+ // masked in queries, revealed only for the owning feature's ctx.config read.
155
+ //
156
+ // smtp-host (config, tenant scope) → platform default → system-row → tenant
157
+ // override; cascade proves a tenant override does not leak across tenants.
158
+
159
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
160
+ import { randomBytes } from "node:crypto";
161
+ import {
162
+ configValuesTable,
163
+ createConfigAccessorFactory,
164
+ createConfigFeature,
165
+ createConfigResolver,
166
+ } from "@cosmicdrift/kumiko-bundled-features/config";
167
+ import {
168
+ createSecretsContext,
169
+ createSecretsFeature,
170
+ tenantSecretsTable,
171
+ } from "@cosmicdrift/kumiko-bundled-features/secrets";
172
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
173
+ import {
174
+ type ConfigCascade,
175
+ createRegistry,
176
+ SYSTEM_TENANT_ID,
177
+ } from "@cosmicdrift/kumiko-framework/engine";
178
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
179
+ import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
180
+ import {
181
+ setupTestStack,
182
+ type TestStack,
183
+ TestUsers,
184
+ testTenantId,
185
+ unsafePushTables,
186
+ } from "@cosmicdrift/kumiko-framework/stack";
187
+ import { integrationsFeature, paymentApiKeyHandle, smtpHostHandle } from "../feature";
188
+
189
+ const TENANT_A = testTenantId(1);
190
+ const TENANT_B = testTenantId(2);
191
+
192
+ const systemAdmin = TestUsers.systemAdmin;
193
+ const acmeAdmin = { ...TestUsers.admin, tenantId: TENANT_A, id: "acme-admin" };
194
+ const globexAdmin = { ...TestUsers.admin, tenantId: TENANT_B, id: "globex-admin" };
195
+
196
+ type Cascades = Record<string, ConfigCascade>;
197
+
198
+ let stack: TestStack;
199
+
200
+ beforeAll(async () => {
201
+ const masterKeyProvider = createEnvMasterKeyProvider({
202
+ env: {
203
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
204
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
205
+ },
206
+ });
207
+
208
+ stack = await setupTestStack({
209
+ features: [createConfigFeature(), createSecretsFeature(), integrationsFeature],
210
+ extraContext: ({ db, registry }) => {
211
+ const resolver = createConfigResolver();
212
+ return {
213
+ configResolver: resolver,
214
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
215
+ secrets: createSecretsContext({ db, masterKeyProvider, registry }),
216
+ };
217
+ },
218
+ });
219
+ await unsafePushTables(stack.db, { configValuesTable, tenantSecretsTable });
220
+ await createEventsTable(stack.db);
221
+ });
222
+
223
+ afterAll(async () => stack?.cleanup());
224
+
225
+ describe("managed config — backing:secrets system key", () => {
226
+ test("set routes the value into the secrets store, not config_values", async () => {
227
+ await stack.http.writeOk(
228
+ "config:write:set",
229
+ { key: paymentApiKeyHandle.name, value: "sk-live-acme-99", scope: "system" },
230
+ systemAdmin,
231
+ );
232
+
233
+ const secretRows = await selectMany(stack.db, tenantSecretsTable, {
234
+ tenantId: SYSTEM_TENANT_ID,
235
+ key: paymentApiKeyHandle.name,
236
+ });
237
+ expect(secretRows).toHaveLength(1);
238
+ // The plaintext never touches config_values, and the stored secret is an
239
+ // envelope — no column carries the cleartext.
240
+ const configRows = await selectMany(stack.db, configValuesTable, {
241
+ key: paymentApiKeyHandle.name,
242
+ });
243
+ expect(configRows).toHaveLength(0);
244
+ expect(JSON.stringify(secretRows[0])).not.toContain("sk-live-acme-99");
245
+ });
246
+
247
+ test("the owning feature reads the revealed plaintext via ctx.config", async () => {
248
+ const res = await stack.http.queryOk<{ value: unknown }>(
249
+ "integrations:query:peek-payment-key",
250
+ {},
251
+ systemAdmin,
252
+ );
253
+ expect(res.value).toBe("sk-live-acme-99");
254
+ });
255
+
256
+ test("config:query:cascade masks the value and every level", async () => {
257
+ const res = await stack.http.queryOk<Cascades>(
258
+ "config:query:cascade",
259
+ { keys: [paymentApiKeyHandle.name] },
260
+ systemAdmin,
261
+ );
262
+ const cascade = res[paymentApiKeyHandle.name];
263
+ expect(cascade?.value).toBe("••••••");
264
+ expect(cascade?.source).toBe("system-row");
265
+ expect(JSON.stringify(cascade)).not.toContain("sk-live-acme-99");
266
+ });
267
+ });
268
+
269
+ describe("managed config — tenant cascade override", () => {
270
+ test("with no rows the tenant sees the platform default", async () => {
271
+ const res = await stack.http.queryOk<Cascades>(
272
+ "config:query:cascade",
273
+ { keys: [smtpHostHandle.name] },
274
+ globexAdmin,
275
+ );
276
+ const cascade = res[smtpHostHandle.name];
277
+ expect(cascade?.value).toBe("smtp.platform.example");
278
+ expect(cascade?.source).toBe("default");
279
+ });
280
+
281
+ test("a system-row default cascades to every tenant", async () => {
282
+ await stack.http.writeOk(
283
+ "config:write:set",
284
+ { key: smtpHostHandle.name, value: "smtp.system.example", scope: "system" },
285
+ systemAdmin,
286
+ );
287
+ const res = await stack.http.queryOk<Cascades>(
288
+ "config:query:cascade",
289
+ { keys: [smtpHostHandle.name] },
290
+ globexAdmin,
291
+ );
292
+ const cascade = res[smtpHostHandle.name];
293
+ expect(cascade?.value).toBe("smtp.system.example");
294
+ expect(cascade?.source).toBe("system-row");
295
+ });
296
+
297
+ test("a tenant override wins for its tenant and never leaks to another", async () => {
298
+ await stack.http.writeOk(
299
+ "config:write:set",
300
+ { key: smtpHostHandle.name, value: "smtp.acme.example", scope: "tenant" },
301
+ acmeAdmin,
302
+ );
303
+
304
+ const acme = (
305
+ await stack.http.queryOk<Cascades>(
306
+ "config:query:cascade",
307
+ { keys: [smtpHostHandle.name] },
308
+ acmeAdmin,
309
+ )
310
+ )[smtpHostHandle.name];
311
+ expect(acme?.value).toBe("smtp.acme.example");
312
+ expect(acme?.source).toBe("tenant-row");
313
+
314
+ // Tenant B never set an override → still resolves the system-row default.
315
+ const globex = (
316
+ await stack.http.queryOk<Cascades>(
317
+ "config:query:cascade",
318
+ { keys: [smtpHostHandle.name] },
319
+ globexAdmin,
320
+ )
321
+ )[smtpHostHandle.name];
322
+ expect(globex?.value).toBe("smtp.system.example");
323
+ expect(globex?.source).toBe("system-row");
324
+ });
325
+ });
326
+
327
+ describe("feature contract — backing:secrets declares the secrets dependency", () => {
328
+ test("registry build rejects integrationsFeature without the secrets feature", () => {
329
+ // r.requires("secrets") is what makes this throw: without the dependency a
330
+ // consumer would only discover the missing tenant_secrets table at runtime.
331
+ // config is registered so the failure isolates the secrets requirement.
332
+ expect(() => createRegistry([createConfigFeature(), integrationsFeature])).toThrow(/secrets/);
333
+ });
334
+ });
335
+ ```
336
+
337
+ ### src/feature.ts
338
+
339
+ ```ts
340
+ // Managed config — one declaration provisions everything a config key needs.
341
+ //
342
+ // Two keys, two backings, two scopes — same declarative surface:
343
+ //
344
+ // payment-api-key scope:"system" backing:"secrets" platform-owned secret
345
+ // → stored envelope-encrypted in the secrets store (system tenant), masked
346
+ // in every query, revealed only for the owning feature's ctx.config read.
347
+ //
348
+ // smtp-host scope:"tenant" (config, plain) per-tenant override
349
+ // → platform default (env/system-row) that a tenant admin can override;
350
+ // cascade resolves tenant-row → system-row → default.
351
+ //
352
+ // `mask` makes each key surface in the self-populating settings hub without a
353
+ // hand-written r.screen / r.nav. `env` wires the platform default from an
354
+ // environment variable at boot (runProdApp) — no manual AppConfigOverrides map.
355
+
356
+ import {
357
+ access,
358
+ type ConfigKeyHandle,
359
+ createSystemConfig,
360
+ createTenantConfig,
361
+ defineFeature,
362
+ } from "@cosmicdrift/kumiko-framework/engine";
363
+ import { z } from "zod";
364
+
365
+ const FEATURE = "integrations";
366
+
367
+ export const paymentApiKeyHandle: ConfigKeyHandle<"text"> = {
368
+ name: `${FEATURE}:config:payment-api-key`,
369
+ type: "text",
370
+ };
371
+
372
+ export const smtpHostHandle: ConfigKeyHandle<"text"> = {
373
+ name: `${FEATURE}:config:smtp-host`,
374
+ type: "text",
375
+ };
376
+
377
+ export const integrationsFeature = defineFeature(FEATURE, (r) => {
378
+ r.requires("config");
379
+ // backing:"secrets" stores payment-api-key in the secrets envelope, which
380
+ // the secrets feature provisions — declare the dependency so registry-build
381
+ // enforces it instead of leaving a consumer to discover the missing
382
+ // tenant_secrets table at runtime.
383
+ r.requires("secrets");
384
+
385
+ r.config({
386
+ keys: {
387
+ // System-only secret: backing:"secrets" routes storage to the secrets
388
+ // envelope (KEK rotation + audit-on-read), never config_values. The
389
+ // boot-guard rejects backing:"secrets" on any non-system scope.
390
+ "payment-api-key": createSystemConfig("text", {
391
+ backing: "secrets",
392
+ write: access.systemAdmin,
393
+ read: access.admin,
394
+ mask: { title: "integrations.payment-api-key", icon: "credit-card", order: 1 },
395
+ }),
396
+ // Tenant override with a platform default. env seeds the default from
397
+ // SMTP_HOST at boot; a tenant admin overrides it per tenant.
398
+ "smtp-host": createTenantConfig("text", {
399
+ env: "SMTP_HOST",
400
+ default: "smtp.platform.example",
401
+ write: access.roles("SystemAdmin", "Admin"),
402
+ read: access.admin,
403
+ mask: { title: "integrations.smtp-host", icon: "mail", order: 2 },
404
+ }),
405
+ },
406
+ });
407
+
408
+ // Internal-read probe: the owning feature reads its own secrets-backed key
409
+ // via ctx.config and receives the revealed plaintext, never the mask.
410
+ r.queryHandler(
411
+ "peek-payment-key",
412
+ z.object({}),
413
+ async (_query, ctx) => {
414
+ if (!ctx.config) throw new Error("ctx.config not wired");
415
+ return { value: await ctx.config(paymentApiKeyHandle) };
416
+ },
417
+ { access: { roles: ["SystemAdmin"] } },
418
+ );
419
+
420
+ r.translations({
421
+ keys: {
422
+ "integrations.settings": { de: "Integrationen", en: "Integrations" },
423
+ "integrations.payment-api-key": { de: "Zahlungs-API-Schlüssel", en: "Payment API Key" },
424
+ "integrations.smtp-host": { de: "SMTP-Server", en: "SMTP Host" },
425
+ },
426
+ });
427
+ });
428
+ ```