Files
WRNexusJS/packages/core/src/tenant.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

365 lines
13 KiB
TypeScript

import type { Context, Middleware } from "./context.ts";
export interface Tenant {
id: string;
slug?: string;
name?: string;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
createdAt: number;
}
export interface TenantQuota {
tenantId: string;
resource: string;
limit: number;
usage: number;
}
export interface TenantDirectoryStore {
putMembership(value: TenantMembership): Promise<void>;
getMembership(tenantId: string, userId: string): Promise<TenantMembership | null>;
listMemberships(tenantId: string): Promise<TenantMembership[]>;
putQuota(value: TenantQuota): Promise<void>;
getQuota(tenantId: string, resource: string): Promise<TenantQuota | null>;
}
export type TenantResolver = (ctx: Context) => Tenant | null | Promise<Tenant | null>;
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<Tenant | null>,
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<Tenant | null>,
): TenantResolver {
return (ctx) => lookup(ctx.url.hostname.toLowerCase(), ctx);
}
export function tenantFromPath(
lookup: (slug: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
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<Tenant | null>,
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<string | null>,
lookup: (id: string, ctx: Context) => Tenant | null | Promise<Tenant | null>,
): 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<T extends object>(
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 | number>): 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<void>; now?: () => number } = {},
) {
const memberships = new Map<string, TenantMembership>();
const quotas = new Map<string, Map<string, number>>();
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<string, TenantMembership>();
const quotas = new Map<string, TenantQuota>();
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<void>; 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<T = Record<string, unknown>>(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<any>(
`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<TenantMembership>(
`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<any>(
`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<T extends Tenant>(
tenants: T[],
migrate: (tenant: T) => void | Promise<void>,
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 };
}