Skip to content

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.

  • tier-engine:write:set-tenant-tier — a SystemAdmin assigns a tier to any tenant, cross-tenant. Stamps source: "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 — a r.toggleable() feature the pro tier unlocks. It appears in a tenant’s effective-features set only when its tier lists it (pro does, free does not).
  • Cache-sync invariant — the manual grant updates the resolver cache the same request, not just the projection. notes-export is in the tenant’s effective set immediately after the set call — same request, before any cache refresh, replay, or restart.
config → tenant config (tier-engine dependency)
tenant → tenant records for cross-tenant grants
tier-engine → set-tenant-tier, get-tenant-tier, tier-options
notes-export → r.toggleable() domain feature unlocked by pro tier
  1. SystemAdmin calls set-tenant-tier for a foreign tenant with tier: "pro" → event lands in the target tenant’s stream, source: "manual".
  2. get-tenant-tier read-back confirms tier + source (billing sync must not overwrite manual grants).
  3. tier-options returns keys from your static TierMap — no hard-coded tier list in the UI.
  4. Resolver built before the grant now reports notes-export in the tenant’s effective set — same process, no rebuild (cache-sync proof).

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.

Terminal window
bun test src/__tests__/feature.integration.test.ts

Integration test proves:

  • Cross-tenant grant + read-back with source: "manual"
  • tier-options matches TierMap keys
  • Idempotent re-grant updates the same aggregate
  • TenantAdmin without SystemAdmin → 403
  • Resolver sees notes-export immediately after grant (cache-sync)

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