Skip to content

Recipe: Tenant-Settings

What this shows: how a per-tenant Currency + Locale default fills a money/locale field on create — without hard-coding "EUR"/"de" as a field literal, the mistake that cost a sibling project (phronexsis) a retrofit once a second tenant needed a different default.

import {
createEntity,
createMoneyField,
createSelectField,
createTextField,
defineFeature,
} from "@cosmicdrift/kumiko-framework/engine";
import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
export const invoiceEntity = createEntity({
table: "read_invoices",
fields: {
title: createTextField({ required: true }),
amount: createMoneyField({ required: true }),
language: createSelectField({ options: ["en", "de", "fr"] as const }),
},
});
export const invoiceFeature = defineFeature("invoice", (r) => {
r.entity("invoice", invoiceEntity);
r.writeHandler(
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
access: { roles: ["Admin"] },
currencyFields: ["amount"],
localeField: "language",
}),
);
// update/delete/list/detail stay the plain defineEntity*Handler factories —
// only create needs the tenant-default fill-in.
});

Mount createTenantSettingsFeature() alongside — it provisions the two config keys (tenant-settings:config:currency, tenant-settings:config:locale) that defineCreateWithTenantDefaults reads:

import { createTenantSettingsFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
const features = [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature];

createTenantSettingsFeature(opts) takes currencies, defaultCurrency, defaultLocale, write — an app with its own supported-currency list or role vocabulary overrides those instead of forking the feature.

buildInsertSchema always makes currency required inside a money field’s value object, even when the field itself is optional — a caller literally cannot submit { amount: 1000 } against the plain defineEntityCreateHandler. defineCreateWithTenantDefaults swaps in its own schema (.extend() over the same buildInsertSchema output, with just the declared currencyFields relaxed) so the caller can omit currency, then fills it from ctx.config(TenantSettingsConfig.currency) before delegating to the same executor.create() the generic factory uses.

Both keys declare mask, so they surface in the self-populating Settings-Hub (Tenant-Audience) without a hand-written r.screen/r.nav — same mechanism as managed-config. A TenantAdmin changes them from Settings → Tenant → Default Currency / Default Locale.

  1. No tenant override yet → a new invoice gets the feature’s own default ("EUR"/"en", set via createTenantSettingsFeature()’s own defaults).
  2. Tenant sets currency to "CHF" and locale to "de" via the Settings-Hub (or config:write:set directly).
  3. A new invoice that doesn’t specify amount.currency/language picks up "CHF"/"de" — no entity code changed, no second migration.
  4. A caller that does specify amount.currency still wins — the tenant setting only fills gaps, it never overrides an explicit value.
Terminal window
bun test src/__tests__/feature.integration.test.ts

Covers the happy path above (tenant defaults fill gaps; explicit caller values still win). Creating without a tenant-settings mount is not a supported fallback — mount the feature (or the recipe’s stack) first.

  • Timezone, number-format, week-start — deliberately out of scope; add them as further config keys on the same feature when a real consumer needs them, don’t speculate ahead of a use case.
  • A dedicated locale-options selectlocale is a free-text ISO-639-1(-region) field validated by regex, not a fixed enum, because the framework doesn’t own which languages an app supports. An app with a fixed language list builds its own createTenantConfig("select", {...}) key if it wants a dropdown instead of TenantSettingsConfig.locale.
  • managed-config — the mask + Settings-Hub mechanism these keys rely on, explained in depth.

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):

// Consuming tenant-settings: an `invoice` entity whose `amount` (money) and
// `language` (locale) are auto-filled from the tenant's currency/locale
// setting when the caller omits them — instead of hard-coding "EUR"/"en" on
// the field, the mistake this recipe exists to prevent (solon#P19).
import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-features/tenant-settings";
import {
createEntity,
createMoneyField,
createSelectField,
createTextField,
defineFeature,
} from "@cosmicdrift/kumiko-framework/engine";
export const invoiceEntity = createEntity({
table: "read_invoices",
fields: {
title: createTextField({ required: true }),
amount: createMoneyField({ required: true }),
language: createSelectField({ options: ["en", "de", "fr"] as const }),
},
});
const ACCESS = { roles: ["Admin"] } as const;
export const invoiceFeature = defineFeature("invoice", (r) => {
r.entity("invoice", invoiceEntity);
r.writeHandler(
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
access: ACCESS,
currencyFields: ["amount"],
localeField: "language",
}),
);
});

📄 On GitHub: samples/recipes/tenant-settings/src/feature.ts