release: WRNexusJS 0.8.0
This commit is contained in:
@@ -7,6 +7,38 @@ export interface Tenant {
|
||||
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 {
|
||||
@@ -41,6 +73,55 @@ export function tenantFromSubdomain(
|
||||
};
|
||||
}
|
||||
|
||||
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.");
|
||||
@@ -54,3 +135,230 @@ export function tenantScope<T extends object>(
|
||||
): 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user