365 lines
13 KiB
TypeScript
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 };
|
|
}
|