Skip to content

Configuration Layers — Ein Ort, eine Deklaration

Prinzip: Jede konfigurierbare Größe hat EINE Deklaration an EINEM Ort

Der Feature-Autor deklariert eine Variable einmal — die Deklaration legt fest:

  • auf welcher Ebene sie gesetzt werden darf (Hardcoded, App-Boot, System, Tenant, User, Per-Request),
  • welche Defaults gelten (Framework-Default, App-Override),
  • welche Bounds gelten (damit ein Tenant-Admin oder Caller keinen DoS-Wert setzen kann),
  • wie die Resolution-Cascade läuft (Request → Tenant → System → App → Framework).

Der Handler-Code fragt danach neutral per ctx.config(key) — ohne zu wissen, auf welcher Ebene der Wert gerade herkommt.

Die drei Entscheidungs-Fragen

Beim Einführen einer neuen Variable beantwortet der Dev diese drei Fragen:

Frage 1: Kann der Wert überhaupt variieren?

  • Nein → Hardcoded Konstante im Code. Fertig.
  • Ja → weiter zu Frage 2.

Frage 2: Wer ändert ihn?

ÄndererEbeneMechanik
NiemandHardcodedKonstante im Modul
App-Dev einmal beim BuildFeature-OptionProp am Field/Feature (createImageField({ maxSize }))
App-Dev einmal beim DeployApp-BootbuildServer({ config: { "files.maxSize": … } })
Ops-Team zur Laufzeit (ohne Deploy)System-Configr.config() mit createSystemConfig(...)
Tenant-AdminTenant-Configr.config() mit createTenantConfig(...)
End-User (eigene Präferenz)User-Configr.config() mit createUserConfig(...)
Caller pro RequestRequest-ParameterQuery-Param mit Framework-Bound-Check

Frage 3: Hängt Geld/Business dran?

  • Nein → direkte Lookup-Cascade.

  • Ja (z.B. „zahlender Tenant bekommt mehr”) → Computed-Value via Business-Feature. Der Config-Resolver ruft eine Funktion auf, die z.B. den Subscription-Plan liest:

    r.config({
    keys: {
    maxUploadSizeMB: createTenantConfig("number", {
    default: 10,
    bounds: { min: 1, max: 10_000 },
    computed: async (ctx) => {
    const plan = await ctx.query("subscription:get-plan", {});
    return plan.features.maxUploadSizeMB;
    },
    }),
    },
    });

    Der Handler-Code bleibt unverändert: await ctx.config(filesConfig.maxUploadSizeMB). Die Geld-Logik steckt in der Variablen-Deklaration, nicht im Handler.

Die sechs Ebenen mit Beispiel

1. Hardcoded

Konstante im Code. Ändert sich nie oder nur mit Deploy + Migration.

packages/framework/src/files/types.ts
export function buildStorageKey(tenantId, entityType, entityId, fieldName, fileName, uuid) {
const ext = fileName.split(".").pop() ?? "bin";
return `${tenantId}/${entityType}/${entityId}/${fieldName}/${uuid}.${ext}`;
}

Warum hardcoded: Format ist Tenant-Isolation-kritisch. Änderung = Daten-Migration über alle bestehenden Keys. Nicht verhandelbar pro Tenant / pro Deploy.

Daumenregel: Werte die Security-Bounds sind (RFC-Konformität, Header-Size-Limits, Storage-Key-Struktur) bleiben hier.

2. Feature-Option (App-Boot, direkt am Feature-Aufruf)

App-Dev setzt den Wert einmal beim Feature-Aufruf. Kein DB-Roundtrip, kein Override zur Laufzeit.

// In einem Feature:
r.entity("user", createEntity({
fields: {
avatar: createImageField({
maxSize: "2mb", // Feature-Option
accept: ["jpg", "png"], // Feature-Option
}),
},
}));

Warum Feature-Option: Das Schema-Shape hängt am Feature-Code. Pro Tenant oder pro Deploy unterschiedlich zu machen erzeugt Inkonsistenz in der Feature-API. App-Dev kennt den Use-Case.

Daumenregel: Werte die zum Feature-Vertrag gehören (Validierung, Field-Shape, Feature-spezifische Limits) — nicht die operative Policy.

3. App-Boot-Override (per buildServer)

App-Dev setzt pro Deploy einen Default der den Framework-Default überschreibt. Keine DB involviert.

apps/web/server.ts
buildServer({
files: {
storageProvider: createS3ProviderFromEnv(),
signedUrlDefaultExpirySeconds: 900,
},
config: {
// Override des Framework-Defaults, bevor System/Tenant-Rows gelesen werden:
"files:config:max-upload-size-mb": 50,
},
});

Warum App-Boot: Framework-Default 10 MB passt nicht für jede App — ein Foto-App-Deploy will vielleicht 200 MB. App-Dev setzt das, ohne eine System-Row seeden zu müssen.

Daumenregel: Werte die dein Deploy von einem generischen Framework-Default unterscheiden — aber nicht pro Tenant variieren sollen.

4. System-Config (Ops-Team zur Laufzeit)

Ops-Team kippt einen Wert ohne Deploy. Betrifft alle Tenants gleichzeitig.

r.config({
keys: {
// Feature-Flag: neue Checkout-Pipeline für alle Tenants aktivieren/deaktivieren
newCheckoutEnabled: createSystemConfig("boolean", {
default: false,
read: access.admin,
write: access.systemAdmin,
}),
},
});
// Handler:
const enabled = await ctx.config(config.newCheckoutEnabled);
if (enabled) { /* neuer Pfad */ }

Warum System-Config: Feature-Rollouts, Kill-Switches, Deploy-weite Toggles. Ops-Team soll sofort reagieren können (Incident, Rollback einer Feature).

Daumenregel: Werte die Ops ohne Dev-Beteiligung flippen darf — und bei denen die Änderung für alle Tenants gleichzeitig gelten soll.

5. Tenant-Config (Tenant-Admin zur Laufzeit)

Tenant-Admin setzt Wert pro Tenant. Cascade: Tenant-Row → System-Row → Default.

r.config({
keys: {
maxUploadSizeMB: createTenantConfig("number", {
default: 10,
bounds: { min: 1, max: 10_000 },
}),
// Geld-Variante:
storageQuotaGB: createTenantConfig("number", {
default: 5,
bounds: { min: 1, max: 10_000 },
computed: async (ctx) => {
const plan = await ctx.query("subscription:get-plan", {});
return plan.features.storageQuotaGB;
},
}),
},
});

Warum Tenant-Config: Das ist der Default für 90 % der Business-Apps. Limits die pro Kunde variieren (Plan-gebunden, Branding-Werte, Präferenzen).

Daumenregel: Im Zweifel → Tenant-Config. Upgrades/Downgrades der Cascade sind einfacher als Downgrades (User → Tenant) oder Upgrades (Tenant → System).

6. User-Config (End-User persönlich)

User setzt seine eigene Präferenz. Cascade: User-Row → Tenant-Row → System-Row → Default.

r.config({
keys: {
showNetPrices: createUserConfig("boolean", { default: true }),
},
});

Warum User-Config: Persönliche UX-Einstellungen (Sprache, Theme, Darstellungs-Präferenzen, Benachrichtigungs-Frequenz).

Daumenregel: Nur für UX-Präferenzen. Niemals für Business-Limits (wenn User seine eigene Quota setzen könnte → Security-Lücke).

7. Per-Request-Parameter (Caller pro Call)

Client entscheidet pro Request. Framework-Bound schützt vor Missbrauch. Deny-by-default: ein Key muss in seiner Deklaration explizit allowPerRequest: true setzen, sonst wirft resolveConfigOrParam.

// Deklaration — explizites Opt-in:
r.config({
keys: {
signedUrlExpirySeconds: createTenantConfig("number", {
default: 900,
bounds: { min: 60, max: 7 * 24 * 3600 },
allowPerRequest: true, // ← Client darf pro Request überschreiben
}),
maxUploadSizeMB: createTenantConfig("number", {
default: 10,
bounds: { min: 1, max: 10_000 },
// kein allowPerRequest → wirft bei resolveConfigOrParam(..., paramValue)
}),
},
});
// Route: resolveConfigOrParam clampt gegen den Config-Bound
// GET /files/:id/download-url?expiresSeconds=3600
const expirySeconds = await resolveConfigOrParam(
ctx,
filesConfig.signedUrlExpirySeconds,
c.req.query("expiresSeconds"),
);
// → number, garantiert innerhalb der deklarierten bounds

Warum Opt-in: Ohne explizite Freigabe könnte jeder Route-Handler, der resolveConfigOrParam aufruft, jeden Config-Key per Query-Param überschreiben — auch solche, die nie dafür gedacht waren (Quota, Rate-Limits, Feature-Flags). Opt-in zwingt den Feature-Dev zur bewussten Entscheidung.

Type-Restriktionen (Sicherheits-Schichten zusätzlich zum Opt-in):

TypePer-Request-Override (mit allowPerRequest: true)Warum
number✅ mit hard-clamp gegen boundsPrimitive, Bound-Check verhindert Missbrauch
boolean"true"/"1" → true, sonst falsePrimitive, nur zwei Zustände möglich
select✅ nur Werte aus der options-Whitelist; sonst Config-FallbackEnum-artig, Injection-frei
textpermanent gesperrtallowPerRequest: true auf text wirft bei BootQuery-Param-Strings könnten XSS/SQL/Shell-Fragmente enthalten; der Helper ist Parser, kein Sanitizer
encrypted: truepermanent gesperrtallowPerRequest: true + encrypted: true wirft bei BootSecret-Values werden nicht über Query-Params transportiert

Wenn du für text-Keys wirklich einen Wert pro Request akzeptieren musst, bau das explizit in der Route mit eigener Escape-Strategie beim Consumer (HTML-Encoder, SQL-Parameter-Binding, Shell-Quoter) — und nicht über resolveConfigOrParam.

Daumenregel: Nur wenn das in-the-moment-Entscheidung ist. Alles mit längerer Halbwertszeit gehört in Config-Ebenen 4–6.

Resolution-Cascade

Wenn der Handler ctx.config(key) aufruft, löst der Resolver in dieser Reihenfolge auf:

Per-Request-Override (wenn erlaubt + im Bound)
User-Row (wenn scope:user)
Tenant-Row (wenn scope:tenant oder :user)
System-Row (immer geprüft)
App-Boot-Override (wenn gesetzt)
Feature-Default (in r.config Declaration)
Framework-Default (hardcoded)

Bound-Check greift auf jeder Ebene wo ein externer Akteur den Wert gesetzt hat (Tenant, User, Per-Request). App-Boot und System-Config sind ops-seitig und umgehen den Clamp — dafür gibt es den Review-Prozess.

Anti-Patterns

Anti-PatternWarum falschWas statt dessen
if (process.env.MAX_FILES) { … } im HandlerConfig-Drift über 15 Stellen, keine Typ-Sicherheit, kein Tenant-Scopectx.config(key) mit Variable in r.config()
Tenant-Admin darf signedUrlExpirySeconds auf 30 Tage setzenWeder Bound noch Begründung — Leaked URL wird 30 Tage gültigbounds: { max: 7*24*3600 } in der Deklaration
User setzt seine eigene Storage-QuotaSecurity-Lücke (User umgeht Limits)User-Config nur für UX-Präferenzen, Business-Limits in Tenant-Config
Geld-Logik im Handler: if (tenant.plan === "pro") { max = 100 } else { max = 10 }Feature-Code kennt Plan-Shape, bricht bei neuem Plancomputed-Funktion in der Config-Deklaration
Dieselbe Variable an 3 Stellen: Feature-Option + r.config + ENVKein klarer Resolution-PfadEinmal deklarieren, alle Ebenen in einer Deklaration

Was Kumiko hat (Stand 2026-04-18)

Das Config-System ist komplett — alle Erweiterungen landed:

EbeneMechanik
r.config() mit drei Scopes (system/tenant/user)DB-gespeicherte Rows, Cascade user→tenant→system→default
Typsichere Reads via ctx.config(handle)Generic ConfigKeyHandle<T>
Access-Control per Rolle (read/write)access: { read, write } pro Key
bounds: { min, max } für numeric keysConditional-Type (nur type="number"), Boot-Check, Hard-Reject in set.write.ts
App-Boot-OverridecreateConfigResolver({ appOverrides }), validateAppOverrides(registry, overrides) — wirft synchronous bei unbekannten Keys, Type-Mismatches, Bounds-Violations, und bei Kollision mit einem computed Key (Plan-Logik darf nicht silent umgangen werden)
computed: async (ctx) => valuePlan-/Subscription-Logik in der Deklaration statt im Handler. Row gewinnt über computed (Admin-Intent > Plan-Default). Nicht kombinierbar mit encrypted oder App-Override
resolveConfigOrParam(ctx, handle, paramValue, options?)Route-Helper: deny-by-default per allowPerRequest: true. options.onClamp für Audit-Logging bei hard-clamp im number-case
resolver.getWithSource(...)Ops-Introspection: returniert { value, source } mit source ∈ user-row / tenant-row / system-row / app-override / computed / default / missing. Für Debug-Tooling “warum ist mein Wert X?”
Boot-Validation für KonsistenzvalidateConfigKeyBounds (min>max, default<min/max, bounds-on-non-number), validateConfigKeyComputed (encrypted+computed → reject), validateConfigKeyAllowPerRequest (allowPerRequest + text / encrypted → reject)

Neue Features dürfen und sollten von Tag 1 an alle Ebenen nutzen.

Debuggability: “warum ist mein Wert X?”

Ops-Support-Frage ist immer dieselbe: ein Wert im System stimmt nicht, und der Dev muss herausfinden aus welcher Ebene er kam. Dafür gibt’s resolver.getWithSource:

const traced = await configResolver.getWithSource(
"orders:config:max-order-count",
keyDef,
user.tenantId,
user.id,
db,
);
// { value: 77, source: "tenant-row" } → Admin hat manuell gesetzt
// { value: 100, source: "default" } → Framework-Default, nichts konfiguriert
// { value: 50, source: "computed" } → Plan-basiert (Subscription-Feature)
// { value: 333, source: "app-override" } → buildServer-override gewinnt

Der Pfad ist explizit für Support/Debugging gedacht — nicht für Hot-Path-Handler (die nutzen ctx.config(handle) und bekommen nur den Wert).

Audit-Logging für clamped Werte

Per-Request-Clamps sind silent — Caller sieht 1000 statt 9999, ohne Hinweis. Für Debugging/Telemetrie:

const expiry = await resolveConfigOrParam(ctx, signedUrlExpiry, c.req.query("expiresSeconds"), {
onClamp: ({ key, original, clamped, max }) => {
ctx.logger?.warn("config.per_request_clamped", { key, original, clamped, max });
// oder: ctx.metrics.counter("config_clamps", { key })
},
});

onClamp fires nur wenn der Wert tatsächlich gekappt wurde — identische In-Bound-Werte bleiben stumm.

Daumenregel — das eine Merksatz

Variable haben? → r.config(). Im Zweifel → createTenantConfig. Security-Bound dran? → Hardcoded.

Diese drei Zeilen decken 95 % der Entscheidungen. Der Rest sind die Sonderfälle (Ops-Kill-Switch → System, UX-Präferenz → User, In-the-moment → Per-Request).