@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,275 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/tenant-settings. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# tenant-settings
|
|
4
|
+
|
|
5
|
+
Tenant-Settings: per-tenant Currency + Locale defaults, auto-filled into a money field / locale field on create instead of a hard-coded literal.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Recipe: Tenant-Settings
|
|
10
|
+
|
|
11
|
+
**What this shows:** how a per-tenant Currency + Locale default fills a
|
|
12
|
+
money/locale field on create — without hard-coding `"EUR"`/`"de"` as a field
|
|
13
|
+
literal, the mistake that cost a sibling project (phronexsis) a retrofit once
|
|
14
|
+
a second tenant needed a different default.
|
|
15
|
+
|
|
16
|
+
## Pattern
|
|
17
|
+
|
|
18
|
+
```ts illustration
|
|
19
|
+
import {
|
|
20
|
+
createEntity,
|
|
21
|
+
createMoneyField,
|
|
22
|
+
createSelectField,
|
|
23
|
+
createTextField,
|
|
24
|
+
defineFeature,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
|
|
27
|
+
|
|
28
|
+
export const invoiceEntity = createEntity({
|
|
29
|
+
table: "read_invoices",
|
|
30
|
+
fields: {
|
|
31
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
32
|
+
amount: createMoneyField({ required: true }),
|
|
33
|
+
language: createSelectField({ options: ["en", "de", "fr"] as const }),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const invoiceFeature = defineFeature("invoice", (r) => {
|
|
38
|
+
r.entity("invoice", invoiceEntity);
|
|
39
|
+
r.writeHandler(
|
|
40
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
41
|
+
access: { roles: ["Admin"] },
|
|
42
|
+
currencyFields: ["amount"],
|
|
43
|
+
localeField: "language",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
// update/delete/list/detail stay the plain defineEntity*Handler factories —
|
|
47
|
+
// only create needs the tenant-default fill-in.
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Mount `createTenantSettingsFeature()` alongside — it provisions the two
|
|
52
|
+
config keys (`tenant-settings:config:currency`, `tenant-settings:config:locale`)
|
|
53
|
+
that `defineCreateWithTenantDefaults` reads:
|
|
54
|
+
|
|
55
|
+
```ts illustration
|
|
56
|
+
import { createTenantSettingsFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
|
|
57
|
+
|
|
58
|
+
const features = [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature];
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`createTenantSettingsFeature(opts)` takes `currencies`, `defaultCurrency`,
|
|
62
|
+
`defaultLocale`, `write` — an app with its own supported-currency list or
|
|
63
|
+
role vocabulary overrides those instead of forking the feature.
|
|
64
|
+
|
|
65
|
+
## Why the schema needs a special case
|
|
66
|
+
|
|
67
|
+
`buildInsertSchema` always makes `currency` required *inside* a money field's
|
|
68
|
+
value object, even when the field itself is optional — a caller literally
|
|
69
|
+
cannot submit `{ amount: 1000 }` against the plain `defineEntityCreateHandler`.
|
|
70
|
+
`defineCreateWithTenantDefaults` swaps in its own schema (`.extend()` over the
|
|
71
|
+
same `buildInsertSchema` output, with just the declared `currencyFields`
|
|
72
|
+
relaxed) so the caller *can* omit `currency`, then fills it from
|
|
73
|
+
`ctx.config(TenantSettingsConfig.currency)` before delegating to the same
|
|
74
|
+
`executor.create()` the generic factory uses.
|
|
75
|
+
|
|
76
|
+
## Settings-Hub
|
|
77
|
+
|
|
78
|
+
Both keys declare `mask`, so they surface in the self-populating Settings-Hub
|
|
79
|
+
(Tenant-Audience) without a hand-written `r.screen`/`r.nav` — same mechanism
|
|
80
|
+
as [managed-config](/en/samples/recipes-managed-config/). A `TenantAdmin`
|
|
81
|
+
changes them from **Settings → Tenant → Default Currency / Default Locale**.
|
|
82
|
+
|
|
83
|
+
## Flow
|
|
84
|
+
|
|
85
|
+
1. No tenant override yet → a new invoice gets the *feature's own* default
|
|
86
|
+
(`"EUR"`/`"en"`, set via `createTenantSettingsFeature()`'s own defaults).
|
|
87
|
+
2. Tenant sets `currency` to `"CHF"` and `locale` to `"de"` via the
|
|
88
|
+
Settings-Hub (or `config:write:set` directly).
|
|
89
|
+
3. A new invoice that doesn't specify `amount.currency`/`language` picks up
|
|
90
|
+
`"CHF"`/`"de"` — no entity code changed, no second migration.
|
|
91
|
+
4. A caller that *does* specify `amount.currency` still wins — the tenant
|
|
92
|
+
setting only fills gaps, it never overrides an explicit value.
|
|
93
|
+
|
|
94
|
+
## Tests
|
|
95
|
+
|
|
96
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Covers the happy path above (tenant defaults fill gaps; explicit caller
|
|
103
|
+
values still win). Creating without a `tenant-settings` mount is not a
|
|
104
|
+
supported fallback — mount the feature (or the recipe's stack) first.
|
|
105
|
+
|
|
106
|
+
## What's not in this recipe
|
|
107
|
+
|
|
108
|
+
- **Timezone, number-format, week-start** — deliberately out of scope; add
|
|
109
|
+
them as further config keys on the same feature when a real consumer needs
|
|
110
|
+
them, don't speculate ahead of a use case.
|
|
111
|
+
- **A dedicated locale-options select** — `locale` is a free-text ISO-639-1(-region)
|
|
112
|
+
field validated by regex, not a fixed enum, because the framework doesn't
|
|
113
|
+
own which languages an app supports. An app with a fixed language list
|
|
114
|
+
builds its own `createTenantConfig("select", {...})` key if it wants a
|
|
115
|
+
dropdown instead of `TenantSettingsConfig.locale`.
|
|
116
|
+
|
|
117
|
+
## Related samples
|
|
118
|
+
|
|
119
|
+
- [managed-config](/en/samples/recipes-managed-config/) — the `mask` +
|
|
120
|
+
Settings-Hub mechanism these keys rely on, explained in depth.
|
|
121
|
+
|
|
122
|
+
## Source files
|
|
123
|
+
|
|
124
|
+
### src/__tests__/feature.integration.test.ts
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// End-to-end over real HTTP: a tenant's currency/locale setting flows into
|
|
128
|
+
// a new invoice's money/locale fields without the caller specifying them.
|
|
129
|
+
|
|
130
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
131
|
+
import {
|
|
132
|
+
configValuesTable,
|
|
133
|
+
createConfigAccessorFactory,
|
|
134
|
+
createConfigFeature,
|
|
135
|
+
createConfigResolver,
|
|
136
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
137
|
+
import { createTenantSettingsFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
|
|
138
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
139
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
140
|
+
import {
|
|
141
|
+
pushEntityProjectionTables,
|
|
142
|
+
setupTestStack,
|
|
143
|
+
type TestStack,
|
|
144
|
+
testTenantId,
|
|
145
|
+
unsafeCreateEntityTable,
|
|
146
|
+
unsafePushTables,
|
|
147
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
148
|
+
import { invoiceEntity, invoiceFeature } from "../feature";
|
|
149
|
+
|
|
150
|
+
const tenantId = testTenantId(1);
|
|
151
|
+
const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
|
|
152
|
+
|
|
153
|
+
let stack: TestStack;
|
|
154
|
+
|
|
155
|
+
beforeAll(async () => {
|
|
156
|
+
const resolver = createConfigResolver();
|
|
157
|
+
stack = await setupTestStack({
|
|
158
|
+
features: [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature],
|
|
159
|
+
extraContext: ({ registry }) => ({
|
|
160
|
+
configResolver: resolver,
|
|
161
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
165
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
166
|
+
await createEventsTable(stack.db);
|
|
167
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterAll(async () => {
|
|
171
|
+
await stack.cleanup();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("no tenant override yet — new invoice gets the feature's own default (EUR/en)", async () => {
|
|
175
|
+
const invoice = await stack.http.writeOk<{
|
|
176
|
+
data: {
|
|
177
|
+
amount: { amount: number; currency: string; amountScaled: number; amountMinor: number };
|
|
178
|
+
language: string;
|
|
179
|
+
};
|
|
180
|
+
}>("invoice:write:invoice:create", { title: "Invoice 1", amount: { amount: 1000 } }, admin);
|
|
181
|
+
expect(invoice.data.amount).toEqual({
|
|
182
|
+
amount: 1000,
|
|
183
|
+
currency: "EUR",
|
|
184
|
+
amountScaled: 100_000,
|
|
185
|
+
amountMinor: 100_000,
|
|
186
|
+
});
|
|
187
|
+
expect(invoice.data.language).toBe("en");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("tenant sets CHF/de — a new invoice without explicit values picks up the tenant setting", async () => {
|
|
191
|
+
await stack.http.writeOk(
|
|
192
|
+
"config:write:set",
|
|
193
|
+
{ key: "tenant-settings:config:currency", value: "CHF" },
|
|
194
|
+
admin,
|
|
195
|
+
);
|
|
196
|
+
await stack.http.writeOk(
|
|
197
|
+
"config:write:set",
|
|
198
|
+
{ key: "tenant-settings:config:locale", value: "de" },
|
|
199
|
+
admin,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const invoice = await stack.http.writeOk<{
|
|
203
|
+
data: {
|
|
204
|
+
amount: { amount: number; currency: string; amountScaled: number; amountMinor: number };
|
|
205
|
+
language: string;
|
|
206
|
+
};
|
|
207
|
+
}>("invoice:write:invoice:create", { title: "Invoice 2", amount: { amount: 2000 } }, admin);
|
|
208
|
+
|
|
209
|
+
expect(invoice.data.amount).toEqual({
|
|
210
|
+
amount: 2000,
|
|
211
|
+
currency: "CHF",
|
|
212
|
+
amountScaled: 200_000,
|
|
213
|
+
amountMinor: 200_000,
|
|
214
|
+
});
|
|
215
|
+
expect(invoice.data.language).toBe("de");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("an explicit value in the payload wins over the tenant setting", async () => {
|
|
219
|
+
const invoice = await stack.http.writeOk<{
|
|
220
|
+
data: {
|
|
221
|
+
amount: { amount: number; currency: string; amountScaled: number; amountMinor: number };
|
|
222
|
+
};
|
|
223
|
+
}>(
|
|
224
|
+
"invoice:write:invoice:create",
|
|
225
|
+
{ title: "Invoice 3", amount: { amount: 3000, currency: "USD" } },
|
|
226
|
+
admin,
|
|
227
|
+
);
|
|
228
|
+
expect(invoice.data.amount).toEqual({
|
|
229
|
+
amount: 3000,
|
|
230
|
+
currency: "USD",
|
|
231
|
+
amountScaled: 300_000,
|
|
232
|
+
amountMinor: 300_000,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### src/feature.ts
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
// Consuming tenant-settings: an `invoice` entity whose `amount` (money) and
|
|
241
|
+
// `language` (locale) are auto-filled from the tenant's currency/locale
|
|
242
|
+
// setting when the caller omits them — instead of hard-coding "EUR"/"en" on
|
|
243
|
+
// the field, the mistake this recipe exists to prevent (solon#P19).
|
|
244
|
+
|
|
245
|
+
import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
|
|
246
|
+
import {
|
|
247
|
+
createEntity,
|
|
248
|
+
createMoneyField,
|
|
249
|
+
createSelectField,
|
|
250
|
+
createTextField,
|
|
251
|
+
defineFeature,
|
|
252
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
253
|
+
|
|
254
|
+
export const invoiceEntity = createEntity({
|
|
255
|
+
table: "read_invoices",
|
|
256
|
+
fields: {
|
|
257
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
258
|
+
amount: createMoneyField({ required: true }),
|
|
259
|
+
language: createSelectField({ options: ["en", "de", "fr"] as const }),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const ACCESS = { roles: ["Admin"] } as const;
|
|
264
|
+
|
|
265
|
+
export const invoiceFeature = defineFeature("invoice", (r) => {
|
|
266
|
+
r.entity("invoice", invoiceEntity);
|
|
267
|
+
r.writeHandler(
|
|
268
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
269
|
+
access: ACCESS,
|
|
270
|
+
currencyFields: ["amount"],
|
|
271
|
+
localeField: "language",
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
```
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/tier-admin. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# tier-admin
|
|
4
|
+
|
|
5
|
+
Manually grant a tier to a tenant without a billing purchase — the SystemAdmin operator flow and how the resolver cache stays in sync.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Tier Admin
|
|
10
|
+
|
|
11
|
+
Manually assign a pricing tier to a tenant — without a billing purchase
|
|
12
|
+
and without writing the projection by hand. The recipe shows the
|
|
13
|
+
SystemAdmin-only operator flow plus the subtlety that makes it correct:
|
|
14
|
+
the grant updates the in-memory resolver cache synchronously, so
|
|
15
|
+
toggleable features unlock in the same request.
|
|
16
|
+
|
|
17
|
+
## What it shows
|
|
18
|
+
|
|
19
|
+
- **`tier-engine:write:set-tenant-tier`** — a SystemAdmin assigns a tier
|
|
20
|
+
to *any* tenant, cross-tenant. Stamps `source: "manual"` so a future
|
|
21
|
+
Stripe → tier sync won't overwrite the grant.
|
|
22
|
+
- **`tier-engine:query:get-tenant-tier`** — SystemAdmin reads back which
|
|
23
|
+
tier a tenant is on plus its source (`"manual"` vs `"billing"`).
|
|
24
|
+
- **`tier-engine:query:tier-options`** — lists the configured tier names
|
|
25
|
+
so the admin UI doesn't have to hard-code them.
|
|
26
|
+
- **`notes-export`** — a `r.toggleable()` feature the `pro` tier unlocks.
|
|
27
|
+
It appears in a tenant's effective-features set only when its tier lists
|
|
28
|
+
it (`pro` does, `free` does not).
|
|
29
|
+
- **Cache-sync invariant** — the manual grant updates the resolver cache
|
|
30
|
+
the same request, not just the projection. `notes-export` is in the
|
|
31
|
+
tenant's effective set immediately after the set call — same request,
|
|
32
|
+
before any cache refresh, replay, or restart.
|
|
33
|
+
|
|
34
|
+
## Feature composition
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
config → tenant config (tier-engine dependency)
|
|
38
|
+
tenant → tenant records for cross-tenant grants
|
|
39
|
+
tier-engine → set-tenant-tier, get-tenant-tier, tier-options
|
|
40
|
+
notes-export → r.toggleable() domain feature unlocked by pro tier
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Flow
|
|
44
|
+
|
|
45
|
+
1. SystemAdmin calls `set-tenant-tier` for a **foreign** tenant with
|
|
46
|
+
`tier: "pro"` → event lands in the target tenant's stream,
|
|
47
|
+
`source: "manual"`.
|
|
48
|
+
2. `get-tenant-tier` read-back confirms tier + source (billing sync must
|
|
49
|
+
not overwrite manual grants).
|
|
50
|
+
3. `tier-options` returns keys from your static `TierMap` — no hard-coded
|
|
51
|
+
tier list in the UI.
|
|
52
|
+
4. Resolver built **before** the grant now reports `notes-export` in the
|
|
53
|
+
tenant's effective set — same process, no rebuild (cache-sync proof).
|
|
54
|
+
|
|
55
|
+
## Why the cache invariant matters
|
|
56
|
+
|
|
57
|
+
`set-tenant-tier` writes through the event-store executor directly. That
|
|
58
|
+
path bypasses the `postSave` entity-hook the resolver normally uses to
|
|
59
|
+
invalidate the cache after a tier-assignment change. Without an explicit
|
|
60
|
+
cache update the grant would persist (next request would see it) but
|
|
61
|
+
*this* request still sees the old tier — surprising for an operator who
|
|
62
|
+
just clicked "set tier to pro".
|
|
63
|
+
|
|
64
|
+
The feature wires `onAssigned` into `createSetTenantTierWrite` for
|
|
65
|
+
exactly this reason.
|
|
66
|
+
|
|
67
|
+
## Tests
|
|
68
|
+
|
|
69
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Integration test proves:
|
|
76
|
+
|
|
77
|
+
- Cross-tenant grant + read-back with `source: "manual"`
|
|
78
|
+
- `tier-options` matches `TierMap` keys
|
|
79
|
+
- Idempotent re-grant updates the same aggregate
|
|
80
|
+
- TenantAdmin without SystemAdmin → 403
|
|
81
|
+
- Resolver sees `notes-export` immediately after grant (cache-sync)
|
|
82
|
+
|
|
83
|
+
## Related samples
|
|
84
|
+
|
|
85
|
+
- [apps-cap-billing-demo](/en/samples/apps-cap-billing-demo/) — tier from
|
|
86
|
+
billing webhooks + cap enforcement.
|
|
87
|
+
- [recipes-encrypted-tenant-config](/en/samples/recipes-encrypted-tenant-config/) —
|
|
88
|
+
per-tenant config keys gated by tier.
|
|
89
|
+
|
|
90
|
+
## Source files
|
|
91
|
+
|
|
92
|
+
### src/__tests__/feature.integration.test.ts
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// Tier Admin — integration test.
|
|
96
|
+
//
|
|
97
|
+
// Drives the SystemAdmin-only operator flow end-to-end against the real
|
|
98
|
+
// dispatcher + DB:
|
|
99
|
+
// 1. SystemAdmin sits in his own tenant.
|
|
100
|
+
// 2. He grants a tier to a FOREIGN tenant via set-tenant-tier.
|
|
101
|
+
// 3. get-tenant-tier (cross-tenant read) returns tier:"pro",
|
|
102
|
+
// source:"manual" — proving the grant landed in the target stream
|
|
103
|
+
// and is marked as a manual grant (Stripe sync won't overwrite it).
|
|
104
|
+
// 4. tier-options returns the TierMap keys without hard-coding.
|
|
105
|
+
|
|
106
|
+
import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
|
|
107
|
+
import { randomBytes } from "node:crypto";
|
|
108
|
+
import {
|
|
109
|
+
configValuesTable,
|
|
110
|
+
createConfigFeature,
|
|
111
|
+
createConfigResolver,
|
|
112
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
113
|
+
import { createTenantFeature, tenantEntity } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
114
|
+
import {
|
|
115
|
+
TierEngineHandlers,
|
|
116
|
+
TierEngineQueries,
|
|
117
|
+
tierAssignmentEntity,
|
|
118
|
+
} from "@cosmicdrift/kumiko-bundled-features/tier-engine";
|
|
119
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/db";
|
|
120
|
+
import { findTierResolverUsage, isTierResolverPlugin } from "@cosmicdrift/kumiko-framework/engine";
|
|
121
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
122
|
+
import {
|
|
123
|
+
createTestUser,
|
|
124
|
+
setupTestStack,
|
|
125
|
+
type TestStack,
|
|
126
|
+
testTenantId,
|
|
127
|
+
unsafeCreateEntityTable,
|
|
128
|
+
unsafePushTables,
|
|
129
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
130
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
131
|
+
import { appTierMap, NOTES_EXPORT_FEATURE, notesExportFeature, tierEngineForApp } from "../feature";
|
|
132
|
+
|
|
133
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
134
|
+
const configFeature = createConfigFeature();
|
|
135
|
+
const tenantFeature = createTenantFeature();
|
|
136
|
+
const recipeFeatures = [configFeature, tenantFeature, tierEngineForApp, notesExportFeature];
|
|
137
|
+
|
|
138
|
+
let stack: TestStack;
|
|
139
|
+
|
|
140
|
+
beforeAll(async () => {
|
|
141
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
142
|
+
const configResolver = createConfigResolver({ cipher: encryption });
|
|
143
|
+
|
|
144
|
+
stack = await setupTestStack({
|
|
145
|
+
features: recipeFeatures,
|
|
146
|
+
extraContext: { configResolver, configEncryption: encryption },
|
|
147
|
+
});
|
|
148
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
149
|
+
await unsafeCreateEntityTable(stack.db, tierAssignmentEntity);
|
|
150
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
151
|
+
await createEventsTable(stack.db);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterAll(async () => {
|
|
155
|
+
await stack.cleanup();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
beforeEach(async () => {
|
|
159
|
+
await asRawClient(stack.db).unsafe(
|
|
160
|
+
"TRUNCATE read_tier_assignments, kumiko_events RESTART IDENTITY CASCADE",
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
type GrantResult = { tenantId: string; tier: string; isNew: boolean };
|
|
165
|
+
type GrantRow = { tier: string; source: string };
|
|
166
|
+
type Options = { tiers: ReadonlyArray<string> };
|
|
167
|
+
|
|
168
|
+
test("SystemAdmin grants a foreign tenant the 'pro' tier without billing", async () => {
|
|
169
|
+
const adminTenant = testTenantId(1);
|
|
170
|
+
const targetTenant = testTenantId(2);
|
|
171
|
+
const sysadmin = createTestUser({ id: 1, tenantId: adminTenant, roles: ["SystemAdmin"] });
|
|
172
|
+
|
|
173
|
+
// (1) Grant — cross-tenant write. SystemAdmin sits in adminTenant, the
|
|
174
|
+
// tier-assignment event lands in targetTenant's stream.
|
|
175
|
+
const grant = await stack.http.writeOk<GrantResult>(
|
|
176
|
+
TierEngineHandlers.setTenantTier,
|
|
177
|
+
{ tenantId: targetTenant, tier: "pro" },
|
|
178
|
+
sysadmin,
|
|
179
|
+
);
|
|
180
|
+
expect(grant.tenantId).toBe(targetTenant);
|
|
181
|
+
expect(grant.tier).toBe("pro");
|
|
182
|
+
expect(grant.isNew).toBe(true);
|
|
183
|
+
|
|
184
|
+
// (2) Read-back via get-tenant-tier — cross-tenant query. source:"manual"
|
|
185
|
+
// is the load-bearing field: it tells a future Stripe → tier sync NOT
|
|
186
|
+
// to overwrite the operator-set tier.
|
|
187
|
+
const row = await stack.http.queryOk<GrantRow>(
|
|
188
|
+
TierEngineQueries.getTenantTier,
|
|
189
|
+
{ tenantId: targetTenant },
|
|
190
|
+
sysadmin,
|
|
191
|
+
);
|
|
192
|
+
expect(row.tier).toBe("pro");
|
|
193
|
+
expect(row.source).toBe("manual");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("tier-options returns the configured TierMap keys (no hard-coding)", async () => {
|
|
197
|
+
const sysadmin = createTestUser({
|
|
198
|
+
id: 2,
|
|
199
|
+
tenantId: testTenantId(3),
|
|
200
|
+
roles: ["SystemAdmin"],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const opts = await stack.http.queryOk<Options>(TierEngineQueries.tierOptions, {}, sysadmin);
|
|
204
|
+
|
|
205
|
+
expect(opts.tiers).toEqual(Object.keys(appTierMap));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("upsert is idempotent — re-granting updates the same aggregate", async () => {
|
|
209
|
+
const sysadmin = createTestUser({
|
|
210
|
+
id: 3,
|
|
211
|
+
tenantId: testTenantId(4),
|
|
212
|
+
roles: ["SystemAdmin"],
|
|
213
|
+
});
|
|
214
|
+
const target = testTenantId(5);
|
|
215
|
+
|
|
216
|
+
const first = await stack.http.writeOk<GrantResult>(
|
|
217
|
+
TierEngineHandlers.setTenantTier,
|
|
218
|
+
{ tenantId: target, tier: "free" },
|
|
219
|
+
sysadmin,
|
|
220
|
+
);
|
|
221
|
+
expect(first.isNew).toBe(true);
|
|
222
|
+
|
|
223
|
+
const second = await stack.http.writeOk<GrantResult>(
|
|
224
|
+
TierEngineHandlers.setTenantTier,
|
|
225
|
+
{ tenantId: target, tier: "pro" },
|
|
226
|
+
sysadmin,
|
|
227
|
+
);
|
|
228
|
+
expect(second.isNew).toBe(false);
|
|
229
|
+
expect(second.tier).toBe("pro");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("TenantAdmin without SystemAdmin role cannot grant a foreign tenant a tier", async () => {
|
|
233
|
+
const tenantAdmin = createTestUser({
|
|
234
|
+
id: 4,
|
|
235
|
+
tenantId: testTenantId(6),
|
|
236
|
+
roles: ["TenantAdmin"],
|
|
237
|
+
});
|
|
238
|
+
const target = testTenantId(7);
|
|
239
|
+
|
|
240
|
+
const res = await stack.http.write(
|
|
241
|
+
TierEngineHandlers.setTenantTier,
|
|
242
|
+
{ tenantId: target, tier: "pro" },
|
|
243
|
+
tenantAdmin,
|
|
244
|
+
);
|
|
245
|
+
expect(res.status).toBe(403);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("set-tenant-tier lights up the tenant's toggleable feature in the resolver — same request, not a projection re-read", async () => {
|
|
249
|
+
const adminTenant = testTenantId(8);
|
|
250
|
+
const targetTenant = testTenantId(9);
|
|
251
|
+
const sysadmin = createTestUser({ id: 5, tenantId: adminTenant, roles: ["SystemAdmin"] });
|
|
252
|
+
|
|
253
|
+
// Build the resolver BEFORE the grant, so its in-memory cache is warm with
|
|
254
|
+
// the pre-grant state. The only path for notes-export to appear afterwards
|
|
255
|
+
// is the synchronous onAssigned cache update inside set-tenant-tier — not a
|
|
256
|
+
// fresh projection read at build time.
|
|
257
|
+
const usage = findTierResolverUsage(recipeFeatures);
|
|
258
|
+
if (!usage) throw new Error("setup failure: no tier-resolver plugin registered");
|
|
259
|
+
if (!isTierResolverPlugin(usage.options)) {
|
|
260
|
+
throw new Error("setup failure: tier-resolver registration has no build() plugin");
|
|
261
|
+
}
|
|
262
|
+
const resolver = await usage.options.build({
|
|
263
|
+
db: stack.db,
|
|
264
|
+
registry: stack.registry,
|
|
265
|
+
});
|
|
266
|
+
expect(resolver(targetTenant).has(NOTES_EXPORT_FEATURE)).toBe(false);
|
|
267
|
+
|
|
268
|
+
// SystemAdmin grants "pro" cross-tenant. set-tenant-tier writes through the
|
|
269
|
+
// event-store executor, which skips the postSave hook that would otherwise
|
|
270
|
+
// invalidate the cache — so the write calls onAssigned explicitly.
|
|
271
|
+
await stack.http.writeOk(
|
|
272
|
+
TierEngineHandlers.setTenantTier,
|
|
273
|
+
{ tenantId: targetTenant, tier: "pro" },
|
|
274
|
+
sysadmin,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Same process, no rebuild / replay / restart: the resolver already sees
|
|
278
|
+
// the pro features. Drop onAssigned from createSetTenantTierWrite and this
|
|
279
|
+
// assertion stays false — the projection updates, the cache does not.
|
|
280
|
+
expect(resolver(targetTenant).has(NOTES_EXPORT_FEATURE)).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### src/feature.ts
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// Tier Admin Sample
|
|
288
|
+
//
|
|
289
|
+
// Shows the SystemAdmin-only operator flow: assigning a tier to *any*
|
|
290
|
+
// tenant without a billing purchase. The app side is intentionally tiny —
|
|
291
|
+
// the recipe is the bundled tier-engine itself, configured with the app's
|
|
292
|
+
// own TierMap. The integration test exercises the cross-tenant grant
|
|
293
|
+
// (set-tenant-tier), the read-back (get-tenant-tier returning
|
|
294
|
+
// source:"manual"), and the option-list (tier-options) end-to-end.
|
|
295
|
+
|
|
296
|
+
import {
|
|
297
|
+
createTierEngineFeature,
|
|
298
|
+
type TierMap,
|
|
299
|
+
} from "@cosmicdrift/kumiko-bundled-features/tier-engine";
|
|
300
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
301
|
+
|
|
302
|
+
// --- App caps ---
|
|
303
|
+
//
|
|
304
|
+
// Each app picks its own cap dimensions. Here a tiny example: how many
|
|
305
|
+
// notes a tenant may keep. The TierMap is generic in this cap-shape so
|
|
306
|
+
// downstream code stays type-safe end-to-end.
|
|
307
|
+
|
|
308
|
+
export type AppCaps = { readonly maxNotes: number };
|
|
309
|
+
|
|
310
|
+
// --- The toggleable feature a paid tier unlocks ---
|
|
311
|
+
//
|
|
312
|
+
// A `r.toggleable()` feature shows up in a tenant's effective-features set
|
|
313
|
+
// exactly when its tier lists it. "pro" lists it below; "free" does not.
|
|
314
|
+
// This is what makes the cache-sync invariant observable: granting "pro"
|
|
315
|
+
// must light this feature up in the resolver the same request, not after a
|
|
316
|
+
// refresh, replay, or restart.
|
|
317
|
+
|
|
318
|
+
export const NOTES_EXPORT_FEATURE = "notes-export";
|
|
319
|
+
|
|
320
|
+
export const notesExportFeature = defineFeature(NOTES_EXPORT_FEATURE, (r) => {
|
|
321
|
+
r.toggleable({ default: false });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// --- Tier map ---
|
|
325
|
+
//
|
|
326
|
+
// "free" + "pro" — the operator picks one of these names when granting
|
|
327
|
+
// a tier manually. "pro" unlocks the notes-export toggleable feature; the
|
|
328
|
+
// integration test grants "pro" and then reaches that feature in the same
|
|
329
|
+
// request, proving the cache-sync invariant end-to-end.
|
|
330
|
+
|
|
331
|
+
export const appTierMap: TierMap<AppCaps> = {
|
|
332
|
+
free: { features: [], caps: { maxNotes: 5 } },
|
|
333
|
+
pro: { features: [NOTES_EXPORT_FEATURE], caps: { maxNotes: 100 } },
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// --- Configured tier-engine ---
|
|
337
|
+
//
|
|
338
|
+
// `defaultTier: "free"` means every new tenant starts on free via the
|
|
339
|
+
// `inTransaction` entity hook the tier-engine registers — no app code
|
|
340
|
+
// needed. `tierMap` makes `tier-options` return ["free", "pro"] so the
|
|
341
|
+
// tier-admin screen can populate its picker without hard-coding.
|
|
342
|
+
|
|
343
|
+
export const tierEngineForApp = createTierEngineFeature<AppCaps>({
|
|
344
|
+
defaultTier: "free",
|
|
345
|
+
tierMap: appTierMap,
|
|
346
|
+
});
|
|
347
|
+
```
|