@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,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
|
+
```
|