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