Ein fehlendes WHERE tenant_id = ? und ein Kunde sieht die Daten eines anderen. Ein vergessenes Rate Limit und ein Tenant hungert alle anderen aus. Ein Event, das außerhalb einer Transaction published wird, verschwindet beim Crash. Das sind keine Edge Cases. Das passiert in jeder Multi-Tenant Codebase irgendwann.
multiverse verhindert das auf Framework-Ebene.

Schema-per-Tenant Isolation
Die meisten Multi-Tenant Systeme arbeiten mit einer gemeinsamen Tabelle und einer tenant_id Spalte. Das funktioniert, bis jemand eine Query ohne Filter schreibt. Oder ein ORM eine Relation lädt, ohne den Tenant Context mitzugeben. Oder ein Background Job den falschen Tenant setzt.
multiverse geht einen anderen Weg: jeder Tenant bekommt sein eigenes Postgres Schema. Bei jedem Connection Checkout wird SET search_path TO tenant_{id}, public ausgeführt. Queries laufen physisch in einem isolierten Namespace. Eine fehlende WHERE-Clause kann keine Daten leaken, weil die Tabellen des anderen Tenants schlicht nicht sichtbar sind.
import { TenantPool, TenantQuery, TenantContext } from "multiverse";
const pool = new TenantPool({
connectionString: process.env.DATABASE_URL,
});
const tenant = TenantContext.current();
const query = new TenantQuery(pool);
// Läuft automatisch im Schema des aktuellen Tenants
const users = await query.sql`SELECT * FROM users`;
SQL Injection Prevention bei der Schema-Setzung: Identifier Validation rejectet alles, was nicht [a-zA-Z_][a-zA-Z0-9_]* matcht, bevor es in Quoted Identifiers eingesetzt wird.
ADR-001 dokumentiert die Entscheidung: Schema-per-Tenant statt Row-Level Isolation trotz höherer Migrations-Komplexität. Der Trade-off: ~10.000 Tenant Ceiling (Postgres Catalog Limits) gegen physische Isolationsgarantie.
Cross-Tenant Query Prevention
Defense-in-Depth: selbst wenn ein Developer versehentlich versucht, auf ein anderes Schema zuzugreifen:
if (!options?.allowCrossTenant) {
const currentTenant = TenantContext.currentOrNull();
if (currentTenant && currentTenant.id !== tenantId) {
throw new CrossTenantAccessError(tenantId, currentTenant.id);
}
}
allowCrossTenant: true ist ein explizites Opt-in für Admin-Operationen. Auditierbar.
AsyncLocalStorage für Tenant Propagation
TenantContext nutzt Node.js AsyncLocalStorage, um den aktuellen Tenant durch die gesamte Async Call Chain zu propagieren. Kein Parameter-Drilling durch 15 Funktionsaufrufe. Kein vergessener Tenant in einem Callback.
TenantContext.current() wirft NoTenantContextError wenn kein Context existiert (Fail-Fast). TenantContext.currentOrNull() für leniente Checks. ADR-003 dokumentiert den ~2-5% Microbenchmark Overhead und warum das für die Ergonomie akzeptabel ist.
Transactional Outbox
Du schreibst einen User in die Datenbank und publishst ein user.created Event. Classic Dual Write Problem: wenn der Publish nach dem Commit fehlschlägt, ist der User da aber das Event nicht. Umgekehrt genauso.
multiverse schreibt Events in dieselbe Transaktion wie die Business-Daten:
const outbox = new Outbox(pool);
await outbox.withTransaction(async (tx) => {
await tx.sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`;
await tx.emit("user.created", { name, email });
});
Die Outbox-Tabelle hat einen Partial Index auf id WHERE delivered_at IS NULL für effizientes Polling. Der Relay Poller nutzt FOR UPDATE SKIP LOCKED für Concurrency-sichere Batch-Verarbeitung (Default: 100 Events pro Tenant pro Poll). Exponential Backoff Retry mit konfigurierbarem Max. Automatischer Cleanup von gelieferten Events (7 Tage Default).
Multi-Tenant Relay Discovery via SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'. Error Isolation: ein fehlerhafter Tenant blockiert nicht die anderen.
Tenant Resolution Chain
Vier Built-in Resolver, composable via ChainResolver mit Fallback:
const resolver = new ChainResolver([
new HeaderTenantResolver("x-tenant-id"),
new SubdomainTenantResolver(), // acme.app.com → acme
new PathTenantResolver("/t/:id"), // /t/acme/api/orders → acme
new JwtTenantResolver("tenant"), // JWT Claim extraction
]);
Enterprise-Kunden kommen über ihre Subdomain, API-Clients schicken einen Header. Der JWT Resolver decodet den Token ohne Signature Verification, die passiert im Auth Middleware Layer danach. Bewusste Trennung: Resolution und Validation sind verschiedene Concerns.
Scoped Authentication
JWT Validation von externen OIDC Providern via jose. JWKS Caching für Performance (kein Key Fetch pro Request). Das Besondere: multiverse gleicht den Tenant Claim im JWT gegen den resolved Tenant ab:
const jwtTenantId = payload[tenantClaim];
if (currentTenant && currentTenant.id !== jwtTenantId) {
throw new CrossTenantAccessError(jwtTenantId, currentTenant.id);
}
Ein Token, das für Tenant A ausgestellt wurde, kann nicht gegen die API von Tenant B verwendet werden, selbst wenn der Token technisch valide ist.
Dual Provider Model: Shared OIDC für alle Tenants, oder Per-Tenant OIDC für Enterprise-Kunden mit eigenem IdP. Bei Per-Tenant: JWT wird erst ohne Verification decodet, um den Tenant zu extrahieren, dann wird die Tenant-spezifische OIDC Config geladen und der Token vollständig verifiziert.
Per-Tenant Rate Limiting
Token Bucket und Sliding Window, konfigurierbar pro Tier mit Per-Tenant Overrides:
const limiter = new TenantRateLimiter({
strategy: { type: "token-bucket", capacity: 100, refillRate: 10 },
tiers: {
enterprise: { capacity: 1000, refillRate: 100 },
},
});
Memory Leak Prevention: Stale Buckets werden alle 60 Sekunden bereinigt (Inaktiv für 2 * (capacity / refillRate) Sekunden). Das Rate Limit greift vor dem Handler. 429 Too Many Requests mit retryAfterMs im Error.
Middleware Composition
Die Execution Order ist fix und jeder Layer kann den Request rejecten:
- Public Path Check (Early Exit)
- Tenant Resolution
- Tenant Validation (Registry Lookup, Active Status)
- TenantContext Setup (
AsyncLocalStorage.run()) - Authentication (JWT + Tenant Scoping)
- Rate Limiting
- Application Handler
composeMiddleware() implementiert eine Koa-style Dispatch Chain mit Double-Call Protection (next() called multiple times → Error).
Per-Tenant Schema Migrations
Discovery via information_schema.schemata. Per-Tenant Tracking Table _migrations(name, executed_at). Jede Migration läuft in BEGIN/COMMIT/ROLLBACK. migrateAll() returned {succeeded: [], failed: []} mit Error Isolation: ein fehlgeschlagener Tenant blockiert nicht die anderen. MigrationError enthält tenantId und migration Name für Debugging.