Zum Inhalt springen

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-Hook
import { 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-Hook
export 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 nutzen
const 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-Farbe
export 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-Passwort
export 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-UI
await ctx.config.write({
scope: { tenantId: "acme-corp" },
key: "smtp.host",
value: "mail.acme-corp.com",
});

Siehe auch