@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.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- 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
|
+
```
|