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?
| Änderer | Ebene | Mechanik |
|---|---|---|
| Niemand | Hardcoded | Konstante im Modul |
| App-Dev einmal beim Build | Feature-Option | Prop am Field/Feature (createImageField({ maxSize })) |
| App-Dev einmal beim Deploy | App-Boot | buildServer({ config: { "files.maxSize": … } }) |
| Ops-Team zur Laufzeit (ohne Deploy) | System-Config | r.config() mit createSystemConfig(...) |
| Tenant-Admin | Tenant-Config | r.config() mit createTenantConfig(...) |
| End-User (eigene Präferenz) | User-Config | r.config() mit createUserConfig(...) |
| Caller pro Request | Request-Parameter | Query-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.
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.
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=3600const expirySeconds = await resolveConfigOrParam( ctx, filesConfig.signedUrlExpirySeconds, c.req.query("expiresSeconds"),);// → number, garantiert innerhalb der deklarierten boundsWarum 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):
| Type | Per-Request-Override (mit allowPerRequest: true) | Warum |
|---|---|---|
number | ✅ mit hard-clamp gegen bounds | Primitive, Bound-Check verhindert Missbrauch |
boolean | ✅ "true"/"1" → true, sonst false | Primitive, nur zwei Zustände möglich |
select | ✅ nur Werte aus der options-Whitelist; sonst Config-Fallback | Enum-artig, Injection-frei |
text | ❌ permanent gesperrt — allowPerRequest: true auf text wirft bei Boot | Query-Param-Strings könnten XSS/SQL/Shell-Fragmente enthalten; der Helper ist Parser, kein Sanitizer |
encrypted: true | ❌ permanent gesperrt — allowPerRequest: true + encrypted: true wirft bei Boot | Secret-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-Pattern | Warum falsch | Was statt dessen |
|---|---|---|
if (process.env.MAX_FILES) { … } im Handler | Config-Drift über 15 Stellen, keine Typ-Sicherheit, kein Tenant-Scope | ctx.config(key) mit Variable in r.config() |
Tenant-Admin darf signedUrlExpirySeconds auf 30 Tage setzen | Weder Bound noch Begründung — Leaked URL wird 30 Tage gültig | bounds: { max: 7*24*3600 } in der Deklaration |
| User setzt seine eigene Storage-Quota | Security-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 Plan | computed-Funktion in der Config-Deklaration |
| Dieselbe Variable an 3 Stellen: Feature-Option + r.config + ENV | Kein klarer Resolution-Pfad | Einmal deklarieren, alle Ebenen in einer Deklaration |
Was Kumiko hat (Stand 2026-04-18)
Das Config-System ist komplett — alle Erweiterungen landed:
| Ebene | Mechanik |
|---|---|
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 keys | Conditional-Type (nur type="number"), Boot-Check, Hard-Reject in set.write.ts |
| App-Boot-Override | createConfigResolver({ 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) => value | Plan-/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 Konsistenz | validateConfigKeyBounds (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 gewinntDer 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).