Multi-tenant apps
Isolate tenant data in a shared database using request-scoped context and model global scopes.
Pondoknusa does not ship a built-in tenancy package — this recipe uses middleware, the container, and ORM scopes you control.
1. Tenant resolution middleware
Resolve the tenant from subdomain or header and expose it to the rest of the request through async context:
// src/middleware/resolve-tenant.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import type { Middleware } from '@pondoknusa/http';
export interface Tenant {
id: string;
slug: string;
}
const tenantStorage = new AsyncLocalStorage<Tenant>();
export function currentTenant(): Tenant | undefined {
return tenantStorage.getStore();
}
export function resolveTenant(): Middleware {
return async (request, next) => {
const host = request.header('host') ?? '';
const slug = host.split('.')[0] ?? '';
if (!slug || slug === 'www') {
return Response.json({ error: 'Unknown tenant' }, { status: 404 });
}
const tenant = await lookupTenantBySlug(slug);
if (!tenant) {
return Response.json({ error: 'Unknown tenant' }, { status: 404 });
}
return tenantStorage.run(tenant, next);
};
}Register on tenant-facing route groups:
Route.middleware(['web', resolveTenant()]).group(() => {
// tenant routes
});2. Global scope on models
Add tenant_id to tenant-owned tables and scope queries automatically:
import { Model, createGlobalScope } from '@pondoknusa/database';
export class Post extends Model {
static table = 'posts';
}
Post.addGlobalScope(
createGlobalScope('tenant', (builder) => {
const tenant = currentTenant();
if (tenant) {
builder.where('tenant_id', tenant.id);
}
}),
);currentTenant() reads the tenant from the AsyncLocalStorage context established by resolveTenant() above.
3. Assign tenant on create
Set tenant_id with a model creating hook or in the controller so inserts never leak across tenants:
export class Post extends Model {
// ...
async creating(): Promise<void> {
this.setAttribute('tenant_id', currentTenant()!.id);
}
}4. Cache and storage prefixes
Prefix cache keys and disk paths per tenant to avoid collisions:
const key = `tenant:${tenant.id}:dashboard-stats`;
await Cache.put(key, stats, 300);For file uploads, use storage/app/tenants/{tenantId}/... paths.
5. Broadcasting channels
Namespace channels by tenant so Echo clients only subscribe to their org:
Broadcast.channel('private-tenant.{tenantId}.orders', (user, tenantId) => {
return user.tenantId === tenantId;
});Testing
Use withSession() and middleware that injects a fake tenant in feature tests. Assert cross-tenant access returns 404 or 403, not another tenant's rows.
Related
- Database & ORM — global scopes and model hooks
- Authentication — attach
tenantIdto the user model - Broadcasting — private channel naming