Tier Admin
Manually assign a pricing tier to a tenant — without a billing purchase and without writing the projection by hand. The recipe shows the SystemAdmin-only operator flow plus the subtlety that makes it correct: the grant updates the in-memory resolver cache synchronously, so toggleable features unlock in the same request.
What it shows
Section titled “What it shows”tier-engine:write:set-tenant-tier— a SystemAdmin assigns a tier to any tenant, cross-tenant. Stampssource: "manual"so a future Stripe → tier sync won’t overwrite the grant.tier-engine:query:get-tenant-tier— SystemAdmin reads back which tier a tenant is on plus its source ("manual"vs"billing").tier-engine:query:tier-options— lists the configured tier names so the admin UI doesn’t have to hard-code them.notes-export— ar.toggleable()feature theprotier unlocks. It appears in a tenant’s effective-features set only when its tier lists it (prodoes,freedoes not).- Cache-sync invariant — the manual grant updates the resolver cache
the same request, not just the projection.
notes-exportis in the tenant’s effective set immediately after the set call — same request, before any cache refresh, replay, or restart.
Feature composition
Section titled “Feature composition”config → tenant config (tier-engine dependency)tenant → tenant records for cross-tenant grantstier-engine → set-tenant-tier, get-tenant-tier, tier-optionsnotes-export → r.toggleable() domain feature unlocked by pro tier- SystemAdmin calls
set-tenant-tierfor a foreign tenant withtier: "pro"→ event lands in the target tenant’s stream,source: "manual". get-tenant-tierread-back confirms tier + source (billing sync must not overwrite manual grants).tier-optionsreturns keys from your staticTierMap— no hard-coded tier list in the UI.- Resolver built before the grant now reports
notes-exportin the tenant’s effective set — same process, no rebuild (cache-sync proof).
Why the cache invariant matters
Section titled “Why the cache invariant matters”set-tenant-tier writes through the event-store executor directly. That
path bypasses the postSave entity-hook the resolver normally uses to
invalidate the cache after a tier-assignment change. Without an explicit
cache update the grant would persist (next request would see it) but
this request still sees the old tier — surprising for an operator who
just clicked “set tier to pro”.
The feature wires onAssigned into createSetTenantTierWrite for
exactly this reason.
bun test src/__tests__/feature.integration.test.tsIntegration test proves:
- Cross-tenant grant + read-back with
source: "manual" tier-optionsmatchesTierMapkeys- Idempotent re-grant updates the same aggregate
- TenantAdmin without SystemAdmin → 403
- Resolver sees
notes-exportimmediately after grant (cache-sync)
Related samples
Section titled “Related samples”- apps-cap-billing-demo — tier from billing webhooks + cap enforcement.
- recipes-encrypted-tenant-config — per-tenant config keys gated by tier.
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// Tier Admin Sample//// Shows the SystemAdmin-only operator flow: assigning a tier to *any*// tenant without a billing purchase. The app side is intentionally tiny —// the recipe is the bundled tier-engine itself, configured with the app's// own TierMap. The integration test exercises the cross-tenant grant// (set-tenant-tier), the read-back (get-tenant-tier returning// source:"manual"), and the option-list (tier-options) end-to-end.
import { createTierEngineFeature, type TierMap,} from "@cosmicdrift/kumiko-bundled-features/tier-engine";import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
// --- App caps ---//// Each app picks its own cap dimensions. Here a tiny example: how many// notes a tenant may keep. The TierMap is generic in this cap-shape so// downstream code stays type-safe end-to-end.
export type AppCaps = { readonly maxNotes: number };
// --- The toggleable feature a paid tier unlocks ---//// A `r.toggleable()` feature shows up in a tenant's effective-features set// exactly when its tier lists it. "pro" lists it below; "free" does not.// This is what makes the cache-sync invariant observable: granting "pro"// must light this feature up in the resolver the same request, not after a// refresh, replay, or restart.
export const NOTES_EXPORT_FEATURE = "notes-export";
export const notesExportFeature = defineFeature(NOTES_EXPORT_FEATURE, (r) => { r.toggleable({ default: false });});
// --- Tier map ---//// "free" + "pro" — the operator picks one of these names when granting// a tier manually. "pro" unlocks the notes-export toggleable feature; the// integration test grants "pro" and then reaches that feature in the same// request, proving the cache-sync invariant end-to-end.
export const appTierMap: TierMap<AppCaps> = { free: { features: [], caps: { maxNotes: 5 } }, pro: { features: [NOTES_EXPORT_FEATURE], caps: { maxNotes: 100 } },};
// --- Configured tier-engine ---//// `defaultTier: "free"` means every new tenant starts on free via the// `inTransaction` entity hook the tier-engine registers — no app code// needed. `tierMap` makes `tier-options` return ["free", "pro"] so the// tier-admin screen can populate its picker without hard-coding.
export const tierEngineForApp = createTierEngineFeature<AppCaps>({ defaultTier: "free", tierMap: appTierMap,});📄 On GitHub: samples/recipes/tier-admin/src/feature.ts