Identity-Layer
Fünf Features, die zusammen die Basis für jede Multi-Tenant-App
bilden. Werden im Auto-Mode (auth: {...}) automatisch geladen — du
musst sie nur kennen, wenn du eigene Hooks an User/Tenant anhängst
oder die Login-UI ersetzen willst.
auth-email-password
Status: ✅ Stable
Wofür: Klassischer Email + Passwort-Login mit signierten JWTs.
Liefert Login-Endpoint, Session-Cookies, Logout, Password-Hashing
(Argon2id), Reset-Tokens. Plus React-Komponenten (emailPasswordClient,
useSession, DefaultTopbarActions) für die UI-Seite.
Wie es funktioniert: Beim Login mintet der Server einen JWT mit
userId + tenantId + roles und setzt ihn als HttpOnly-Cookie. Jeder
weitere Request liest den Cookie in der Auth-Middleware, validiert die
Signatur, und stellt ctx.user für Handler bereit. Tokens haben
eingebaute Refresh-Logik (Sliding-Window). Multi-Tenant: User können in
mehreren Tenants Mitglied sein, der TenantSwitcher schreibt nur den
tenantId-Anspruch um.
Wann nicht: SSO-Only-Apps (SAML, OIDC) — dafür kommt ein eigenes
Bundle. Public-only-Apps ohne Login → einfach weglassen, mit
anonymousAccess arbeiten.
Beispiel:
// Server: aktivieren via auth-Option (composeFeatures kümmert sich um den Rest)import { runDevApp } from "@kumiko/dev-server";
await runDevApp({ features: [myFeature], auth: { jwtSecret: process.env["JWT_SECRET"]! },});// Client: Login-Form + Session-Hookimport { emailPasswordClient, useSession } from "@kumiko/bundled-features/auth-email-password/web";
function MyApp() { const { user, signOut } = useSession(); if (!user) return <emailPasswordClient.LoginForm />; return <button onClick={signOut}>Logout {user.email}</button>;}sessions
Status: ✅ Stable
Wofür: Server-side Session-Tracking parallel zu den JWTs. Nötig, wenn du „Auf allen Geräten ausloggen” brauchst, Login-History zeigen willst, oder Session-Limits durchsetzen musst (max. 5 aktive Sessions pro User).
Wie es funktioniert: Jeder Login schreibt eine Row in
user_sessions mit id, userId, tenantId, createdAt, expiresAt, revokedAt. Die Auth-Middleware liest die Session-ID aus dem JWT und
prüft pro Request, ob die Row noch lebt (Cache: 60 Sekunden). Revoke =
revokedAt-Stempel setzen → der nächste Request kommt mit
session_expired zurück. Mass-Revoke (alle eines Users) ist ein
einzelner SQL-Update.
Optionen: expiryMs, cacheMs an createSessionsFeature(). Die
Cache-TTL ist der Trade-off zwischen Latenz (low TTL = mehr DB-Hits)
und Reaktionszeit auf Revoke (high TTL = User ist max. TTL Sekunden
nach Revoke noch drin).
Beispiel:
import { createSessionsFeature } from "@kumiko/bundled-features/sessions";
await runDevApp({ features: [ createSessionsFeature({ expiryMs: 30 * 24 * 60 * 60 * 1000, // 30 Tage Sliding-Window cacheMs: 60_000, // Revoke greift in ≤ 60s }), myFeature, ],});
// Im Handler: alle Sessions eines Users beenden ("Auf allen Geräten ausloggen")await ctx.sessions.revokeAllForUser(userId);user
Status: ✅ Stable
Wofür: User-Entity mit Email, Display-Name, globalen roles
(jsonb, parallel zu Membership-Roles), Sysadmin-Konzept. Liefert die
Standard-CRUD-Handler plus eigene Queries (me, byEmail).
Wie es funktioniert: Globale users.roles werden beim Login mit
den Membership-Roles des aktuellen Tenants gemerged in den JWT
geschrieben. So kann ein Sysadmin überall Admin-Pfade sehen, ohne
in jedem Tenant Mitglied zu sein. Verbreitetes Pattern: Plattform-
Tenant mit admin@platform.local als Sysadmin, andere Tenants kennen
ihn gar nicht.
Wann eigene Hooks: Welcome-Email beim Create, automatischer Tenant-
Beitritt, Geburtstags-Schaltjahr-Logik — alles über r.hook("postSave", ...) in einem eigenen Feature, das requires: ["user"] deklariert.
Beispiel:
// Welcome-Email beim Create — eigenes Feature mit User-Hookexport const welcomeFeature = defineFeature("welcome", (r) => { r.requires(["user"]); r.hook("postSave", "user.create", async (ctx, { id, data }) => { await ctx.delivery.send({ to: id, template: "welcome", data: { email: data.email }, }); });});tenant
Status: ✅ Stable
Wofür: Multi-Tenant-Backbone. Tenant-Entity mit Slug + Display-Name,
Membership-Tabelle (User ↔ Tenant ↔ Roles), Tenant-Switcher-Hook für
die UI. Pro Tenant ein logischer Mandant — DB-Rows tragen tenantId,
Reads filtern automatisch.
Wie es funktioniert: Das tenantId-Feld wird beim Boot in jede
Entity-Definition injiziert (außer der Tenant-Entity selbst —
tenants.id ist der tenantId). Der CrudExecutor schreibt es bei
Inserts, der List-Handler scoped Reads automatisch. Ohne
Tenant-Kontext läuft kein Standard-Read — du musst anonymousAccess
mit explizitem Tenant-Resolver nutzen, wenn du das aushebeln willst.
Wann nicht: Single-Tenant-Apps (interne Tools für eine Firma)
können tenant weglassen — der Code-Pfad mit tenantId: "default"
funktioniert auch ohne sichtbare Multi-Tenancy.
Beispiel:
// Public-Page: Tenant-Resolution per Subdomain (acme.app.com → tenant "acme")await runDevApp({ features: [myFeature], anonymousAccess: { tenantResolver: (req) => { const host = req.headers.get("host") ?? ""; const sub = host.split(".")[0]; return sub === "www" ? null : sub; }, },});
// Im Handler: aktuellen Tenant nutzenconst tenant = await ctx.db.read("tenant", ctx.tenantId);config
Status: ✅ Stable
Wofür: Typed Config-Layers pro Feature. Plattform-Default → Tenant-Override → User-Override, alles in einer Tabelle, alles type-safe. Encrypted-Keys (z. B. SMTP-Passwort) gehen mit demselben Pattern, der Wert ist im DB-Stempel encrypted.
Wie es funktioniert: Du deklarierst Config-Keys in deinem Feature
über r.config({ key: "smtp.host", default: "...", encrypted: false }).
Der Resolver liest sie zur Request-Zeit aus config_values, fällt auf
Defaults zurück. r.readsConfig(["smtp.host", ...]) markiert die
Dependencies — beim Boot wird geprüft, dass alle deklarierten Keys
existieren, du kannst keinen ungelesenen Key registrieren oder einen
unregistrierten lesen.
Encryption: Wenn ein Key encrypted: true ist, brauchst du beim
Server-Start CONFIG_ENCRYPTION_KEY (32-byte base64). Ohne den Key
abortest der Server beim Boot mit klarem Fehler — kein silent skip.
config encrypted vs secrets:
beide verschlüsseln Werte im DB-Stempel, aber für unterschiedliche
Use-Cases. config ist für Settings, die der Operator pflegt
(SMTP-Passwort, Webhook-URL, Feature-Flags) — pro Schlüssel eine Row,
einfache Verschlüsselung, Defaults aus dem Code-Repo. secrets
ist für externe Service-Credentials (Stripe-API-Key,
OAuth-Client-Secret) — envelope-encryption mit Per-Tenant-Master-Key,
eingebauter Rotation und Read-Audit-Trail. Faustregel: kommt der Wert
aus einem Settings-Dialog → config. Kommt er aus einem externen
Provider-Dashboard und soll rotierbar sein → secrets.
Beispiel:
// Plain Config-Key mit Default — z.B. Branding-Farbeexport const brandingFeature = defineFeature("branding", (r) => { r.config({ key: "branding.primary_color", default: "#0066cc" }); r.readsConfig(["branding.primary_color"]);
r.queryHandler({ qn: "branding:theme", handler: async (ctx) => ({ primaryColor: await ctx.config.read("branding.primary_color"), }), });});// Encrypted Config-Key — Per-Tenant SMTP-Passwortexport const smtpFeature = defineFeature("smtp", (r) => { r.config({ key: "smtp.host", default: "smtp.example.com" }); r.config({ key: "smtp.username", default: "" }); r.config({ key: "smtp.password", default: "", encrypted: true }); r.readsConfig(["smtp.host", "smtp.username", "smtp.password"]);
r.writeHandler({ qn: "smtp:send", handler: async (ctx, payload) => { const host = await ctx.config.read("smtp.host"); const user = await ctx.config.read("smtp.username"); const pass = await ctx.config.read("smtp.password"); // Tenant-spezifisch wenn überschrieben, sonst Plattform-Default await sendEmail({ host, user, pass, ...payload }); }, });});// Operator setzt einen Override für einen Tenant — z.B. via Admin-UIawait ctx.config.write({ scope: { tenantId: "acme-corp" }, key: "smtp.host", value: "mail.acme-corp.com",});