import type { Context, Middleware } from "./context.ts"; export interface Tenant { id: string; slug?: string; name?: string; metadata?: Record; } export interface TenantResource { tenantId: string; } export interface TenantMembership { tenantId: string; userId: string; roles?: string[]; workspaceIds?: string[]; } export interface TenantAuditEvent { tenantId: string; action: string; actorId?: string; resource?: string; metadata?: Record; createdAt: number; } export interface TenantQuota { tenantId: string; resource: string; limit: number; usage: number; } export interface TenantDirectoryStore { putMembership(value: TenantMembership): Promise; getMembership(tenantId: string, userId: string): Promise; listMemberships(tenantId: string): Promise; putQuota(value: TenantQuota): Promise; getQuota(tenantId: string, resource: string): Promise; } export type TenantResolver = (ctx: Context) => Tenant | null | Promise; export interface TenantMiddlewareOptions { required?: boolean; status?: number; } export function tenantMiddleware( resolveTenant: TenantResolver, options: TenantMiddlewareOptions = {}, ): Middleware { return async (ctx, next) => { const tenant = await resolveTenant(ctx); ctx.tenant = tenant ?? undefined; if (!tenant && options.required !== false) { return new Response("Tenant not found", { status: options.status ?? 404 }); } return next(); }; } export function tenantFromSubdomain( lookup: (slug: string, ctx: Context) => Tenant | null | Promise, rootDomains: string[] = [], ): TenantResolver { return async (ctx) => { const host = ctx.url.hostname.toLowerCase(); const root = rootDomains.find((domain) => host === domain || host.endsWith(`.${domain}`)); const slug = root ? host.slice(0, -(root.length + 1)) : host.split(".")[0]; if (!slug || slug === host || slug === "www") return null; return lookup(slug, ctx); }; } export function tenantFromDomain( lookup: (domain: string, ctx: Context) => Tenant | null | Promise, ): TenantResolver { return (ctx) => lookup(ctx.url.hostname.toLowerCase(), ctx); } export function tenantFromPath( lookup: (slug: string, ctx: Context) => Tenant | null | Promise, prefix = "", ): TenantResolver { return (ctx) => { const segments = ctx.url.pathname.split("/").filter(Boolean); const normalized = prefix.replace(/^\/+|\/+$/g, ""); const slug = normalized ? (segments[0] === normalized ? segments[1] : undefined) : segments[0]; return slug ? lookup(slug, ctx) : null; }; } /** Header resolution is intentionally opt-in and must only be used behind a trusted proxy. */ export function tenantFromHeader( lookup: (id: string, ctx: Context) => Tenant | null | Promise, header = "x-wrnexus-tenant", ): TenantResolver { return (ctx) => { const value = ctx.req.headers.get(header)?.trim(); return value ? lookup(value, ctx) : null; }; } export function tenantFromSession( resolveId: (ctx: Context) => string | null | Promise, lookup: (id: string, ctx: Context) => Tenant | null | Promise, ): TenantResolver { return async (ctx) => { const id = await resolveId(ctx); return id ? lookup(id, ctx) : null; }; } export function composeTenantResolvers(...resolvers: TenantResolver[]): TenantResolver { return async (ctx) => { for (const resolver of resolvers) { const tenant = await resolver(ctx); if (tenant) return tenant; } return null; }; } export function requireTenant(ctx: Context): Tenant { if (!ctx.tenant) throw new Error("WRN-TENANT-REQUIRED: tenant middleware has not resolved a tenant."); return ctx.tenant; } /** Wrap a repository so every operation receives the current tenant id. */ export function tenantScope( tenant: Tenant, repository: T, ): T & { tenantId: string } { return Object.assign(Object.create(repository), { tenantId: tenant.id }); } export function assertTenantAccess(tenant: Tenant, resource: TenantResource): void { if (!resource.tenantId || resource.tenantId !== tenant.id) throw new Error("WRN-TENANT-CROSS-ACCESS: resource does not belong to the active tenant."); } export function tenantKey(tenant: Tenant | string, ...parts: Array): string { const id = typeof tenant === "string" ? tenant : tenant.id; if (!id.trim() || id.includes(":")) throw new TypeError("WRN-TENANT-KEY: tenant id must be non-empty and cannot contain ':'."); return [ "tenant", encodeURIComponent(id), ...parts.map((part) => encodeURIComponent(String(part))), ].join(":"); } export function createTenantDirectory( options: { audit?: (event: TenantAuditEvent) => void | Promise; now?: () => number } = {}, ) { const memberships = new Map(); const quotas = new Map>(); const now = options.now ?? Date.now; const key = (tenantId: string, userId: string) => `${tenantId}\0${userId}`; return { async addMembership(membership: TenantMembership, actorId?: string) { if (!membership.tenantId || !membership.userId) throw new TypeError("tenantId and userId are required"); memberships.set(key(membership.tenantId, membership.userId), structuredClone(membership)); await options.audit?.({ tenantId: membership.tenantId, action: "membership.added", actorId, resource: membership.userId, createdAt: now(), }); }, membership(tenantId: string, userId: string) { const value = memberships.get(key(tenantId, userId)); return value ? structuredClone(value) : null; }, async switchWorkspace(tenantId: string, userId: string, workspaceId: string) { const membership = memberships.get(key(tenantId, userId)); if (!membership?.workspaceIds?.includes(workspaceId)) throw new Error("WRN-TENANT-WORKSPACE-DENIED"); await options.audit?.({ tenantId, action: "workspace.switched", actorId: userId, resource: workspaceId, createdAt: now(), }); return { tenantId, workspaceId }; }, setQuota(tenantId: string, resource: string, limit: number) { if (!Number.isFinite(limit) || limit < 0) throw new RangeError("tenant quota must be non-negative"); const values = quotas.get(tenantId) ?? new Map(); values.set(resource, limit); quotas.set(tenantId, values); }, enforceQuota(tenantId: string, resource: string, usage: number, requested = 0) { const limit = quotas.get(tenantId)?.get(resource); if (limit !== undefined && usage + requested > limit) throw new Error(`WRN-TENANT-QUOTA: ${resource} quota exceeded.`); return { usage, requested, limit }; }, }; } export function memoryTenantDirectoryStore(): TenantDirectoryStore { const memberships = new Map(); const quotas = new Map(); return { async putMembership(value) { memberships.set(`${value.tenantId}\0${value.userId}`, structuredClone(value)); }, async getMembership(tenantId, userId) { const value = memberships.get(`${tenantId}\0${userId}`); return value ? structuredClone(value) : null; }, async listMemberships(tenantId) { return [...memberships.values()] .filter((value) => value.tenantId === tenantId) .map((value) => structuredClone(value)); }, async putQuota(value) { quotas.set(`${value.tenantId}\0${value.resource}`, structuredClone(value)); }, async getQuota(tenantId, resource) { const value = quotas.get(`${tenantId}\0${resource}`); return value ? structuredClone(value) : null; }, }; } export function createPersistentTenantDirectory( store: TenantDirectoryStore, options: { audit?: (event: TenantAuditEvent) => void | Promise; now?: () => number } = {}, ) { const now = options.now ?? Date.now; return { async addMembership(membership: TenantMembership, actorId?: string) { if (!membership.tenantId || !membership.userId) throw new TypeError("tenantId and userId are required"); await store.putMembership(structuredClone(membership)); await options.audit?.({ tenantId: membership.tenantId, action: "membership.added", actorId, resource: membership.userId, createdAt: now(), }); }, membership: (tenantId: string, userId: string) => store.getMembership(tenantId, userId), memberships: (tenantId: string) => store.listMemberships(tenantId), async switchWorkspace(tenantId: string, userId: string, workspaceId: string) { const membership = await store.getMembership(tenantId, userId); if (!membership?.workspaceIds?.includes(workspaceId)) throw new Error("WRN-TENANT-WORKSPACE-DENIED"); await options.audit?.({ tenantId, action: "workspace.switched", actorId: userId, resource: workspaceId, createdAt: now(), }); return { tenantId, workspaceId }; }, async setQuota(tenantId: string, resource: string, limit: number, usage = 0) { if (!Number.isFinite(limit) || limit < 0 || !Number.isFinite(usage) || usage < 0) throw new RangeError("tenant quota values must be non-negative"); await store.putQuota({ tenantId, resource, limit, usage }); }, async consumeQuota(tenantId: string, resource: string, requested: number) { if (!Number.isFinite(requested) || requested < 0) throw new RangeError("requested quota must be non-negative"); const quota = await store.getQuota(tenantId, resource); if (quota && quota.usage + requested > quota.limit) throw new Error(`WRN-TENANT-QUOTA: ${resource} quota exceeded.`); if (quota) { quota.usage += requested; await store.putQuota(quota); } return quota; }, }; } export interface TenantSqlClient { query>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>; } export function postgresTenantDirectoryStore(db: TenantSqlClient): TenantDirectoryStore { return { async putMembership(value) { await db.query( `INSERT INTO wrnexus_tenant_memberships (tenant_id,user_id,roles,workspace_ids) VALUES ($1,$2,$3,$4) ON CONFLICT (tenant_id,user_id) DO UPDATE SET roles=$3,workspace_ids=$4`, [ value.tenantId, value.userId, JSON.stringify(value.roles ?? []), JSON.stringify(value.workspaceIds ?? []), ], ); }, async getMembership(tenantId, userId) { const result = await db.query( `SELECT tenant_id AS "tenantId",user_id AS "userId",roles,workspace_ids AS "workspaceIds" FROM wrnexus_tenant_memberships WHERE tenant_id=$1 AND user_id=$2`, [tenantId, userId], ); return result.rows[0] ?? null; }, async listMemberships(tenantId) { const result = await db.query( `SELECT tenant_id AS "tenantId",user_id AS "userId",roles,workspace_ids AS "workspaceIds" FROM wrnexus_tenant_memberships WHERE tenant_id=$1 ORDER BY user_id`, [tenantId], ); return result.rows; }, async putQuota(value) { await db.query( `INSERT INTO wrnexus_tenant_quotas (tenant_id,resource,quota_limit,usage) VALUES ($1,$2,$3,$4) ON CONFLICT (tenant_id,resource) DO UPDATE SET quota_limit=$3,usage=$4`, [value.tenantId, value.resource, value.limit, value.usage], ); }, async getQuota(tenantId, resource) { const result = await db.query( `SELECT tenant_id AS "tenantId",resource,quota_limit AS "limit",usage FROM wrnexus_tenant_quotas WHERE tenant_id=$1 AND resource=$2`, [tenantId, resource], ); return result.rows[0] ?? null; }, }; } export const POSTGRES_TENANT_DIRECTORY_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_tenant_memberships (tenant_id text NOT NULL,user_id text NOT NULL,roles jsonb NOT NULL DEFAULT '[]',workspace_ids jsonb NOT NULL DEFAULT '[]',PRIMARY KEY (tenant_id,user_id)); CREATE TABLE IF NOT EXISTS wrnexus_tenant_quotas (tenant_id text NOT NULL,resource text NOT NULL,quota_limit bigint NOT NULL,usage bigint NOT NULL DEFAULT 0,PRIMARY KEY (tenant_id,resource));`; export async function migrateTenants( tenants: T[], migrate: (tenant: T) => void | Promise, options: { concurrency?: number; continueOnError?: boolean } = {}, ) { const concurrency = options.concurrency ?? 4; if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 32) throw new RangeError("Tenant migration concurrency must be between 1 and 32"); const pending = [...tenants]; const migrated: string[] = []; const failed: Array<{ tenantId: string; error: string }> = []; await Promise.all( Array.from({ length: Math.min(concurrency, pending.length) }, async () => { while (pending.length) { const tenant = pending.shift()!; try { await migrate(tenant); migrated.push(tenant.id); } catch (error) { failed.push({ tenantId: tenant.id, error: error instanceof Error ? error.message : String(error), }); if (!options.continueOnError) pending.length = 0; } } }), ); return { migrated, failed }; }