@happyvertical/smrt-tenancy 0.40.17 → 0.40.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/dist/adapters/index.js +1 -1
- package/dist/chunks/{adapters-Bd8dPv0x.js → adapters-B4hMNcuw.js} +2 -2
- package/dist/chunks/{adapters-Bd8dPv0x.js.map → adapters-B4hMNcuw.js.map} +1 -1
- package/dist/chunks/{context-ChVERhZz.js → context-CwbLwyIV.js} +8 -2
- package/dist/chunks/context-CwbLwyIV.js.map +1 -0
- package/dist/chunks/{testing-5mZMoGX9.js → testing-CxhEt3bu.js} +2 -2
- package/dist/chunks/{testing-5mZMoGX9.js.map → testing-CxhEt3bu.js.map} +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/manifest.json +2 -2
- package/dist/smrt-knowledge.json +6 -6
- package/dist/testing.js +1 -1
- package/package.json +5 -5
- package/dist/chunks/context-ChVERhZz.js.map +0 -1
package/AGENTS.md
CHANGED
|
@@ -18,6 +18,8 @@ await withSystemContext(async () => { /* bypasses all tenant checks */ });
|
|
|
18
18
|
|
|
19
19
|
**Critical distinction**: `withSystemContext()` sets a SYSTEM_CONTEXT_MARKER sentinel — different from "no context" (undefined). Interceptor can distinguish intentional bypass from missing context.
|
|
20
20
|
|
|
21
|
+
**Duplication-safe storage**: the underlying `AsyncLocalStorage` is a `Symbol.for`-keyed singleton on `globalThis`, so context survives Vite/vitest/SvelteKit pipelines that evaluate the module more than once — context entered through one module instance is visible to guards in another (#2077).
|
|
22
|
+
|
|
21
23
|
## Interceptor System
|
|
22
24
|
|
|
23
25
|
Hooks into SmrtCollection via `GlobalInterceptors.register()` (priority 100, runs first):
|
package/dist/adapters/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as createExpressMiddleware, r as createCliContext, t as createSvelteKitHandle } from "../chunks/adapters-
|
|
1
|
+
import { n as createExpressMiddleware, r as createCliContext, t as createSvelteKitHandle } from "../chunks/adapters-B4hMNcuw.js";
|
|
2
2
|
export { createCliContext, createExpressMiddleware, createSvelteKitHandle };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as enterTenantContext, m as withTenant, p as withSystemContext } from "./context-
|
|
1
|
+
import { i as enterTenantContext, m as withTenant, p as withSystemContext } from "./context-CwbLwyIV.js";
|
|
2
2
|
//#region src/adapters/cli.ts
|
|
3
3
|
function createCliContext(options = {}) {
|
|
4
4
|
const { resolveTenantId, resolveUserId, defaultPermissions = /* @__PURE__ */ new Set(), superAdminByDefault = false } = options;
|
|
@@ -118,4 +118,4 @@ function matchPath(path, pattern) {
|
|
|
118
118
|
//#endregion
|
|
119
119
|
export { createExpressMiddleware as n, createCliContext as r, createSvelteKitHandle as t };
|
|
120
120
|
|
|
121
|
-
//# sourceMappingURL=adapters-
|
|
121
|
+
//# sourceMappingURL=adapters-B4hMNcuw.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adapters-Bd8dPv0x.js","names":[],"sources":["../../src/adapters/cli.ts","../../src/adapters/express.ts","../../src/adapters/sveltekit.ts"],"sourcesContent":["/**\n * CLI Adapter for smrt-tenancy\n *\n * Provides utilities for setting up tenant context in CLI tools.\n *\n * @example\n * ```typescript\n * import { createCliContext } from '@happyvertical/smrt-tenancy/adapters';\n *\n * // Create context helper for CLI\n * const cliContext = createCliContext({\n * resolveTenantId: () => process.env.TENANT_ID,\n * });\n *\n * // Run command in tenant context\n * await cliContext.run(async () => {\n * await documentCollection.list({});\n * });\n * ```\n */\n\nimport {\n type TenantContextData,\n withSystemContext,\n withTenant,\n} from '../context.js';\n\n/**\n * Configuration for the CLI context runner created by `createCliContext()`.\n *\n * `resolveTenantId` is optional — when omitted (or when it returns\n * `null`/`undefined`) the `run()` method falls back to `withSystemContext()`.\n *\n * @see createCliContext\n * @see CliContextRunner\n */\nexport interface CliContextOptions {\n /**\n * Resolve tenant ID\n *\n * Common sources:\n * - Environment variable: () => process.env.TENANT_ID\n * - Command line argument: () => argv.tenant\n * - Config file: () => config.tenantId\n */\n resolveTenantId?: () =>\n | Promise<string | null | undefined>\n | string\n | null\n | undefined;\n\n /**\n * Resolve user ID (optional)\n */\n resolveUserId?: () =>\n | Promise<string | null | undefined>\n | string\n | null\n | undefined;\n\n /**\n * Default permissions for CLI operations\n */\n defaultPermissions?: Set<string>;\n\n /**\n * Run CLI as super admin by default\n * @default false\n */\n superAdminByDefault?: boolean;\n}\n\n/**\n * Context runner returned by `createCliContext()`.\n *\n * Provides four execution modes suitable for different CLI scenarios:\n * - `run()` — resolves the tenant from the configured options and runs code in\n * that context; falls back to system context if no tenant is available.\n * - `runWithTenant()` — explicitly specify a tenant ID for this invocation.\n * - `runAsSystem()` — bypass all tenant checks (migration scripts, admin tools).\n * - `runAsSuperAdmin()` — tenant context with bypass flag enabled.\n *\n * @see createCliContext\n */\nexport interface CliContextRunner {\n /**\n * Run `fn` inside the tenant context resolved from the `CliContextOptions`.\n *\n * Falls back to `withSystemContext()` when no tenant ID is available.\n *\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n run<T>(fn: () => Promise<T>): Promise<T>;\n\n /**\n * Run `fn` inside the context of the specified tenant.\n *\n * @param tenantId - Tenant ID to set as context.\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n runWithTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T>;\n\n /**\n * Run `fn` in system context, bypassing all tenant checks.\n *\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n runAsSystem<T>(fn: () => Promise<T>): Promise<T>;\n\n /**\n * Run `fn` with a tenant context and super admin bypass enabled.\n *\n * @param tenantId - Tenant ID to set as context.\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n runAsSuperAdmin<T>(tenantId: string, fn: () => Promise<T>): Promise<T>;\n}\n\n/**\n * Create a CLI context runner\n *\n * @param options - Configuration options\n * @returns CLI context runner with various execution modes\n *\n * @example\n * ```typescript\n * const cli = createCliContext({\n * resolveTenantId: () => process.env.TENANT_ID,\n * superAdminByDefault: true,\n * });\n *\n * // Use resolved tenant\n * await cli.run(async () => {\n * const docs = await collection.list({});\n * console.log(`Found ${docs.length} documents`);\n * });\n *\n * // Override tenant\n * await cli.runWithTenant('other-tenant', async () => {\n * // Operations in other-tenant context\n * });\n *\n * // System operations (no tenant)\n * await cli.runAsSystem(async () => {\n * // Can access all data\n * const allDocs = await collection.list({});\n * });\n * ```\n */\nexport function createCliContext(\n options: CliContextOptions = {},\n): CliContextRunner {\n const {\n resolveTenantId,\n resolveUserId,\n defaultPermissions = new Set<string>(),\n superAdminByDefault = false,\n } = options;\n\n return {\n async run<T>(fn: () => Promise<T>): Promise<T> {\n const tenantId = resolveTenantId ? await resolveTenantId() : null;\n\n if (!tenantId) {\n // No tenant configured - run in system context\n return withSystemContext(fn);\n }\n\n const userId = resolveUserId ? await resolveUserId() : undefined;\n\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions: defaultPermissions,\n superAdminBypass: superAdminByDefault,\n };\n\n return withTenant(context, fn);\n },\n\n async runWithTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {\n const userId = resolveUserId ? await resolveUserId() : undefined;\n\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions: defaultPermissions,\n superAdminBypass: superAdminByDefault,\n };\n\n return withTenant(context, fn);\n },\n\n async runAsSystem<T>(fn: () => Promise<T>): Promise<T> {\n return withSystemContext(fn);\n },\n\n async runAsSuperAdmin<T>(\n tenantId: string,\n fn: () => Promise<T>,\n ): Promise<T> {\n const userId = resolveUserId ? await resolveUserId() : undefined;\n\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions: defaultPermissions,\n superAdminBypass: true,\n };\n\n return withTenant(context, fn);\n },\n };\n}\n\n/**\n * Run an async function with a specific tenant ID set as context.\n *\n * Convenience wrapper around `withTenant()` for one-off CLI operations where\n * a full `CliContextRunner` is not needed.\n *\n * @param tenantId - Tenant ID to set as the active context.\n * @param fn - Async function to execute in the tenant context.\n * @returns Promise resolving to the return value of `fn`.\n *\n * @example\n * ```typescript\n * import { runWithTenant } from '@happyvertical/smrt-tenancy/adapters';\n *\n * await runWithTenant('tenant-123', async () => {\n * await collection.list({});\n * });\n * ```\n *\n * @see createCliContext\n * @see runAsSystem\n */\nexport async function runWithTenant<T>(\n tenantId: string,\n fn: () => Promise<T>,\n): Promise<T> {\n return withTenant({ tenantId }, fn);\n}\n\n/**\n * Run an async function in system context, bypassing all tenant checks.\n *\n * Convenience wrapper around `withSystemContext()` for one-off CLI operations\n * such as migration scripts or admin tooling that needs cross-tenant access.\n *\n * @param fn - Async function to execute in system context.\n * @returns Promise resolving to the return value of `fn`.\n *\n * @example\n * ```typescript\n * import { runAsSystem } from '@happyvertical/smrt-tenancy/adapters';\n *\n * await runAsSystem(async () => {\n * const all = await collection.list({});\n * console.log(`Total records: ${all.length}`);\n * });\n * ```\n *\n * @see runWithTenant\n * @see createCliContext\n */\nexport async function runAsSystem<T>(fn: () => Promise<T>): Promise<T> {\n return withSystemContext(fn);\n}\n","/**\n * Express Adapter for smrt-tenancy\n *\n * Provides Express middleware that sets up tenant context for each request.\n *\n * @example\n * ```typescript\n * import express from 'express';\n * import { createExpressMiddleware } from '@happyvertical/smrt-tenancy/adapters';\n *\n * const app = express();\n *\n * app.use(createExpressMiddleware({\n * resolveTenantId: (req) => req.headers['x-tenant-id'] as string,\n * }));\n * ```\n */\n\nimport { enterTenantContext, type TenantContextData } from '../context.js';\n\n/**\n * Express Request interface (minimal to avoid direct dependency)\n */\ninterface ExpressRequest {\n headers: Record<string, string | string[] | undefined>;\n url: string;\n path: string;\n query: Record<string, unknown>;\n cookies?: Record<string, string>;\n}\n\n/**\n * Request object after the tenancy middleware has attached the resolved tenant\n * context. The middleware sets these properties so downstream route handlers can\n * read the tenant directly off the request.\n */\ninterface TenantAwareExpressRequest extends ExpressRequest {\n tenantContext?: TenantContextData;\n tenantId?: string;\n}\n\n/**\n * Express Response interface\n */\ninterface ExpressResponse {\n status(code: number): ExpressResponse;\n json(data: unknown): ExpressResponse;\n send(data: unknown): ExpressResponse;\n}\n\n/**\n * Express NextFunction\n */\ntype ExpressNext = (error?: unknown) => void;\n\n/**\n * Configuration options for the Express tenancy middleware created by\n * `createExpressMiddleware()`.\n *\n * Only `resolveTenantId` is required. All callback options receive the raw\n * Express `Request` object so you can extract tenant information from headers,\n * subdomains, cookies, or any other request property.\n *\n * @see createExpressMiddleware\n */\nexport interface ExpressMiddlewareOptions {\n /**\n * Resolve tenant ID from the request\n */\n resolveTenantId: (\n req: ExpressRequest,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve user ID from the request (optional)\n */\n resolveUserId?: (\n req: ExpressRequest,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve permissions (optional)\n */\n resolvePermissions?: (\n req: ExpressRequest,\n tenantId: string,\n userId?: string,\n ) => Promise<Set<string>> | Set<string>;\n\n /**\n * Check if user is super admin (optional)\n */\n isSuperAdmin?: (\n req: ExpressRequest,\n tenantId: string,\n userId?: string,\n ) => Promise<boolean> | boolean;\n\n /**\n * Called when no tenant ID could be resolved\n * Return true to continue, false to stop with 400 error.\n */\n onNoTenant?: (\n req: ExpressRequest,\n res: ExpressResponse,\n ) => Promise<boolean> | boolean;\n\n /**\n * Paths to exclude from tenant context\n */\n excludePaths?: string[];\n}\n\n/**\n * Create an Express middleware function that establishes tenant context for\n * every incoming request.\n *\n * Uses `enterTenantContext()` (rather than `withTenant()`) because Express\n * middleware returns before route handlers execute. `enterWith()` sets the\n * context on the current async resource so it propagates to handlers that run\n * after `next()` is called.\n *\n * The resolved context is also attached directly to the request object for\n * convenience:\n * - `req.tenantContext` — full `TenantContextData`\n * - `req.tenantId` — string tenant ID shortcut\n *\n * When no tenant ID can be resolved, the default behaviour returns a `400`\n * JSON response. Customise this with the `onNoTenant` option.\n *\n * @param options - Middleware configuration including the required\n * `resolveTenantId` callback.\n * @returns An Express-compatible middleware function `(req, res, next) => void`.\n *\n * @example\n * ```typescript\n * import express from 'express';\n * import { createExpressMiddleware } from '@happyvertical/smrt-tenancy/adapters';\n *\n * const app = express();\n * app.use(createExpressMiddleware({\n * resolveTenantId: (req) => req.headers['x-tenant-id'] as string,\n * excludePaths: ['/health', '/public/*'],\n * }));\n * ```\n *\n * @see ExpressMiddlewareOptions\n * @see createSvelteKitHandle\n */\nexport function createExpressMiddleware(options: ExpressMiddlewareOptions) {\n const {\n resolveTenantId,\n resolveUserId,\n resolvePermissions,\n isSuperAdmin,\n onNoTenant,\n excludePaths = [],\n } = options;\n\n return async function tenancyMiddleware(\n req: ExpressRequest,\n res: ExpressResponse,\n next: ExpressNext,\n ): Promise<void> {\n try {\n // Check excluded paths\n if (excludePaths.some((pattern) => matchPath(req.path, pattern))) {\n next();\n return;\n }\n\n // Resolve tenant ID\n const tenantId = await resolveTenantId(req);\n\n if (!tenantId) {\n if (onNoTenant) {\n const shouldContinue = await onNoTenant(req, res);\n if (shouldContinue) {\n next();\n return;\n }\n } else {\n res.status(400).json({\n error: 'Tenant ID required',\n message:\n 'Please provide a tenant ID via header, subdomain, or query parameter.',\n });\n return;\n }\n return;\n }\n\n // Resolve optional context\n const userId = resolveUserId ? await resolveUserId(req) : undefined;\n const permissions = resolvePermissions\n ? await resolvePermissions(req, tenantId, userId ?? undefined)\n : new Set<string>();\n const superAdminBypass = isSuperAdmin\n ? await isSuperAdmin(req, tenantId, userId ?? undefined)\n : false;\n\n // Build context\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions,\n superAdminBypass,\n };\n\n // Attach to request for access in route handlers\n (req as TenantAwareExpressRequest).tenantContext = context;\n (req as TenantAwareExpressRequest).tenantId = tenantId;\n\n // Use enterWith() to establish context that persists for the entire\n // request lifecycle. This ensures route handlers have access to\n // tenant context via AsyncLocalStorage.\n enterTenantContext(context);\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Simple glob pattern matching for paths\n */\nfunction matchPath(path: string, pattern: string): boolean {\n const regexPattern = pattern\n .replace(/\\*/g, '.*')\n .replace(/\\//g, '\\\\/')\n .replace(/\\?/g, '.');\n\n return new RegExp(`^${regexPattern}$`).test(path);\n}\n","/**\n * SvelteKit Adapter for smrt-tenancy\n *\n * Provides a SvelteKit Handle that sets up tenant context for each request.\n *\n * @example\n * ```typescript\n * // hooks.server.ts\n * import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy/adapters';\n *\n * export const handle = createSvelteKitHandle({\n * resolveTenantId: async (event) => {\n * // From subdomain\n * const host = event.request.headers.get('host');\n * const subdomain = host?.split('.')[0];\n * return subdomain;\n *\n * // Or from header\n * // return event.request.headers.get('x-tenant-id');\n *\n * // Or from cookie\n * // return event.cookies.get('tenant_id');\n * }\n * });\n * ```\n */\n\nimport { type TenantContextData, withTenant } from '../context.js';\n\n/**\n * SvelteKit RequestEvent (minimal interface to avoid direct dependency)\n */\ninterface SvelteKitEvent {\n request: Request;\n url: URL;\n cookies: {\n get(name: string): string | undefined;\n set(name: string, value: string, opts?: unknown): void;\n };\n locals: Record<string, unknown>;\n}\n\n/**\n * SvelteKit resolve function\n */\ntype SvelteKitResolve = (event: SvelteKitEvent) => Promise<Response>;\n\n/**\n * Configuration options for the SvelteKit tenancy handle created by\n * `createSvelteKitHandle()`.\n *\n * Only `resolveTenantId` is required; all other fields are optional callbacks\n * used to enrich the context or customise missing-tenant behaviour.\n *\n * @see createSvelteKitHandle\n */\nexport interface SvelteKitHandleOptions {\n /**\n * Resolve tenant ID from the request\n *\n * Return the tenant ID string, or null/undefined if no tenant context should be set.\n */\n resolveTenantId: (\n event: SvelteKitEvent,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve user ID from the request (optional)\n */\n resolveUserId?: (\n event: SvelteKitEvent,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve permissions for the user in this tenant (optional)\n */\n resolvePermissions?: (\n event: SvelteKitEvent,\n tenantId: string,\n userId?: string,\n ) => Promise<Set<string>> | Set<string>;\n\n /**\n * Check if user is a super admin (optional)\n * If true and super admin bypass is enabled on the class, tenant filtering is skipped.\n */\n isSuperAdmin?: (\n event: SvelteKitEvent,\n tenantId: string,\n userId?: string,\n ) => Promise<boolean> | boolean;\n\n /**\n * Called when no tenant ID could be resolved\n * Return a Response to short-circuit, or undefined to continue without tenant context.\n */\n onNoTenant?: (\n event: SvelteKitEvent,\n ) => Promise<Response | undefined> | Response | undefined;\n\n /**\n * Paths to exclude from tenant context (e.g., public APIs, health checks)\n * Supports glob patterns.\n */\n excludePaths?: string[];\n}\n\n/**\n * Create a SvelteKit `Handle` function that establishes tenant context for\n * every server-side request.\n *\n * The returned handle wraps the `resolve` call in `withTenant()` so that all\n * server-side load functions, API routes, and `+server.ts` handlers within the\n * request share the same tenant context via `AsyncLocalStorage`.\n *\n * The resolved context is also stored in `event.locals` under two keys:\n * - `event.locals.tenantContext` — full `TenantContextData`\n * - `event.locals.tenantId` — string tenant ID shortcut\n *\n * When no tenant ID can be resolved (and no custom `onNoTenant` handler\n * returns a `Response`), the request continues without any tenant context.\n *\n * @param options - Configuration options including the required\n * `resolveTenantId` callback.\n * @returns A SvelteKit `Handle` function suitable for use in `hooks.server.ts`.\n *\n * @example\n * ```typescript\n * // src/hooks.server.ts\n * import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy/adapters';\n *\n * export const handle = createSvelteKitHandle({\n * resolveTenantId: (event) =>\n * event.request.headers.get('x-tenant-id'),\n * onNoTenant: () =>\n * new Response('Tenant required', { status: 400 }),\n * });\n * ```\n *\n * @see SvelteKitHandleOptions\n * @see createExpressMiddleware\n */\nexport function createSvelteKitHandle(options: SvelteKitHandleOptions) {\n const {\n resolveTenantId,\n resolveUserId,\n resolvePermissions,\n isSuperAdmin,\n onNoTenant,\n excludePaths = [],\n } = options;\n\n return async function handle({\n event,\n resolve,\n }: {\n event: SvelteKitEvent;\n resolve: SvelteKitResolve;\n }): Promise<Response> {\n // Check excluded paths\n const path = event.url.pathname;\n if (excludePaths.some((pattern) => matchPath(path, pattern))) {\n return resolve(event);\n }\n\n // Resolve tenant ID\n const tenantId = await resolveTenantId(event);\n\n if (!tenantId) {\n // No tenant ID - call handler or continue without context\n if (onNoTenant) {\n const response = await onNoTenant(event);\n if (response) {\n return response;\n }\n }\n return resolve(event);\n }\n\n // Resolve optional context data\n const userId = resolveUserId ? await resolveUserId(event) : undefined;\n const permissions = resolvePermissions\n ? await resolvePermissions(event, tenantId, userId ?? undefined)\n : new Set<string>();\n const superAdminBypass = isSuperAdmin\n ? await isSuperAdmin(event, tenantId, userId ?? undefined)\n : false;\n\n // Build context\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions,\n superAdminBypass,\n };\n\n // Store in locals for access in load functions\n event.locals.tenantContext = context;\n event.locals.tenantId = tenantId;\n\n // Run request in tenant context\n return withTenant(context, () => resolve(event));\n };\n}\n\n/**\n * Simple glob pattern matching for paths\n */\nfunction matchPath(path: string, pattern: string): boolean {\n // Convert glob to regex\n const regexPattern = pattern\n .replace(/\\*/g, '.*')\n .replace(/\\//g, '\\\\/')\n .replace(/\\?/g, '.');\n\n return new RegExp(`^${regexPattern}$`).test(path);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"adapters-B4hMNcuw.js","names":[],"sources":["../../src/adapters/cli.ts","../../src/adapters/express.ts","../../src/adapters/sveltekit.ts"],"sourcesContent":["/**\n * CLI Adapter for smrt-tenancy\n *\n * Provides utilities for setting up tenant context in CLI tools.\n *\n * @example\n * ```typescript\n * import { createCliContext } from '@happyvertical/smrt-tenancy/adapters';\n *\n * // Create context helper for CLI\n * const cliContext = createCliContext({\n * resolveTenantId: () => process.env.TENANT_ID,\n * });\n *\n * // Run command in tenant context\n * await cliContext.run(async () => {\n * await documentCollection.list({});\n * });\n * ```\n */\n\nimport {\n type TenantContextData,\n withSystemContext,\n withTenant,\n} from '../context.js';\n\n/**\n * Configuration for the CLI context runner created by `createCliContext()`.\n *\n * `resolveTenantId` is optional — when omitted (or when it returns\n * `null`/`undefined`) the `run()` method falls back to `withSystemContext()`.\n *\n * @see createCliContext\n * @see CliContextRunner\n */\nexport interface CliContextOptions {\n /**\n * Resolve tenant ID\n *\n * Common sources:\n * - Environment variable: () => process.env.TENANT_ID\n * - Command line argument: () => argv.tenant\n * - Config file: () => config.tenantId\n */\n resolveTenantId?: () =>\n | Promise<string | null | undefined>\n | string\n | null\n | undefined;\n\n /**\n * Resolve user ID (optional)\n */\n resolveUserId?: () =>\n | Promise<string | null | undefined>\n | string\n | null\n | undefined;\n\n /**\n * Default permissions for CLI operations\n */\n defaultPermissions?: Set<string>;\n\n /**\n * Run CLI as super admin by default\n * @default false\n */\n superAdminByDefault?: boolean;\n}\n\n/**\n * Context runner returned by `createCliContext()`.\n *\n * Provides four execution modes suitable for different CLI scenarios:\n * - `run()` — resolves the tenant from the configured options and runs code in\n * that context; falls back to system context if no tenant is available.\n * - `runWithTenant()` — explicitly specify a tenant ID for this invocation.\n * - `runAsSystem()` — bypass all tenant checks (migration scripts, admin tools).\n * - `runAsSuperAdmin()` — tenant context with bypass flag enabled.\n *\n * @see createCliContext\n */\nexport interface CliContextRunner {\n /**\n * Run `fn` inside the tenant context resolved from the `CliContextOptions`.\n *\n * Falls back to `withSystemContext()` when no tenant ID is available.\n *\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n run<T>(fn: () => Promise<T>): Promise<T>;\n\n /**\n * Run `fn` inside the context of the specified tenant.\n *\n * @param tenantId - Tenant ID to set as context.\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n runWithTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T>;\n\n /**\n * Run `fn` in system context, bypassing all tenant checks.\n *\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n runAsSystem<T>(fn: () => Promise<T>): Promise<T>;\n\n /**\n * Run `fn` with a tenant context and super admin bypass enabled.\n *\n * @param tenantId - Tenant ID to set as context.\n * @param fn - Async function to execute.\n * @returns Promise resolving to the return value of `fn`.\n */\n runAsSuperAdmin<T>(tenantId: string, fn: () => Promise<T>): Promise<T>;\n}\n\n/**\n * Create a CLI context runner\n *\n * @param options - Configuration options\n * @returns CLI context runner with various execution modes\n *\n * @example\n * ```typescript\n * const cli = createCliContext({\n * resolveTenantId: () => process.env.TENANT_ID,\n * superAdminByDefault: true,\n * });\n *\n * // Use resolved tenant\n * await cli.run(async () => {\n * const docs = await collection.list({});\n * console.log(`Found ${docs.length} documents`);\n * });\n *\n * // Override tenant\n * await cli.runWithTenant('other-tenant', async () => {\n * // Operations in other-tenant context\n * });\n *\n * // System operations (no tenant)\n * await cli.runAsSystem(async () => {\n * // Can access all data\n * const allDocs = await collection.list({});\n * });\n * ```\n */\nexport function createCliContext(\n options: CliContextOptions = {},\n): CliContextRunner {\n const {\n resolveTenantId,\n resolveUserId,\n defaultPermissions = new Set<string>(),\n superAdminByDefault = false,\n } = options;\n\n return {\n async run<T>(fn: () => Promise<T>): Promise<T> {\n const tenantId = resolveTenantId ? await resolveTenantId() : null;\n\n if (!tenantId) {\n // No tenant configured - run in system context\n return withSystemContext(fn);\n }\n\n const userId = resolveUserId ? await resolveUserId() : undefined;\n\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions: defaultPermissions,\n superAdminBypass: superAdminByDefault,\n };\n\n return withTenant(context, fn);\n },\n\n async runWithTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {\n const userId = resolveUserId ? await resolveUserId() : undefined;\n\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions: defaultPermissions,\n superAdminBypass: superAdminByDefault,\n };\n\n return withTenant(context, fn);\n },\n\n async runAsSystem<T>(fn: () => Promise<T>): Promise<T> {\n return withSystemContext(fn);\n },\n\n async runAsSuperAdmin<T>(\n tenantId: string,\n fn: () => Promise<T>,\n ): Promise<T> {\n const userId = resolveUserId ? await resolveUserId() : undefined;\n\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions: defaultPermissions,\n superAdminBypass: true,\n };\n\n return withTenant(context, fn);\n },\n };\n}\n\n/**\n * Run an async function with a specific tenant ID set as context.\n *\n * Convenience wrapper around `withTenant()` for one-off CLI operations where\n * a full `CliContextRunner` is not needed.\n *\n * @param tenantId - Tenant ID to set as the active context.\n * @param fn - Async function to execute in the tenant context.\n * @returns Promise resolving to the return value of `fn`.\n *\n * @example\n * ```typescript\n * import { runWithTenant } from '@happyvertical/smrt-tenancy/adapters';\n *\n * await runWithTenant('tenant-123', async () => {\n * await collection.list({});\n * });\n * ```\n *\n * @see createCliContext\n * @see runAsSystem\n */\nexport async function runWithTenant<T>(\n tenantId: string,\n fn: () => Promise<T>,\n): Promise<T> {\n return withTenant({ tenantId }, fn);\n}\n\n/**\n * Run an async function in system context, bypassing all tenant checks.\n *\n * Convenience wrapper around `withSystemContext()` for one-off CLI operations\n * such as migration scripts or admin tooling that needs cross-tenant access.\n *\n * @param fn - Async function to execute in system context.\n * @returns Promise resolving to the return value of `fn`.\n *\n * @example\n * ```typescript\n * import { runAsSystem } from '@happyvertical/smrt-tenancy/adapters';\n *\n * await runAsSystem(async () => {\n * const all = await collection.list({});\n * console.log(`Total records: ${all.length}`);\n * });\n * ```\n *\n * @see runWithTenant\n * @see createCliContext\n */\nexport async function runAsSystem<T>(fn: () => Promise<T>): Promise<T> {\n return withSystemContext(fn);\n}\n","/**\n * Express Adapter for smrt-tenancy\n *\n * Provides Express middleware that sets up tenant context for each request.\n *\n * @example\n * ```typescript\n * import express from 'express';\n * import { createExpressMiddleware } from '@happyvertical/smrt-tenancy/adapters';\n *\n * const app = express();\n *\n * app.use(createExpressMiddleware({\n * resolveTenantId: (req) => req.headers['x-tenant-id'] as string,\n * }));\n * ```\n */\n\nimport { enterTenantContext, type TenantContextData } from '../context.js';\n\n/**\n * Express Request interface (minimal to avoid direct dependency)\n */\ninterface ExpressRequest {\n headers: Record<string, string | string[] | undefined>;\n url: string;\n path: string;\n query: Record<string, unknown>;\n cookies?: Record<string, string>;\n}\n\n/**\n * Request object after the tenancy middleware has attached the resolved tenant\n * context. The middleware sets these properties so downstream route handlers can\n * read the tenant directly off the request.\n */\ninterface TenantAwareExpressRequest extends ExpressRequest {\n tenantContext?: TenantContextData;\n tenantId?: string;\n}\n\n/**\n * Express Response interface\n */\ninterface ExpressResponse {\n status(code: number): ExpressResponse;\n json(data: unknown): ExpressResponse;\n send(data: unknown): ExpressResponse;\n}\n\n/**\n * Express NextFunction\n */\ntype ExpressNext = (error?: unknown) => void;\n\n/**\n * Configuration options for the Express tenancy middleware created by\n * `createExpressMiddleware()`.\n *\n * Only `resolveTenantId` is required. All callback options receive the raw\n * Express `Request` object so you can extract tenant information from headers,\n * subdomains, cookies, or any other request property.\n *\n * @see createExpressMiddleware\n */\nexport interface ExpressMiddlewareOptions {\n /**\n * Resolve tenant ID from the request\n */\n resolveTenantId: (\n req: ExpressRequest,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve user ID from the request (optional)\n */\n resolveUserId?: (\n req: ExpressRequest,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve permissions (optional)\n */\n resolvePermissions?: (\n req: ExpressRequest,\n tenantId: string,\n userId?: string,\n ) => Promise<Set<string>> | Set<string>;\n\n /**\n * Check if user is super admin (optional)\n */\n isSuperAdmin?: (\n req: ExpressRequest,\n tenantId: string,\n userId?: string,\n ) => Promise<boolean> | boolean;\n\n /**\n * Called when no tenant ID could be resolved\n * Return true to continue, false to stop with 400 error.\n */\n onNoTenant?: (\n req: ExpressRequest,\n res: ExpressResponse,\n ) => Promise<boolean> | boolean;\n\n /**\n * Paths to exclude from tenant context\n */\n excludePaths?: string[];\n}\n\n/**\n * Create an Express middleware function that establishes tenant context for\n * every incoming request.\n *\n * Uses `enterTenantContext()` (rather than `withTenant()`) because Express\n * middleware returns before route handlers execute. `enterWith()` sets the\n * context on the current async resource so it propagates to handlers that run\n * after `next()` is called.\n *\n * The resolved context is also attached directly to the request object for\n * convenience:\n * - `req.tenantContext` — full `TenantContextData`\n * - `req.tenantId` — string tenant ID shortcut\n *\n * When no tenant ID can be resolved, the default behaviour returns a `400`\n * JSON response. Customise this with the `onNoTenant` option.\n *\n * @param options - Middleware configuration including the required\n * `resolveTenantId` callback.\n * @returns An Express-compatible middleware function `(req, res, next) => void`.\n *\n * @example\n * ```typescript\n * import express from 'express';\n * import { createExpressMiddleware } from '@happyvertical/smrt-tenancy/adapters';\n *\n * const app = express();\n * app.use(createExpressMiddleware({\n * resolveTenantId: (req) => req.headers['x-tenant-id'] as string,\n * excludePaths: ['/health', '/public/*'],\n * }));\n * ```\n *\n * @see ExpressMiddlewareOptions\n * @see createSvelteKitHandle\n */\nexport function createExpressMiddleware(options: ExpressMiddlewareOptions) {\n const {\n resolveTenantId,\n resolveUserId,\n resolvePermissions,\n isSuperAdmin,\n onNoTenant,\n excludePaths = [],\n } = options;\n\n return async function tenancyMiddleware(\n req: ExpressRequest,\n res: ExpressResponse,\n next: ExpressNext,\n ): Promise<void> {\n try {\n // Check excluded paths\n if (excludePaths.some((pattern) => matchPath(req.path, pattern))) {\n next();\n return;\n }\n\n // Resolve tenant ID\n const tenantId = await resolveTenantId(req);\n\n if (!tenantId) {\n if (onNoTenant) {\n const shouldContinue = await onNoTenant(req, res);\n if (shouldContinue) {\n next();\n return;\n }\n } else {\n res.status(400).json({\n error: 'Tenant ID required',\n message:\n 'Please provide a tenant ID via header, subdomain, or query parameter.',\n });\n return;\n }\n return;\n }\n\n // Resolve optional context\n const userId = resolveUserId ? await resolveUserId(req) : undefined;\n const permissions = resolvePermissions\n ? await resolvePermissions(req, tenantId, userId ?? undefined)\n : new Set<string>();\n const superAdminBypass = isSuperAdmin\n ? await isSuperAdmin(req, tenantId, userId ?? undefined)\n : false;\n\n // Build context\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions,\n superAdminBypass,\n };\n\n // Attach to request for access in route handlers\n (req as TenantAwareExpressRequest).tenantContext = context;\n (req as TenantAwareExpressRequest).tenantId = tenantId;\n\n // Use enterWith() to establish context that persists for the entire\n // request lifecycle. This ensures route handlers have access to\n // tenant context via AsyncLocalStorage.\n enterTenantContext(context);\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Simple glob pattern matching for paths\n */\nfunction matchPath(path: string, pattern: string): boolean {\n const regexPattern = pattern\n .replace(/\\*/g, '.*')\n .replace(/\\//g, '\\\\/')\n .replace(/\\?/g, '.');\n\n return new RegExp(`^${regexPattern}$`).test(path);\n}\n","/**\n * SvelteKit Adapter for smrt-tenancy\n *\n * Provides a SvelteKit Handle that sets up tenant context for each request.\n *\n * @example\n * ```typescript\n * // hooks.server.ts\n * import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy/adapters';\n *\n * export const handle = createSvelteKitHandle({\n * resolveTenantId: async (event) => {\n * // From subdomain\n * const host = event.request.headers.get('host');\n * const subdomain = host?.split('.')[0];\n * return subdomain;\n *\n * // Or from header\n * // return event.request.headers.get('x-tenant-id');\n *\n * // Or from cookie\n * // return event.cookies.get('tenant_id');\n * }\n * });\n * ```\n */\n\nimport { type TenantContextData, withTenant } from '../context.js';\n\n/**\n * SvelteKit RequestEvent (minimal interface to avoid direct dependency)\n */\ninterface SvelteKitEvent {\n request: Request;\n url: URL;\n cookies: {\n get(name: string): string | undefined;\n set(name: string, value: string, opts?: unknown): void;\n };\n locals: Record<string, unknown>;\n}\n\n/**\n * SvelteKit resolve function\n */\ntype SvelteKitResolve = (event: SvelteKitEvent) => Promise<Response>;\n\n/**\n * Configuration options for the SvelteKit tenancy handle created by\n * `createSvelteKitHandle()`.\n *\n * Only `resolveTenantId` is required; all other fields are optional callbacks\n * used to enrich the context or customise missing-tenant behaviour.\n *\n * @see createSvelteKitHandle\n */\nexport interface SvelteKitHandleOptions {\n /**\n * Resolve tenant ID from the request\n *\n * Return the tenant ID string, or null/undefined if no tenant context should be set.\n */\n resolveTenantId: (\n event: SvelteKitEvent,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve user ID from the request (optional)\n */\n resolveUserId?: (\n event: SvelteKitEvent,\n ) => Promise<string | null | undefined> | string | null | undefined;\n\n /**\n * Resolve permissions for the user in this tenant (optional)\n */\n resolvePermissions?: (\n event: SvelteKitEvent,\n tenantId: string,\n userId?: string,\n ) => Promise<Set<string>> | Set<string>;\n\n /**\n * Check if user is a super admin (optional)\n * If true and super admin bypass is enabled on the class, tenant filtering is skipped.\n */\n isSuperAdmin?: (\n event: SvelteKitEvent,\n tenantId: string,\n userId?: string,\n ) => Promise<boolean> | boolean;\n\n /**\n * Called when no tenant ID could be resolved\n * Return a Response to short-circuit, or undefined to continue without tenant context.\n */\n onNoTenant?: (\n event: SvelteKitEvent,\n ) => Promise<Response | undefined> | Response | undefined;\n\n /**\n * Paths to exclude from tenant context (e.g., public APIs, health checks)\n * Supports glob patterns.\n */\n excludePaths?: string[];\n}\n\n/**\n * Create a SvelteKit `Handle` function that establishes tenant context for\n * every server-side request.\n *\n * The returned handle wraps the `resolve` call in `withTenant()` so that all\n * server-side load functions, API routes, and `+server.ts` handlers within the\n * request share the same tenant context via `AsyncLocalStorage`.\n *\n * The resolved context is also stored in `event.locals` under two keys:\n * - `event.locals.tenantContext` — full `TenantContextData`\n * - `event.locals.tenantId` — string tenant ID shortcut\n *\n * When no tenant ID can be resolved (and no custom `onNoTenant` handler\n * returns a `Response`), the request continues without any tenant context.\n *\n * @param options - Configuration options including the required\n * `resolveTenantId` callback.\n * @returns A SvelteKit `Handle` function suitable for use in `hooks.server.ts`.\n *\n * @example\n * ```typescript\n * // src/hooks.server.ts\n * import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy/adapters';\n *\n * export const handle = createSvelteKitHandle({\n * resolveTenantId: (event) =>\n * event.request.headers.get('x-tenant-id'),\n * onNoTenant: () =>\n * new Response('Tenant required', { status: 400 }),\n * });\n * ```\n *\n * @see SvelteKitHandleOptions\n * @see createExpressMiddleware\n */\nexport function createSvelteKitHandle(options: SvelteKitHandleOptions) {\n const {\n resolveTenantId,\n resolveUserId,\n resolvePermissions,\n isSuperAdmin,\n onNoTenant,\n excludePaths = [],\n } = options;\n\n return async function handle({\n event,\n resolve,\n }: {\n event: SvelteKitEvent;\n resolve: SvelteKitResolve;\n }): Promise<Response> {\n // Check excluded paths\n const path = event.url.pathname;\n if (excludePaths.some((pattern) => matchPath(path, pattern))) {\n return resolve(event);\n }\n\n // Resolve tenant ID\n const tenantId = await resolveTenantId(event);\n\n if (!tenantId) {\n // No tenant ID - call handler or continue without context\n if (onNoTenant) {\n const response = await onNoTenant(event);\n if (response) {\n return response;\n }\n }\n return resolve(event);\n }\n\n // Resolve optional context data\n const userId = resolveUserId ? await resolveUserId(event) : undefined;\n const permissions = resolvePermissions\n ? await resolvePermissions(event, tenantId, userId ?? undefined)\n : new Set<string>();\n const superAdminBypass = isSuperAdmin\n ? await isSuperAdmin(event, tenantId, userId ?? undefined)\n : false;\n\n // Build context\n const context: TenantContextData = {\n tenantId,\n userId: userId ?? undefined,\n permissions,\n superAdminBypass,\n };\n\n // Store in locals for access in load functions\n event.locals.tenantContext = context;\n event.locals.tenantId = tenantId;\n\n // Run request in tenant context\n return withTenant(context, () => resolve(event));\n };\n}\n\n/**\n * Simple glob pattern matching for paths\n */\nfunction matchPath(path: string, pattern: string): boolean {\n // Convert glob to regex\n const regexPattern = pattern\n .replace(/\\*/g, '.*')\n .replace(/\\//g, '\\\\/')\n .replace(/\\?/g, '.');\n\n return new RegExp(`^${regexPattern}$`).test(path);\n}\n"],"mappings":"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"}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
//#region src/context.ts
|
|
3
3
|
var SYSTEM_CONTEXT_MARKER = /* @__PURE__ */ Symbol.for("smrt:system-context");
|
|
4
|
-
var
|
|
4
|
+
var TENANT_STORAGE_KEY = /* @__PURE__ */ Symbol.for("smrt:tenant-context-storage");
|
|
5
|
+
function resolveTenantStorage() {
|
|
6
|
+
const root = globalThis;
|
|
7
|
+
root[TENANT_STORAGE_KEY] ??= new AsyncLocalStorage();
|
|
8
|
+
return root[TENANT_STORAGE_KEY];
|
|
9
|
+
}
|
|
10
|
+
var tenantStorage = resolveTenantStorage();
|
|
5
11
|
function getCurrentTenant() {
|
|
6
12
|
const store = tenantStorage.getStore();
|
|
7
13
|
if (store === SYSTEM_CONTEXT_MARKER) return;
|
|
@@ -154,4 +160,4 @@ var TenantIsolationError = class extends Error {
|
|
|
154
160
|
//#endregion
|
|
155
161
|
export { getCurrentTenant as a, isSuperAdminBypass as c, requireTenantId as d, withSuperAdminBypass as f, withTenantSync as h, enterTenantContext as i, isSystemContext as l, withTenant as m, TenantContextError as n, getTenantId as o, withSystemContext as p, TenantIsolationError as r, hasTenantContext as s, TenantContext as t, requireTenant as u };
|
|
156
162
|
|
|
157
|
-
//# sourceMappingURL=context-
|
|
163
|
+
//# sourceMappingURL=context-CwbLwyIV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context-CwbLwyIV.js","names":[],"sources":["../../src/context.ts"],"sourcesContent":["/**\n * TenantContext - AsyncLocalStorage-based tenant context propagation\n *\n * Provides request-scoped tenant context that flows through async operations.\n * This is the core of the tenancy system - all tenant isolation depends on\n * having a valid context.\n *\n * @example Basic usage with middleware\n * ```typescript\n * import { withTenant, requireTenantId } from '@happyvertical/smrt-tenancy';\n *\n * // In middleware (SvelteKit, Express, etc.)\n * await withTenant({ tenantId: 'tenant-123' }, async () => {\n * // All code in this async tree has access to tenant context\n * const id = requireTenantId(); // 'tenant-123'\n * });\n * ```\n *\n * @example Background job binding\n * ```typescript\n * import { TenantContext } from '@happyvertical/smrt-tenancy';\n *\n * // Bind a callback to preserve context across async boundaries\n * setTimeout(TenantContext.bind(() => {\n * console.log(requireTenantId()); // Works!\n * }), 1000);\n * ```\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n */\n\nimport { AsyncLocalStorage } from 'node:async_hooks';\nimport type { DatabaseInterface } from '@happyvertical/sql';\n\n/**\n * Full data stored in tenant context for the current async execution scope.\n *\n * Created by `withTenant()` / `enterTenantContext()` and read by `getCurrentTenant()`,\n * `getTenantId()`, and the interceptor hooks. All fields except `tenantId` and\n * `permissions` are optional and may be populated lazily by higher-level packages\n * (e.g., `smrt-users`).\n *\n * @see withTenant\n * @see MinimalTenantContext\n */\nexport interface TenantContextData {\n /** Current tenant ID (required) */\n tenantId: string;\n\n /** Current tenant object (lazy-loaded if smrt-users is available) */\n tenant?: unknown;\n\n /** Current user ID (optional) */\n userId?: string;\n\n /** Current user object (lazy-loaded if smrt-users is available) */\n user?: unknown;\n\n /** Resolved permissions for this user in this tenant */\n permissions: Set<string>;\n\n /** Database connection for this tenant (if database-per-tenant strategy) */\n database?: DatabaseInterface;\n\n /** Super admin bypass enabled - allows cross-tenant operations */\n superAdminBypass?: boolean;\n\n /** Custom metadata for application-specific data */\n metadata?: Record<string, unknown>;\n}\n\n/**\n * Minimal context accepted by `withTenant()` and `withTenantSync()` when only a\n * tenant ID is known.\n *\n * `permissions` defaults to an empty `Set` when omitted. Use `TenantContextData`\n * when you also need to carry user info, database handles, or resolved permissions.\n *\n * @see TenantContextData\n * @see withTenant\n */\nexport interface MinimalTenantContext {\n /** Tenant identifier. */\n tenantId: string;\n /** Resolved permissions; defaults to an empty Set when omitted. */\n permissions?: Set<string>;\n /** When `true`, tenant auto-filtering is skipped for classes that allow super admin bypass. */\n superAdminBypass?: boolean;\n /** Arbitrary application-specific metadata to carry through the context. */\n metadata?: Record<string, unknown>;\n}\n\n// Sentinel symbol to mark system context (distinct from \"no context\")\nconst SYSTEM_CONTEXT_MARKER = Symbol.for('smrt:system-context');\n\n// Storage type includes the marker for system context\ntype ContextStoreValue = TenantContextData | typeof SYSTEM_CONTEXT_MARKER;\n\n// globalThis key for the shared AsyncLocalStorage. Vite/vitest/SvelteKit\n// server pipelines can evaluate this module more than once from a single copy\n// on disk (separate SSR/node module graphs, CJS/ESM interop); a module-local\n// storage would make context entered through one instance invisible to guards\n// reading from another (e.g. smrt-profiles' isSystemContext() check missing an\n// app's withSystemContext()). Symbol.for() resolves to the same symbol in\n// every instance, so all of them share the one storage below.\nconst TENANT_STORAGE_KEY = Symbol.for('smrt:tenant-context-storage');\n\n// AsyncLocalStorage instance for tenant context, shared across module instances\nfunction resolveTenantStorage(): AsyncLocalStorage<ContextStoreValue> {\n const root = globalThis as typeof globalThis & {\n [TENANT_STORAGE_KEY]?: AsyncLocalStorage<ContextStoreValue>;\n };\n root[TENANT_STORAGE_KEY] ??= new AsyncLocalStorage<ContextStoreValue>();\n return root[TENANT_STORAGE_KEY];\n}\n\nconst tenantStorage = resolveTenantStorage();\n\n/**\n * Get the current tenant context for this async execution scope.\n *\n * Returns `undefined` when called outside any tenant scope or inside a\n * `withSystemContext()` block (the system context marker is treated as \"no\n * tenant data\"). Prefer `requireTenant()` when a context is mandatory.\n *\n * @returns The active `TenantContextData`, or `undefined` if none is set.\n *\n * @example\n * ```typescript\n * const ctx = getCurrentTenant();\n * if (ctx) {\n * console.log('Current tenant:', ctx.tenantId);\n * }\n * ```\n *\n * @see requireTenant\n * @see hasTenantContext\n */\nexport function getCurrentTenant(): TenantContextData | undefined {\n const store = tenantStorage.getStore();\n // Return undefined for system context marker (no tenant data available)\n if (store === SYSTEM_CONTEXT_MARKER) {\n return undefined;\n }\n return store;\n}\n\n/**\n * Get the current tenant context or throw if one is not available.\n *\n * Use this in business-logic code that must run inside a tenant scope.\n * For a non-throwing alternative use `getCurrentTenant()`.\n *\n * @returns The active `TenantContextData`.\n * @throws {TenantContextError} When no tenant context is set (code is outside\n * any `withTenant()` call or the enclosing middleware has not run).\n *\n * @example\n * ```typescript\n * const { tenantId, permissions } = requireTenant();\n * ```\n *\n * @see getCurrentTenant\n * @see requireTenantId\n */\nexport function requireTenant(): TenantContextData {\n const ctx = tenantStorage.getStore();\n if (!ctx || ctx === SYSTEM_CONTEXT_MARKER) {\n throw new TenantContextError(\n 'No tenant context available. ' +\n 'Ensure request is wrapped in withTenant() or middleware is configured.',\n );\n }\n return ctx;\n}\n\n/**\n * Get the current tenant ID or throw if no tenant context is available.\n *\n * Shorthand for `requireTenant().tenantId`.\n *\n * @returns The active tenant ID string.\n * @throws {TenantContextError} When no tenant context is set.\n *\n * @example\n * ```typescript\n * const tenantId = requireTenantId();\n * const rows = await db.query(`SELECT * FROM docs WHERE tenant_id = ?`, [tenantId]);\n * ```\n *\n * @see getTenantId\n * @see requireTenant\n */\nexport function requireTenantId(): string {\n return requireTenant().tenantId;\n}\n\n/**\n * Get the current tenant ID without throwing.\n *\n * Returns `undefined` when called outside any tenant scope or inside a\n * `withSystemContext()` block. Use `requireTenantId()` when a missing context\n * should be treated as an error.\n *\n * @returns The active tenant ID, or `undefined` if none is set.\n *\n * @example\n * ```typescript\n * const tenantId = getTenantId();\n * if (tenantId) {\n * // Optional tenant-scoped logic\n * }\n * ```\n *\n * @see requireTenantId\n * @see hasTenantContext\n */\nexport function getTenantId(): string | undefined {\n const store = tenantStorage.getStore();\n if (store === SYSTEM_CONTEXT_MARKER) {\n return undefined;\n }\n return store?.tenantId;\n}\n\n/**\n * Check whether the current async execution scope has an active tenant context.\n *\n * Returns `false` both when there is no context at all and when code is running\n * inside `withSystemContext()` (the system marker is not a tenant context).\n *\n * @returns `true` if a `TenantContextData` is active, `false` otherwise.\n *\n * @example\n * ```typescript\n * if (hasTenantContext()) {\n * console.log('Tenant:', getTenantId());\n * }\n * ```\n *\n * @see getTenantId\n * @see isSystemContext\n */\nexport function hasTenantContext(): boolean {\n const store = tenantStorage.getStore();\n // System context marker means no tenant context (even though storage is set)\n return store !== undefined && store !== SYSTEM_CONTEXT_MARKER;\n}\n\n/**\n * Check whether the current async execution scope was entered via `withSystemContext()`.\n *\n * A system context is explicitly set to bypass all tenant checks; it is distinct\n * from \"no context\" (undefined store). When the store is undefined the\n * interceptor enforces tenant requirements; when it holds the system marker the\n * interceptor skips all checks.\n *\n * @returns `true` if inside a `withSystemContext()` call, `false` otherwise.\n *\n * @see withSystemContext\n * @see hasTenantContext\n */\nexport function isSystemContext(): boolean {\n return tenantStorage.getStore() === SYSTEM_CONTEXT_MARKER;\n}\n\n/**\n * Check whether the super admin bypass flag is set in the current tenant context.\n *\n * When `true`, the interceptor skips tenant auto-filtering for classes that have\n * `allowSuperAdminBypass: true` in their `@TenantScoped()` config. Returns\n * `false` inside a system context (no tenant data is available).\n *\n * @returns `true` if super admin bypass is active, `false` otherwise.\n *\n * @see withSuperAdminBypass\n * @see TenantScopedOptions.allowSuperAdminBypass\n */\nexport function isSuperAdminBypass(): boolean {\n const store = tenantStorage.getStore();\n if (store === SYSTEM_CONTEXT_MARKER) {\n return false;\n }\n return store?.superAdminBypass === true;\n}\n\n/**\n * Run code within a tenant context (async version)\n *\n * @param context - Tenant context data (at minimum, tenantId)\n * @param fn - Async function to run within the tenant context\n * @returns Promise resolving to the function's return value\n *\n * @example\n * ```typescript\n * await withTenant({ tenantId: 'tenant-123' }, async () => {\n * const id = requireTenantId(); // 'tenant-123'\n * await doSomething();\n * });\n * ```\n */\nexport async function withTenant<T>(\n context: TenantContextData | MinimalTenantContext,\n fn: () => Promise<T>,\n): Promise<T> {\n const fullContext: TenantContextData = {\n permissions: new Set(),\n ...context,\n };\n return tenantStorage.run(fullContext, fn);\n}\n\n/**\n * Run synchronous code within a tenant context.\n *\n * Prefer `withTenant()` for async code. Use this variant only when the\n * callback must be synchronous (e.g., initializing a module-level value that\n * is consumed synchronously downstream).\n *\n * @param context - Tenant context data (at minimum, `tenantId`).\n * @param fn - Synchronous function to run within the tenant context.\n * @returns The return value of `fn`.\n *\n * @example\n * ```typescript\n * const result = withTenantSync({ tenantId: 'tenant-123' }, () => {\n * return computeSomethingSync();\n * });\n * ```\n *\n * @see withTenant\n */\nexport function withTenantSync<T>(\n context: TenantContextData | MinimalTenantContext,\n fn: () => T,\n): T {\n const fullContext: TenantContextData = {\n permissions: new Set(),\n ...context,\n };\n return tenantStorage.run(fullContext, fn);\n}\n\n/**\n * Enter tenant context for the remainder of the current async execution\n *\n * This uses AsyncLocalStorage.enterWith() to establish context that persists\n * until the async resource completes. Useful for Express middleware where\n * the route handler executes after the middleware returns.\n *\n * @param context - Tenant context data\n *\n * @example Express middleware\n * ```typescript\n * app.use((req, res, next) => {\n * const tenantId = req.headers['x-tenant-id'] as string;\n * enterTenantContext({ tenantId });\n * next(); // Route handlers now have tenant context\n * });\n * ```\n */\nexport function enterTenantContext(\n context: TenantContextData | MinimalTenantContext,\n): void {\n const fullContext: TenantContextData = {\n permissions: new Set(),\n ...context,\n };\n tenantStorage.enterWith(fullContext);\n}\n\n/**\n * Run code in system context (bypasses tenant checks)\n *\n * Use this for:\n * - Migration scripts\n * - Admin tools that need cross-tenant access\n * - Background jobs that process multiple tenants\n *\n * System context is explicitly different from \"no context\" - it signals\n * that tenant checks should be bypassed, while no context means the\n * interceptor should enforce tenant requirements.\n *\n * @param fn - Async function to run without tenant context\n *\n * @example\n * ```typescript\n * await withSystemContext(async () => {\n * // No tenant context - can access all data\n * const allDocuments = await documentCollection.list({});\n * });\n * ```\n */\nexport async function withSystemContext<T>(fn: () => Promise<T>): Promise<T> {\n // Run with system context marker (distinct from undefined/no context)\n return tenantStorage.run(SYSTEM_CONTEXT_MARKER, fn);\n}\n\n/**\n * Run async code with the super admin bypass flag enabled on the current\n * tenant context.\n *\n * Unlike `withSystemContext()`, this does **not** remove the tenant context —\n * the caller's `tenantId` remains intact. The interceptor skips\n * auto-filtering only for classes that have `allowSuperAdminBypass: true` in\n * their `@TenantScoped()` config.\n *\n * A tenant context must already be active (i.e., this must be called from\n * within a `withTenant()` scope). Use `withSystemContext()` if no tenant\n * context is available at all.\n *\n * @param fn - Async function to run with super admin bypass enabled.\n * @returns Promise resolving to the return value of `fn`.\n * @throws {TenantContextError} If called outside any tenant context.\n *\n * @example\n * ```typescript\n * await withTenant({ tenantId: 'admin-tenant' }, async () => {\n * await withSuperAdminBypass(async () => {\n * // Can read any tenant's AuditLog (if allowSuperAdminBypass: true)\n * const logs = await auditLogCollection.list({});\n * });\n * });\n * ```\n *\n * @see withSystemContext\n * @see isSuperAdminBypass\n */\nexport async function withSuperAdminBypass<T>(\n fn: () => Promise<T>,\n): Promise<T> {\n const current = tenantStorage.getStore();\n if (!current || current === SYSTEM_CONTEXT_MARKER) {\n throw new TenantContextError(\n 'Cannot enable super admin bypass without a tenant context. ' +\n 'Use withTenant() first or withSystemContext() instead.',\n );\n }\n\n const bypassContext: TenantContextData = {\n ...current,\n superAdminBypass: true,\n };\n\n return tenantStorage.run(bypassContext, fn);\n}\n\n/**\n * Namespace object providing advanced tenant context utilities.\n *\n * Contains helpers for binding callbacks, inspecting context state, and\n * running code with the context stored in a queued job payload. These\n * utilities supplement the standalone exported functions for situations where\n * async context might otherwise be lost (e.g., `setTimeout`, event emitters,\n * message queue consumers).\n *\n * @example\n * ```typescript\n * import { TenantContext } from '@happyvertical/smrt-tenancy';\n *\n * // Preserve context across a setTimeout\n * setTimeout(TenantContext.bind(() => {\n * console.log(getTenantId()); // context is intact\n * }), 500);\n *\n * // Process a queued job\n * await TenantContext.runWithJobContext(job, async () => {\n * await processJob(job);\n * });\n * ```\n */\nexport const TenantContext = {\n /**\n * Bind a callback to the current tenant context\n *\n * Use this when passing callbacks to setTimeout, event emitters,\n * or other APIs that might lose the async context.\n *\n * @param fn - Function to bind to current context\n * @returns Wrapped function that will run in the original context\n *\n * @example\n * ```typescript\n * // Without bind - context is lost\n * setTimeout(() => {\n * console.log(getTenantId()); // undefined!\n * }, 1000);\n *\n * // With bind - context is preserved\n * setTimeout(TenantContext.bind(() => {\n * console.log(getTenantId()); // 'tenant-123'\n * }), 1000);\n * ```\n */\n bind<T extends (...args: unknown[]) => unknown>(fn: T): T {\n const store = tenantStorage.getStore();\n if (!store) {\n // No context to bind, return function as-is\n return fn;\n }\n\n // Preserve the context (including system context marker)\n return ((...args: unknown[]) => {\n return tenantStorage.run(store, () => fn(...args));\n }) as T;\n },\n\n /**\n * Get the current context data (or undefined for system/no context)\n */\n get current(): TenantContextData | undefined {\n return getCurrentTenant();\n },\n\n /**\n * Check if we're in system context\n */\n get isSystem(): boolean {\n return isSystemContext();\n },\n\n /**\n * Run code with context from a job/message payload\n *\n * Useful for processing queued jobs that include tenant metadata.\n *\n * @param job - Job object with tenantId in metadata\n * @param fn - Function to run in the job's tenant context\n *\n * @example\n * ```typescript\n * const job = await queue.pop();\n * await TenantContext.runWithJobContext(job, async () => {\n * await processJob(job);\n * });\n * ```\n */\n async runWithJobContext<T>(\n job: { metadata?: { tenantId?: string }; tenantId?: string },\n fn: () => Promise<T>,\n ): Promise<T> {\n const tenantId = job.metadata?.tenantId ?? job.tenantId;\n if (!tenantId) {\n throw new TenantContextError(\n 'Job does not contain tenant information. ' +\n 'Ensure jobs include tenantId in metadata or as a top-level field.',\n );\n }\n\n return withTenant({ tenantId }, fn);\n },\n};\n\n/**\n * Error thrown when a tenant context is required but not available.\n *\n * Raised by `requireTenant()`, `requireTenantId()`, and the tenant interceptor\n * when a `@TenantScoped({ mode: 'required' })` operation is attempted outside\n * any `withTenant()` scope.\n *\n * The `code` property is always `'TENANT_CONTEXT_REQUIRED'` and can be used for\n * programmatic error handling.\n *\n * @example\n * ```typescript\n * try {\n * const tenantId = requireTenantId();\n * } catch (err) {\n * if (err instanceof TenantContextError) {\n * // err.code === 'TENANT_CONTEXT_REQUIRED'\n * }\n * }\n * ```\n *\n * @see requireTenant\n * @see requireTenantId\n * @see TenantIsolationError\n */\nexport class TenantContextError extends Error {\n /** Stable error code; always `'TENANT_CONTEXT_REQUIRED'`. */\n readonly code = 'TENANT_CONTEXT_REQUIRED';\n\n constructor(message: string) {\n super(message);\n this.name = 'TenantContextError';\n }\n}\n\n/**\n * Error thrown when a tenant isolation boundary is crossed.\n *\n * Raised by the tenant interceptor when:\n * - A `list()` or `get()` query explicitly filters by a tenant ID that does not\n * match the current context tenant.\n * - A `save()` or `delete()` is attempted on an object whose `tenantId` field\n * belongs to a different tenant than the current context.\n * - A raw SQL query is executed against a tenant-scoped class without an\n * explicit bypass (when `rawQueryPolicy` is `'throw'`).\n *\n * The `code` property is always `'TENANT_ISOLATION_VIOLATION'`.\n *\n * @example\n * ```typescript\n * try {\n * await collection.list({ where: { tenantId: 'other-tenant' } });\n * } catch (err) {\n * if (err instanceof TenantIsolationError) {\n * // err.tenantId — context tenant\n * // err.attemptedTenantId — tenant that was attempted\n * }\n * }\n * ```\n *\n * @see TenantContextError\n * @see createTenantInterceptor\n */\nexport class TenantIsolationError extends Error {\n /** Stable error code; always `'TENANT_ISOLATION_VIOLATION'`. */\n readonly code = 'TENANT_ISOLATION_VIOLATION';\n /** The tenant ID that is active in the current context. */\n readonly tenantId?: string;\n /** The tenant ID that was attempted (and rejected). */\n readonly attemptedTenantId?: string;\n\n constructor(\n message: string,\n details?: { tenantId?: string; attemptedTenantId?: string },\n ) {\n super(message);\n this.name = 'TenantIsolationError';\n this.tenantId = details?.tenantId;\n this.attemptedTenantId = details?.attemptedTenantId;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as getCurrentTenant, c as isSuperAdminBypass, l as isSystemContext, m as withTenant, n as TenantContextError, o as getTenantId, p as withSystemContext, r as TenantIsolationError, s as hasTenantContext } from "./context-
|
|
1
|
+
import { a as getCurrentTenant, c as isSuperAdminBypass, l as isSystemContext, m as withTenant, n as TenantContextError, o as getTenantId, p as withSystemContext, r as TenantIsolationError, s as hasTenantContext } from "./context-CwbLwyIV.js";
|
|
2
2
|
import { GlobalInterceptors, ObjectRegistry, setDispatchTenantResolver, setTenantEntryPointRunner, setTenantScopedClassResolver } from "@happyvertical/smrt-core";
|
|
3
3
|
import { createLogger } from "@happyvertical/logger";
|
|
4
4
|
//#region src/registry.ts
|
|
@@ -375,4 +375,4 @@ async function assertTenantIsolationViolation(fn, messageContains) {
|
|
|
375
375
|
//#endregion
|
|
376
376
|
export { unregisterTenantScopedClass as _, setupTestTenancy as a, disableTenancy as c, isTenancyEnabled as d, clearTenantScopedRegistry as f, registerTenantScopedClass as g, isTenantScopedClass as h, resetTenancy as i, enableTenancy as l, getTenantScopedConfig as m, assertTenantIsolationViolation as n, testTenantIsolation as o, getAllTenantScopedClasses as p, createTestTenantContext as r, createTenantInterceptor as s, assertTenantContextRequired as t, runTenantScopedEntryPoint as u };
|
|
377
377
|
|
|
378
|
-
//# sourceMappingURL=testing-
|
|
378
|
+
//# sourceMappingURL=testing-CxhEt3bu.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing-5mZMoGX9.js","names":[],"sources":["../../src/registry.ts","../../src/enabled-state.ts","../../src/entry-point.ts","../../src/interceptor.ts","../../src/testing.ts"],"sourcesContent":["/**\n * Tenant-Scoped Class Registry\n *\n * Tracks which classes are tenant-scoped and their configuration.\n * Used by the interceptor to determine how to handle operations.\n *\n * This registry supports two patterns:\n * 1. @TenantScoped() decorator + tenantId field (original pattern)\n * 2. @smrt({ tenantScoped: true }) in smrt-core (Issue #688 pattern)\n *\n * Both patterns are automatically recognized by the interceptor.\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n * @see https://github.com/happyvertical/smrt/issues/688\n */\n\nimport { ObjectRegistry } from '@happyvertical/smrt-core';\n\n/**\n * Resolved tenancy configuration for a single class, as stored in the registry.\n *\n * Every field has a concrete (non-optional) value — defaults are applied by\n * `registerTenantScopedClass()` when the class is registered via `@TenantScoped()`.\n *\n * @see TenantScopedOptions\n * @see registerTenantScopedClass\n */\nexport interface TenantScopedConfig {\n /**\n * Tenancy mode for this class\n * - 'required': Must have tenant context for all operations\n * - 'optional': Works with or without tenant context\n * @default 'required'\n */\n mode: 'required' | 'optional';\n\n /**\n * Field name containing tenant ID\n * @default 'tenantId'\n */\n field: string;\n\n /**\n * Auto-filter all queries by tenant\n * @default true\n */\n autoFilter: boolean;\n\n /**\n * Auto-populate tenant ID from context on create\n * @default true\n */\n autoPopulate: boolean;\n\n /**\n * Allow super admin bypass for this class\n * @default false\n */\n allowSuperAdminBypass: boolean;\n}\n\nconst DEFAULT_CONFIG: TenantScopedConfig = {\n mode: 'required',\n field: 'tenantId',\n autoFilter: true,\n autoPopulate: true,\n allowSuperAdminBypass: false,\n};\n\n// Registry storing tenant-scoped class configurations\nconst tenantScopedClasses = new Map<string, TenantScopedConfig>();\n\n/**\n * Register a class as tenant-scoped with the given configuration.\n *\n * Called automatically by the `@TenantScoped()` decorator. You can also call\n * this directly when you cannot use decorators (e.g., third-party classes or\n * plain objects in tests). Defaults from `DEFAULT_CONFIG` are merged over any\n * omitted options.\n *\n * Calling this again for the same `className` overwrites the previous entry.\n *\n * @param className - The class's `name` property (e.g., `'Document'`).\n * @param config - Partial tenancy configuration; omitted fields receive defaults.\n *\n * @example\n * ```typescript\n * // Manually register a class (e.g., for testing)\n * registerTenantScopedClass('Document', { mode: 'optional' });\n * ```\n *\n * @see TenantScoped\n * @see unregisterTenantScopedClass\n */\nexport function registerTenantScopedClass(\n className: string,\n config: Partial<TenantScopedConfig> = {},\n): void {\n tenantScopedClasses.set(className, {\n ...DEFAULT_CONFIG,\n ...config,\n });\n}\n\n/**\n * Remove a class from the tenant-scoped registry.\n *\n * Primarily intended for test teardown — use `clearTenantScopedRegistry()` to\n * reset the entire registry at once.\n *\n * @param className - The class name to remove (e.g., `'Document'`).\n *\n * @see clearTenantScopedRegistry\n * @see registerTenantScopedClass\n */\nexport function unregisterTenantScopedClass(className: string): void {\n tenantScopedClasses.delete(className);\n}\n\n/**\n * Strip a qualified `@scope/pkg:ClassName` name down to its bare class name.\n *\n * `@TenantScoped` registers classes by their simple name (`target.name`), but\n * `ObjectRegistry.getInheritanceChain()` emits **qualified** names where a\n * class has package context. The simple-name bridge this enables is confined to\n * the inheritance walk (`getInheritedTenantScopedConfig`) — never the direct\n * lookup — so a *direct* qualified lookup can't strip the namespace and\n * cross-match a same-simple-name class in another package.\n */\nfunction toSimpleClassName(className: string): string {\n const idx = className.lastIndexOf(':');\n return idx === -1 ? className : className.slice(idx + 1);\n}\n\n/**\n * Return a shallow copy of a config so callers can never mutate the stored\n * registration. The same backing object is shared by the base and every\n * inheriting descendant, so handing out the reference would let an accidental\n * caller mutation silently corrupt the base (and all children). (#1598 review)\n */\nfunction cloneConfig(config: TenantScopedConfig): TenantScopedConfig {\n return { ...config };\n}\n\n/**\n * Resolve a class's OWN tenancy configuration — no STI inheritance, EXACT name\n * match only.\n *\n * Checks the two registration mechanisms in order, with the local registry\n * taking precedence:\n * 1. The local registry populated by `@TenantScoped()` (keyed by simple name).\n * 2. The core `ObjectRegistry` populated by `@smrt({ tenantScoped: true })`.\n *\n * Lookups are by exact name only — no simple-name fallback — so a qualified\n * lookup (e.g. `@happyvertical/smrt-affiliates:Payout`, explicitly not scoped)\n * can never strip its namespace and match a same-simple-name scoped class in\n * another package (e.g. `@happyvertical/smrt-commerce:Payout`). The\n * namespace-stripping bridge lives only in the inheritance walk. (#1598 review)\n */\nfunction getDirectTenantScopedConfig(\n className: string,\n): TenantScopedConfig | undefined {\n // 1. Local registry (explicit @TenantScoped decorator).\n const localConfig = tenantScopedClasses.get(className);\n if (localConfig) {\n return cloneConfig(localConfig);\n }\n\n // 2. Core registry (@smrt({ tenantScoped: true }) pattern - Issue #688).\n // findClass() resolves qualified names package-safely, so this branch is\n // already disambiguated.\n const coreConfig = ObjectRegistry.getTenantScopedConfig(className);\n if (coreConfig) {\n // Convert core config to TenantScopedConfig format\n return {\n mode: coreConfig.mode,\n field: coreConfig.field,\n autoFilter: coreConfig.autoFilter,\n autoPopulate: coreConfig.autoPopulate,\n allowSuperAdminBypass: coreConfig.allowSuperAdminBypass,\n };\n }\n\n return undefined;\n}\n\n/**\n * Resolve tenancy configuration inherited from an STI/ancestor class.\n *\n * `@TenantScoped` (and `@smrt({ tenantScoped })`) register ONLY the exact class\n * decorated — recognition does NOT propagate to subclasses. Before #1596 this\n * meant an STI child with its own collection (the child is the collection's\n * `_itemClass`) was treated as non-tenant-scoped at runtime: the interceptor\n * skipped tenant filtering on its `list()`/`get()` (cross-tenant reads), skipped\n * tenant population in `beforeSave`, and skipped the raw-SQL policy. Manual\n * re-declaration on every child was the fragile pattern that already bit images\n * (#1407) and messages.\n *\n * We now walk the STI inheritance chain so any descendant of a tenant-scoped\n * base is recognized automatically and inherits the base's config. A subclass\n * of a tenant-scoped class is always itself tenant-scoped — there is no safe\n * reason for it to opt out — so the walk intentionally covers any inheritance\n * (the motivating leak is STI child collections, but this is correct for CTI\n * hierarchies too).\n *\n * Ancestors are walked from nearest-to-self toward the root, returning the\n * first tenant-scoped ancestor's config so a closer ancestor wins. The class's\n * OWN declaration is resolved by the direct lookup in `getTenantScopedConfig`\n * and always takes precedence over anything inherited here.\n */\nfunction getInheritedTenantScopedConfig(\n className: string,\n): TenantScopedConfig | undefined {\n // getInheritanceChain returns [root, ..., self] (qualified names where the\n // class has package context). It is cached by core and only reached here when\n // the direct lookup misses, so the per-call cost on non-tenant classes is a\n // cache hit plus this short loop. Returns [] for unregistered classes.\n const chain = ObjectRegistry.getInheritanceChain(className);\n // chain[length - 1] is the class itself (already covered by the direct\n // lookup); walk its ancestors from nearest to root.\n for (let i = chain.length - 2; i >= 0; i--) {\n const ancestor = chain[i];\n\n // Exact, package-safe match first — covers `@smrt({ tenantScoped })` bases\n // (resolved through the core registry by qualified name) and any class\n // whose @TenantScoped key matches the chain entry verbatim.\n const direct = getDirectTenantScopedConfig(ancestor);\n if (direct) {\n return direct;\n }\n\n // @TenantScoped registers by SIMPLE name (`target.name`), but the chain\n // emits QUALIFIED names. Bridge to the simple-keyed local registry here —\n // scoped to the inheritance walk only, so a direct qualified lookup never\n // strips the namespace (see getDirectTenantScopedConfig). The chain entry\n // is a verified ancestor of `className`, so matching its simple name is the\n // intended hop. (Residual: the @TenantScoped registry is simple-keyed, so\n // two DISTINCT same-simple-name classes that are BOTH @TenantScoped across\n // packages could cross-match here — a pre-existing decorator-keying limit,\n // not the direct-lookup hazard fixed above.)\n const simple = toSimpleClassName(ancestor);\n if (simple !== ancestor) {\n const bySimple = tenantScopedClasses.get(simple);\n if (bySimple) {\n return cloneConfig(bySimple);\n }\n }\n }\n return undefined;\n}\n\n/**\n * Retrieve the resolved tenancy configuration for a class.\n *\n * Resolution order:\n * 1. The class's OWN declaration — local `@TenantScoped()` registry first, then\n * the core `@smrt({ tenantScoped: true })` registry.\n * 2. STI inheritance — the nearest tenant-scoped ancestor's config (#1596).\n *\n * A class that declares its own tenancy never reaches step 2, so an explicit\n * child `@TenantScoped` always overrides the inherited base config.\n *\n * @param className - The class name to look up.\n * @returns The `TenantScopedConfig` if the class is tenant-scoped directly or\n * by inheritance, or `undefined` if it is not.\n *\n * @see isTenantScopedClass\n * @see getAllTenantScopedClasses\n */\nexport function getTenantScopedConfig(\n className: string,\n): TenantScopedConfig | undefined {\n // A class's own @TenantScoped / @smrt({ tenantScoped }) declaration wins.\n const direct = getDirectTenantScopedConfig(className);\n if (direct) {\n return direct;\n }\n // Otherwise inherit recognition from a tenant-scoped STI ancestor (#1596).\n return getInheritedTenantScopedConfig(className);\n}\n\n/**\n * Return `true` if the named class is tenant-scoped — directly (via\n * `@TenantScoped()` / `@smrt({ tenantScoped: true })`) or by inheriting from a\n * tenant-scoped STI ancestor (#1596).\n *\n * @param className - The class name to look up (e.g., `'Document'`).\n * @returns `true` if the class is tenant-scoped by any mechanism.\n *\n * @see getTenantScopedConfig\n * @see registerTenantScopedClass\n */\nexport function isTenantScopedClass(className: string): boolean {\n return getTenantScopedConfig(className) !== undefined;\n}\n\n/**\n * Return a snapshot of all classes registered via `@TenantScoped()`.\n *\n * Returns a new `Map` so mutations to the returned value do not affect the\n * internal registry. Note that classes registered only through the core\n * `ObjectRegistry` (`@smrt({ tenantScoped: true })`) are **not** included in\n * this map.\n *\n * @returns A copy of the local tenant-scoped class registry, keyed by class name.\n *\n * @see isTenantScopedClass\n * @see getTenantScopedConfig\n */\nexport function getAllTenantScopedClasses(): Map<string, TenantScopedConfig> {\n return new Map(tenantScopedClasses);\n}\n\n/**\n * Remove all entries from the local tenant-scoped class registry.\n *\n * Intended for test teardown via `resetTenancy()`. Does not affect\n * registrations held by the core `ObjectRegistry`.\n *\n * @see resetTenancy\n * @see unregisterTenantScopedClass\n */\nexport function clearTenantScopedRegistry(): void {\n tenantScopedClasses.clear();\n}\n","/**\n * Shared tenancy-enabled flag.\n *\n * Holds the single boolean toggled by `enableTenancy()` / `disableTenancy()`.\n * It lives in its own leaf module (importing nothing from the package) so that\n * both `interceptor.ts` and `entry-point.ts` can read it without forming a\n * circular import: `interceptor.ts` imports `runTenantScopedEntryPoint` from\n * `entry-point.ts`, and `entry-point.ts` needs the enabled flag — routing the\n * flag through here keeps that dependency one-directional.\n */\n\nlet enabled = false;\n\n/**\n * Set the global tenancy-enabled flag. Internal — called by `enableTenancy()` /\n * `disableTenancy()` in `interceptor.ts`.\n *\n * @param value - `true` to mark tenancy enabled, `false` to clear it.\n */\nexport function setTenancyEnabled(value: boolean): void {\n enabled = value;\n}\n\n/**\n * Return `true` if tenant enforcement is currently active.\n *\n * @returns Whether `enableTenancy()` has been called without a later\n * `disableTenancy()`.\n */\nexport function isTenancyEnabled(): boolean {\n return enabled;\n}\n","/**\n * Fail-closed tenant-context establishment for non-web entry points (#1554).\n *\n * The SvelteKit/Express adapters establish tenant context from the authenticated\n * request principal, so the web surface of a `@TenantScoped({ mode: 'optional' })`\n * model never reads across tenants without an active context. The generated\n * **CLI** and **MCP** entry points have no request principal, so an invocation\n * with no active context would fall through the interceptor's optional-mode\n * pass-through and return rows across **all** tenants.\n *\n * `runTenantScopedEntryPoint()` closes that gap. It is the single fail-closed\n * gate both generated surfaces wrap their per-command/per-tool execution in.\n *\n * @see createCliContext for the richer CLI runner (resolveTenantId, super-admin).\n */\n\nimport {\n hasTenantContext,\n isSystemContext,\n TenantContextError,\n withSystemContext,\n withTenant,\n} from './context.js';\nimport { isTenancyEnabled } from './enabled-state.js';\nimport { isTenantScopedClass } from './registry.js';\n\n/**\n * Inputs for {@link runTenantScopedEntryPoint}.\n *\n * Provide **either** `className` (the gate resolves tenant-scoping from the\n * authoritative tenancy registry — the same source the interceptor uses, so it\n * covers both `@TenantScoped` and `@smrt({ tenantScoped })` registrations) or an\n * explicit `tenantScoped` boolean (when the caller already resolved it, e.g. a\n * build-time generated surface). An explicit boolean wins when both are given.\n */\nexport interface TenantEntryPointOptions {\n /**\n * Class name of the target model. When provided, tenant-scoping is resolved\n * via `isTenantScopedClass(className)`.\n */\n className?: string;\n\n /**\n * Explicit tenant-scoping decision. Overrides `className` resolution when set.\n * Non-scoped models always pass through unchanged — the gate is a no-op.\n */\n tenantScoped?: boolean;\n\n /**\n * Explicit operator-provided tenant selector (CLI `--tenant <id>`, MCP\n * `context.tenantId`). When present (and no context is already active) the\n * function runs inside this tenant's context.\n */\n tenantId?: string | null;\n\n /**\n * Explicit operator opt-in to cross-tenant / system access (CLI\n * `--all-tenants`, an MCP host that trusts the caller as an operator). When\n * set the function runs in system context, bypassing tenant filtering.\n *\n * @default false\n */\n allowCrossTenant?: boolean;\n\n /**\n * Human-facing surface name used in the fail-closed error message, e.g.\n * `'CLI'` or `'MCP'`.\n *\n * @default 'entry point'\n */\n surface?: string;\n}\n\n/**\n * Run `fn` inside an appropriate tenant context for a generated CLI/MCP entry\n * point, failing closed for tenant-scoped models when no authorized context can\n * be established.\n *\n * Resolution order (tenant-scoped models only):\n * 1. A tenant context is already active, or an explicit `withSystemContext()`\n * bypass is in effect (e.g. `runAsSystem()`, migrations) → run as-is.\n * 2. `allowCrossTenant` was explicitly set → run in system context. Checked\n * before `tenantId` so an explicit cross-tenant opt-in wins over a default\n * principal/host tenant rather than being silently scoped.\n * 3. An explicit `tenantId` was provided → run inside that tenant.\n * 4. Tenancy is enabled but none of the above → **throw** `TenantContextError`\n * (the fail-closed branch — never silently read across tenants).\n * 5. Tenancy is disabled (single-/no-tenant deployment) → pass through.\n *\n * Non-tenant-scoped models always pass straight through.\n *\n * @param options - {@link TenantEntryPointOptions}.\n * @param fn - The command/tool body to execute.\n * @returns The resolved value of `fn`.\n * @throws {TenantContextError} When a tenant-scoped model is reached with\n * tenancy enabled and no tenant/cross-tenant selector.\n */\nexport async function runTenantScopedEntryPoint<T>(\n options: TenantEntryPointOptions,\n fn: () => Promise<T>,\n): Promise<T> {\n const {\n className,\n tenantScoped,\n tenantId,\n allowCrossTenant = false,\n surface = 'entry point',\n } = options;\n\n // Resolve tenant-scoping: an explicit boolean wins; otherwise consult the\n // authoritative tenancy registry by class name (matches the interceptor).\n const scoped =\n typeof tenantScoped === 'boolean'\n ? tenantScoped\n : className\n ? isTenantScopedClass(className)\n : false;\n\n // Non-scoped models run as-is. So do calls already inside a tenant context\n // (an upstream handle) or an explicit system-context bypass — the interceptor\n // honors `withSystemContext()` (migrations, `runAsSystem()`), so the gate must\n // not fail-close over it (hasTenantContext() is false for the system marker).\n if (!scoped) return fn();\n if (hasTenantContext() || isSystemContext()) return fn();\n\n // Explicit operator opt-in to cross-tenant access. Checked before the tenant\n // selector so a deliberate `--all-tenants` / `allowCrossTenant` overrides a\n // default host/principal tenant instead of being silently scoped to it.\n if (allowCrossTenant) {\n return withSystemContext(fn);\n }\n\n // Explicit tenant selector.\n if (typeof tenantId === 'string' && tenantId) {\n return withTenant({ tenantId }, fn);\n }\n\n // Fail closed: tenancy is on but the caller gave us nothing to scope by.\n if (isTenancyEnabled()) {\n throw new TenantContextError(\n `Tenant context required for tenant-scoped access via ${surface}. ` +\n 'Pass an explicit tenant (e.g. --tenant <id> / a tenantId) or opt into ' +\n 'cross-tenant access (e.g. --all-tenants) to read across all tenants.',\n );\n }\n\n // Tenancy disabled → single-tenant deployment, pass through.\n return fn();\n}\n","/**\n * Tenant Interceptor - Core enforcement mechanism\n *\n * Registers with GlobalInterceptors in smrt-core to automatically:\n * - Filter queries by tenant ID\n * - Validate tenant context on save/delete\n * - Block or audit raw SQL on tenant-scoped classes\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { SmrtObject } from '@happyvertical/smrt-core';\nimport {\n type CollectionInterceptor,\n type DispatchBus,\n GlobalInterceptors,\n type InterceptorContext,\n type ListOptions,\n type QueryInterceptResult,\n type QueryOptions,\n setDispatchTenantResolver,\n setTenantEntryPointRunner,\n setTenantScopedClassResolver,\n} from '@happyvertical/smrt-core';\nimport {\n getCurrentTenant,\n getTenantId,\n isSuperAdminBypass,\n isSystemContext,\n TenantContextError,\n TenantIsolationError,\n} from './context.js';\nimport { isTenancyEnabled, setTenancyEnabled } from './enabled-state.js';\nimport { runTenantScopedEntryPoint } from './entry-point.js';\nimport { getTenantScopedConfig, isTenantScopedClass } from './registry.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Policy controlling what happens when raw SQL is executed against a\n * tenant-scoped class without an explicit bypass.\n *\n * - `'throw'` — Raises a `TenantIsolationError` (most secure; default).\n * - `'warn'` — Logs a `console.warn` but allows the query to proceed (useful\n * during migration periods).\n * - `'allow'` — Silently allows the query; not recommended for production.\n *\n * @see TenantInterceptorOptions.rawQueryPolicy\n * @see enableTenancy\n */\nexport type RawQueryPolicy = 'throw' | 'warn' | 'allow';\n\n/**\n * Configuration options accepted by `createTenantInterceptor()` and\n * `enableTenancy()`.\n *\n * All options are optional; reasonable defaults are applied. The callback\n * hooks (`onRawQuery`, `onMissingContext`, `onIsolationViolation`) are useful\n * for logging and alerting without altering the enforcement behaviour.\n *\n * @see createTenantInterceptor\n * @see enableTenancy\n */\nexport interface TenantInterceptorOptions {\n /**\n * Policy for raw SQL queries on tenant-scoped classes\n * - 'throw': Throw error (most secure, default)\n * - 'warn': Log warning but allow (for migration)\n * - 'allow': Silently allow (not recommended for production)\n * @default 'throw'\n */\n rawQueryPolicy?: RawQueryPolicy;\n\n /**\n * Called when a raw query is attempted on a tenant-scoped class\n * Useful for logging/auditing\n */\n onRawQuery?: (\n className: string,\n sql: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * Called when tenant context is missing for a tenant-scoped operation\n */\n onMissingContext?: (\n className: string,\n operation: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * Called when an isolation violation is detected\n */\n onIsolationViolation?: (\n className: string,\n expectedTenantId: string,\n actualTenantId: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * DispatchBus instance for emitting provisioning events on lifecycle changes.\n * When provided along with directoryClasses, afterSave/afterDelete hooks\n * emit dispatches like `directory.membership.created`.\n */\n dispatchBus?: DispatchBus;\n\n /**\n * Class names to emit directory dispatches for on save/delete lifecycle events.\n * Only classes listed here will trigger dispatch emissions.\n * @example ['Tenant', 'Membership', 'User']\n */\n directoryClasses?: string[];\n}\n\nconst DEFAULT_OPTIONS: TenantInterceptorOptions = {\n rawQueryPolicy: 'throw',\n};\n\n/**\n * Extract a plain-object snapshot of an instance for dispatch payloads.\n *\n * Prefers `toJSON()` when available (all real SmrtObject instances) because\n * it returns only data fields and excludes internal handles like `_db`, `_ai`,\n * and `_fs` which may contain circular references (e.g. connection pools with\n * Timeout objects).\n *\n * @see https://github.com/happyvertical/smrt/issues/946\n */\nfunction serializeInstance(\n instance: SmrtObject,\n className: string,\n): Record<string, unknown> {\n // Documented exception to the \"never call toJSON() directly\" convention\n // (docs/content/standards.md §7): the interceptor must serialize whatever\n // instance is handed to it, including workspace stubs and plain-object\n // doubles used in unit tests whose classes may not extend SmrtObject and\n // therefore have no `transformJSON()` hook. Using `toJSON()` here is a\n // duck-typed fallback — when present, it strips framework-internal handles\n // for us; when absent, we fall through to manual key iteration below.\n const maybeToJSON = (instance as { toJSON?: unknown }).toJSON;\n if (typeof maybeToJSON === 'function') {\n return {\n className,\n ...(maybeToJSON.call(instance) as Record<string, unknown>),\n };\n }\n\n // Fallback for plain-object stubs (e.g. in unit tests):\n // skip functions and framework-internal properties\n const result: Record<string, unknown> = { className };\n const record = instance as unknown as Record<string, unknown>;\n for (const key of Object.keys(instance)) {\n const value = record[key];\n if (typeof value !== 'function') {\n result[key] = value;\n }\n }\n return result;\n}\n\n/**\n * Create a `CollectionInterceptor` that enforces tenant isolation on all\n * `SmrtCollection` operations.\n *\n * The returned interceptor hooks into the smrt-core `GlobalInterceptors`\n * pipeline at priority 100 (runs before all other interceptors) and\n * handles the following lifecycle hooks:\n *\n * | Hook | Behaviour |\n * |---------------|-----------|\n * | `beforeList` | Injects tenant filter into `WHERE`; validates explicit filters. |\n * | `beforeGet` | Converts ID lookups to `{ id, tenantId }` filter objects. |\n * | `beforeSave` | Auto-populates `tenantId`; validates existing values. |\n * | `beforeDelete`| Validates the instance's `tenantId` matches context. |\n * | `beforeQuery` | Enforces `rawQueryPolicy` on raw SQL calls. |\n * | `afterSave` | Emits `directory.<class>.created/updated` via `dispatchBus`. |\n * | `afterDelete` | Emits `directory.<class>.deleted` via `dispatchBus`. |\n *\n * Use `enableTenancy()` to register the interceptor globally. Call this\n * directly only when you need multiple interceptor instances (e.g., for\n * isolated tests or feature flags).\n *\n * @param options - Configuration for the interceptor.\n * @returns A `CollectionInterceptor` ready to be registered with\n * `GlobalInterceptors.register()`.\n *\n * @example\n * ```typescript\n * import { createTenantInterceptor } from '@happyvertical/smrt-tenancy';\n * import { GlobalInterceptors } from '@happyvertical/smrt-core';\n *\n * const interceptor = createTenantInterceptor({ rawQueryPolicy: 'warn' });\n * GlobalInterceptors.register(interceptor);\n * ```\n *\n * @see enableTenancy\n * @see TenantInterceptorOptions\n */\nexport function createTenantInterceptor(\n options: TenantInterceptorOptions = {},\n): CollectionInterceptor {\n const opts = { ...DEFAULT_OPTIONS, ...options };\n\n return {\n name: 'smrt-tenancy',\n priority: 100, // High priority - should run first\n\n /**\n * Before list: Add tenant filter to queries\n */\n beforeList(\n className: string,\n listOptions: ListOptions,\n context: InterceptorContext,\n ): ListOptions | undefined {\n // Check if this class is tenant-scoped\n if (!isTenantScopedClass(className)) {\n return; // Not tenant-scoped, pass through\n }\n\n // Check for super admin bypass\n if (isSuperAdminBypass()) {\n return; // Bypass enabled, pass through\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantContext = getCurrentTenant();\n\n // If no tenant context and mode is 'required', throw\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'list', context);\n throw new TenantContextError(\n `Tenant context required for listing ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return; // Mode is 'optional', allow without filtering\n }\n\n // Add tenant filter to where clause\n const tenantField = config?.field || 'tenantId';\n const where = listOptions.where || {};\n\n // Check if tenant filter is already present\n if (tenantField in where) {\n // Validate it matches context. The filter may be a scalar\n // (`tenantId: 'x'`) or an IN-style array (`tenantId: ['x']`) —\n // smrt-core auto-converts array values to SQL IN clauses, so an\n // array containing only the context tenant is a valid filter.\n // See https://github.com/happyvertical/smrt/issues/1495\n const existingFilter = where[tenantField];\n const filterValues = Array.isArray(existingFilter)\n ? existingFilter\n : [existingFilter];\n // findIndex (not find) so a literal null/undefined filter value is\n // still flagged as a violation rather than mistaken for \"not found\"\n const offendingIndex = filterValues.findIndex(\n (value) => value !== tenantContext.tenantId,\n );\n if (offendingIndex !== -1) {\n const offending = filterValues[offendingIndex];\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n String(offending),\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation in ${className} query: ` +\n `context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: String(offending),\n },\n );\n }\n return; // Filter already correct\n }\n\n // Inject tenant filter\n return {\n ...listOptions,\n where: {\n ...where,\n [tenantField]: tenantContext.tenantId,\n },\n };\n },\n\n /**\n * Before get: Add tenant filter to single record fetches\n */\n beforeGet(\n className: string,\n filter: string | Record<string, unknown>,\n context: InterceptorContext,\n ): string | Record<string, unknown> | undefined {\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantContext = getCurrentTenant();\n\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'get', context);\n throw new TenantContextError(\n `Tenant context required for getting ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return;\n }\n\n const tenantField = config?.field || 'tenantId';\n\n // If filter is a string (ID), convert to object filter with tenant\n if (typeof filter === 'string') {\n return {\n id: filter,\n [tenantField]: tenantContext.tenantId,\n };\n }\n\n // Add tenant filter to object\n if (!(tenantField in filter)) {\n return {\n ...filter,\n [tenantField]: tenantContext.tenantId,\n };\n }\n\n // Validate existing filter. Like beforeList, accept scalar or\n // IN-style array filters (smrt-core auto-converts arrays to SQL IN).\n // See https://github.com/happyvertical/smrt/issues/1495\n const existingFilter = filter[tenantField];\n const filterValues = Array.isArray(existingFilter)\n ? existingFilter\n : [existingFilter];\n // findIndex (not find) so a literal null/undefined filter value is\n // still flagged as a violation rather than mistaken for \"not found\"\n const offendingIndex = filterValues.findIndex(\n (value) => value !== tenantContext.tenantId,\n );\n if (offendingIndex !== -1) {\n const offending = filterValues[offendingIndex];\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n String(offending),\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation in ${className} get: ` +\n `context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: String(offending),\n },\n );\n }\n\n return;\n },\n\n /**\n * Before query: Handle raw SQL on tenant-scoped classes\n */\n beforeQuery(\n className: string,\n queryOptions: QueryOptions,\n context: InterceptorContext,\n ): QueryInterceptResult | undefined {\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n // Check for explicit bypass flag\n if (queryOptions.allowRawOnTenantScoped) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return; // Explicitly allowed\n }\n\n if (isSuperAdminBypass()) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return;\n }\n\n // Handle based on policy\n const message =\n `Raw SQL query attempted on tenant-scoped class ${className}. ` +\n `Use list()/get() for automatic tenant filtering, or call ` +\n `query() with { allowRawOnTenantScoped: true } if you're handling ` +\n `tenant filtering manually.`;\n\n opts.onRawQuery?.(className, queryOptions.sql, context);\n\n switch (opts.rawQueryPolicy) {\n case 'throw':\n throw new TenantIsolationError(message);\n\n case 'warn':\n logger.warn(`[smrt-tenancy] WARNING: ${message}`);\n return;\n default:\n return;\n }\n },\n\n /**\n * Before save: Validate tenant ID is set and matches context\n */\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n // Use context.className which is always correct\n // (instance.constructor.name may not match for proxies or plain objects in tests)\n const className = context.className;\n\n // Stash isNew flag for afterSave dispatch detection\n if (opts.directoryClasses?.includes(className)) {\n const id = (instance as unknown as Record<string, unknown>).id;\n context.metadata = {\n ...context.metadata,\n _directoryIsNew: id === undefined || id === null,\n };\n }\n\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantField = config?.field || 'tenantId';\n const instanceRecord = instance as unknown as Record<string, unknown>;\n const instanceTenantId = instanceRecord[tenantField];\n\n const tenantContext = getCurrentTenant();\n\n // Check if tenant context is required\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'save', context);\n throw new TenantContextError(\n `Tenant context required for saving ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return; // Mode is 'optional'\n }\n\n // Auto-populate tenant ID if not set\n if (!instanceTenantId && config?.autoPopulate !== false) {\n instanceRecord[tenantField] = tenantContext.tenantId;\n return;\n }\n\n // Validate tenant ID matches context\n if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {\n const attemptedTenantId = String(instanceTenantId);\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n attemptedTenantId,\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation: cannot save ${className} with ` +\n `tenantId '${attemptedTenantId}' in context of tenant '${tenantContext.tenantId}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId,\n },\n );\n }\n },\n\n /**\n * Before delete: Validate instance belongs to current tenant\n */\n beforeDelete(instance: SmrtObject, context: InterceptorContext): void {\n // Use context.className which is always correct\n const className = context.className;\n\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantField = config?.field || 'tenantId';\n const instanceTenantId = (instance as unknown as Record<string, unknown>)[\n tenantField\n ];\n\n const tenantContext = getCurrentTenant();\n\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'delete', context);\n throw new TenantContextError(\n `Tenant context required for deleting ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return;\n }\n\n // Validate tenant ID matches\n if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {\n const attemptedTenantId = String(instanceTenantId);\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n attemptedTenantId,\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation: cannot delete ${className} with ` +\n `tenantId '${attemptedTenantId}' in context of tenant '${tenantContext.tenantId}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId,\n },\n );\n }\n },\n\n /**\n * After save: Emit directory dispatch for configured classes\n */\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n if (\n !opts.dispatchBus ||\n !opts.directoryClasses?.includes(context.className)\n )\n return;\n\n const instanceId = (instance as unknown as Record<string, unknown>).id;\n const sourceId = typeof instanceId === 'string' ? instanceId : undefined;\n const rawIsNew = context.metadata?._directoryIsNew;\n const isNew =\n typeof rawIsNew === 'boolean' ? rawIsNew : instanceId == null;\n const event = isNew\n ? `directory.${context.className.toLowerCase()}.created`\n : `directory.${context.className.toLowerCase()}.updated`;\n\n await opts.dispatchBus.emit(\n event,\n serializeInstance(instance, context.className),\n {\n source: 'smrt-tenancy',\n sourceId,\n },\n );\n },\n\n /**\n * After delete: Emit directory dispatch for configured classes\n */\n async afterDelete(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n if (\n !opts.dispatchBus ||\n !opts.directoryClasses?.includes(context.className)\n )\n return;\n\n const instanceId = (instance as unknown as Record<string, unknown>).id;\n await opts.dispatchBus.emit(\n `directory.${context.className.toLowerCase()}.deleted`,\n serializeInstance(instance, context.className),\n {\n source: 'smrt-tenancy',\n sourceId: typeof instanceId === 'string' ? instanceId : undefined,\n },\n );\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Registration Functions\n// ─────────────────────────────────────────────────────────────────────────────\n\n// The enabled flag lives in `enabled-state.ts` (a leaf module) so `entry-point.ts`\n// can read it without importing this module — breaking the otherwise-circular\n// interceptor ↔ entry-point dependency.\nlet registeredInterceptor: CollectionInterceptor | null = null;\n\n/**\n * Enable tenant enforcement globally\n *\n * Call this once at application startup to enable automatic tenant isolation.\n *\n * @param options - Configuration options\n *\n * @example\n * ```typescript\n * // In your app initialization\n * import { enableTenancy } from '@happyvertical/smrt-tenancy';\n *\n * enableTenancy({\n * rawQueryPolicy: 'throw',\n * onMissingContext: (className, operation) => {\n * console.error(`Missing tenant context for ${operation} on ${className}`);\n * }\n * });\n * ```\n */\nexport function enableTenancy(options: TenantInterceptorOptions = {}): void {\n if (isTenancyEnabled()) {\n logger.warn(\n '[smrt-tenancy] Tenancy is already enabled. Call disableTenancy() first to reconfigure.',\n );\n return;\n }\n\n registeredInterceptor = createTenantInterceptor(options);\n GlobalInterceptors.register(registeredInterceptor);\n\n // Wire the DispatchBus tenant-scope resolver (S5 #1398). Core cannot depend\n // on tenancy, so it reads the active tenant through this injected hook; the\n // bus stamps/filters dispatches by the active tenant only while tenancy is\n // enabled. Mirrors the GlobalInterceptors inversion above.\n setDispatchTenantResolver(() => getTenantId());\n\n // Wire the fail-closed tenant gate for generated CLI/MCP entry points (#1554).\n // Core invokes this runner around tenant-scoped CLI/MCP execution; without it\n // (tenancy disabled) those surfaces pass through unchanged.\n setTenantEntryPointRunner(runTenantScopedEntryPoint);\n\n // Wire the tenant-scoped-class resolver so core-side fail-closed read guards\n // (generated REST read scope, #1782) recognize `@TenantScoped()`-decorated\n // classes, which record their config only in the tenancy registry.\n setTenantScopedClassResolver((className) => isTenantScopedClass(className));\n\n setTenancyEnabled(true);\n}\n\n/**\n * Disable global tenant enforcement.\n *\n * Unregisters the interceptor previously installed by `enableTenancy()` and\n * resets the internal enabled flag so `enableTenancy()` can be called again.\n * Idempotent — safe to call even when tenancy was never enabled.\n *\n * Common use-cases:\n * - Test teardown (via `resetTenancy()`).\n * - Temporarily disabling tenancy before reconfiguring with new options.\n *\n * @example\n * ```typescript\n * afterAll(() => {\n * disableTenancy();\n * });\n * ```\n *\n * @see enableTenancy\n * @see isTenancyEnabled\n * @see resetTenancy\n */\nexport function disableTenancy(): void {\n if (!isTenancyEnabled() || !registeredInterceptor) {\n return;\n }\n\n GlobalInterceptors.unregister(registeredInterceptor);\n // Clear the DispatchBus tenant resolver so the bus reverts to its no-op\n // (pre-tenancy) behavior when tenancy is disabled.\n setDispatchTenantResolver(undefined);\n // Clear the CLI/MCP tenant gate so those surfaces pass through (#1554).\n setTenantEntryPointRunner(undefined);\n // Clear the tenant-scoped-class resolver (#1782).\n setTenantScopedClassResolver(undefined);\n registeredInterceptor = null;\n setTenancyEnabled(false);\n}\n\n/**\n * Return `true` if tenant enforcement is currently active.\n *\n * Reflects whether `enableTenancy()` has been called and the interceptor has not\n * yet been removed by `disableTenancy()`. Re-exported from `enabled-state.ts`\n * (the shared leaf module) so the public API surface is unchanged.\n *\n * @see enableTenancy\n * @see disableTenancy\n */\nexport { isTenancyEnabled };\n","/**\n * Testing Utilities for smrt-tenancy\n *\n * Helpers for testing tenant-scoped applications.\n *\n * @example\n * ```typescript\n * import { createTestTenantContext, resetTenancy } from '@happyvertical/smrt-tenancy/testing';\n *\n * beforeEach(() => {\n * resetTenancy(); // Clear all state\n * });\n *\n * it('should filter by tenant', async () => {\n * await createTestTenantContext({ tenantId: 'tenant-1' }, async () => {\n * const docs = await collection.list({});\n * // Only tenant-1 documents\n * });\n * });\n * ```\n */\n\nimport {\n type MinimalTenantContext,\n type TenantContextData,\n withTenant,\n} from './context.js';\nimport { disableTenancy, enableTenancy } from './interceptor.js';\nimport { clearTenantScopedRegistry } from './registry.js';\n\n/**\n * Reset all tenancy state (for use in beforeEach/afterEach)\n *\n * This clears:\n * - Registered interceptors\n * - Tenant-scoped class registry\n *\n * @example\n * ```typescript\n * afterEach(() => {\n * resetTenancy();\n * });\n * ```\n */\nexport function resetTenancy(): void {\n disableTenancy();\n clearTenantScopedRegistry();\n}\n\n/**\n * Create a test tenant context and run code within it\n *\n * Convenience wrapper around withTenant() with sensible defaults for testing.\n *\n * @param context - Tenant context (can be minimal, just tenantId)\n * @param fn - Async function to run in the context\n *\n * @example\n * ```typescript\n * await createTestTenantContext({ tenantId: 'test-tenant' }, async () => {\n * const product = await collection.create({ name: 'Test' });\n * expect(product.tenantId).toBe('test-tenant');\n * });\n * ```\n */\nexport async function createTestTenantContext<T>(\n context: MinimalTenantContext | TenantContextData,\n fn: () => Promise<T>,\n): Promise<T> {\n return withTenant(context, fn);\n}\n\n/**\n * Create multiple tenant contexts for isolation testing\n *\n * @param tenantIds - Array of tenant IDs to create contexts for\n * @param fn - Function that receives an object mapping tenant IDs to context runners\n *\n * @example\n * ```typescript\n * await testTenantIsolation(['tenant-a', 'tenant-b'], async (tenants) => {\n * // Create in tenant A\n * const docA = await tenants['tenant-a'](async () => {\n * return collection.create({ title: 'A doc' });\n * });\n *\n * // Verify not visible in tenant B\n * await tenants['tenant-b'](async () => {\n * const found = await collection.get(docA.id);\n * expect(found).toBeNull();\n * });\n * });\n * ```\n */\nexport async function testTenantIsolation<T>(\n tenantIds: string[],\n fn: (\n tenants: Record<string, <R>(runner: () => Promise<R>) => Promise<R>>,\n ) => Promise<T>,\n): Promise<T> {\n const tenants: Record<string, <R>(runner: () => Promise<R>) => Promise<R>> =\n {};\n\n for (const tenantId of tenantIds) {\n tenants[tenantId] = async <R>(runner: () => Promise<R>) => {\n return withTenant({ tenantId }, runner);\n };\n }\n\n return fn(tenants);\n}\n\n/**\n * Options for `setupTestTenancy()`.\n *\n * @see setupTestTenancy\n */\nexport interface SetupTestTenancyOptions {\n /**\n * Enable tenancy interceptors\n * @default true\n */\n enableInterceptors?: boolean;\n\n /**\n * Raw query policy for tests\n * @default 'throw'\n */\n rawQueryPolicy?: 'throw' | 'warn' | 'allow';\n}\n\n/**\n * Set up tenancy for a test suite\n *\n * Call in beforeAll or at the start of tests to configure tenancy.\n *\n * @param options - Setup options\n *\n * @example\n * ```typescript\n * beforeAll(() => {\n * setupTestTenancy({ enableInterceptors: true });\n * });\n *\n * afterAll(() => {\n * resetTenancy();\n * });\n * ```\n */\nexport function setupTestTenancy(options: SetupTestTenancyOptions = {}): void {\n const { enableInterceptors = true, rawQueryPolicy = 'throw' } = options;\n\n // Clear any existing state\n resetTenancy();\n\n // Enable interceptors if requested\n if (enableInterceptors) {\n enableTenancy({ rawQueryPolicy });\n }\n}\n\n/**\n * Assert that executing `fn` throws a `TenantContextError`.\n *\n * Fails with a descriptive message if `fn` completes without throwing, or if\n * it throws a different error type. Optionally verifies that the error message\n * contains a specific substring.\n *\n * Useful for testing that business-logic code correctly rejects calls that are\n * made outside a tenant context.\n *\n * @param fn - Async function that should throw `TenantContextError`.\n * @param messageContains - Optional substring the error message must include.\n *\n * @example\n * ```typescript\n * await assertTenantContextRequired(async () => {\n * // No withTenant() in scope\n * await documentCollection.list({});\n * });\n * ```\n *\n * @see assertTenantIsolationViolation\n * @see TenantContextError\n */\nexport async function assertTenantContextRequired(\n fn: () => Promise<unknown>,\n messageContains?: string,\n): Promise<void> {\n try {\n await fn();\n throw new Error('Expected TenantContextError but no error was thrown');\n } catch (error: unknown) {\n const err = error as Error & { code?: string };\n if (err.code !== 'TENANT_CONTEXT_REQUIRED') {\n throw new Error(\n `Expected TenantContextError but got ${err.constructor.name}: ${err.message}`,\n );\n }\n if (messageContains && !err.message.includes(messageContains)) {\n throw new Error(\n `Expected error message to contain '${messageContains}' but got: ${err.message}`,\n );\n }\n }\n}\n\n/**\n * Assert that executing `fn` throws a `TenantIsolationError`.\n *\n * Fails with a descriptive message if `fn` completes without throwing, or if\n * it throws a different error type. Optionally verifies that the error message\n * contains a specific substring.\n *\n * Use this to verify that cross-tenant data access attempts are correctly\n * blocked by the interceptor.\n *\n * @param fn - Async function that should throw `TenantIsolationError`.\n * @param messageContains - Optional substring the error message must include.\n *\n * @example\n * ```typescript\n * await withTenant({ tenantId: 'tenant-a' }, async () => {\n * await assertTenantIsolationViolation(async () => {\n * // Attempt to filter by a different tenant\n * await collection.list({ where: { tenantId: 'tenant-b' } });\n * });\n * });\n * ```\n *\n * @see assertTenantContextRequired\n * @see TenantIsolationError\n */\nexport async function assertTenantIsolationViolation(\n fn: () => Promise<unknown>,\n messageContains?: string,\n): Promise<void> {\n try {\n await fn();\n throw new Error('Expected TenantIsolationError but no error was thrown');\n } catch (error: unknown) {\n const err = error as Error & { code?: string };\n if (err.code !== 'TENANT_ISOLATION_VIOLATION') {\n throw new Error(\n `Expected TenantIsolationError but got ${err.constructor.name}: ${err.message}`,\n );\n }\n if (messageContains && !err.message.includes(messageContains)) {\n throw new Error(\n `Expected error message to contain '${messageContains}' but got: ${err.message}`,\n );\n }\n }\n}\n"],"mappings":";;;;AA6DA,IAAM,iBAAqC;CACzC,MAAM;CACN,OAAO;CACP,YAAY;CACZ,cAAc;CACd,uBAAuB;AACzB;AAGA,IAAM,sCAAsB,IAAI,IAAgC;AAwBzD,SAAS,0BACd,WACA,SAAsC,CAAC,GACjC;CACN,oBAAoB,IAAI,WAAW;EACjC,GAAG;EACH,GAAG;CACL,CAAC;AACH;AAaO,SAAS,4BAA4B,WAAyB;CACnE,oBAAoB,OAAO,SAAS;AACtC;AAYA,SAAS,kBAAkB,WAA2B;CACpD,MAAM,MAAM,UAAU,YAAY,GAAG;CACrC,OAAO,QAAQ,KAAK,YAAY,UAAU,MAAM,MAAM,CAAC;AACzD;AAQA,SAAS,YAAY,QAAgD;CACnE,OAAO,EAAE,GAAG,OAAO;AACrB;AAiBA,SAAS,4BACP,WACgC;CAEhC,MAAM,cAAc,oBAAoB,IAAI,SAAS;CACrD,IAAI,aACF,OAAO,YAAY,WAAW;CAMhC,MAAM,aAAa,eAAe,sBAAsB,SAAS;CACjE,IAAI,YAEF,OAAO;EACL,MAAM,WAAW;EACjB,OAAO,WAAW;EAClB,YAAY,WAAW;EACvB,cAAc,WAAW;EACzB,uBAAuB,WAAW;CACpC;AAIJ;AA0BA,SAAS,+BACP,WACgC;CAKhC,MAAM,QAAQ,eAAe,oBAAoB,SAAS;CAG1D,KAAA,IAAS,IAAI,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK;EAC1C,MAAM,WAAW,MAAM;EAKvB,MAAM,SAAS,4BAA4B,QAAQ;EACnD,IAAI,QACF,OAAO;EAYT,MAAM,SAAS,kBAAkB,QAAQ;EACzC,IAAI,WAAW,UAAU;GACvB,MAAM,WAAW,oBAAoB,IAAI,MAAM;GAC/C,IAAI,UACF,OAAO,YAAY,QAAQ;EAE/B;CACF;AAEF;AAoBO,SAAS,sBACd,WACgC;CAEhC,MAAM,SAAS,4BAA4B,SAAS;CACpD,IAAI,QACF,OAAO;CAGT,OAAO,+BAA+B,SAAS;AACjD;AAaO,SAAS,oBAAoB,WAA4B;CAC9D,OAAO,sBAAsB,SAAS,MAAM,KAAA;AAC9C;AAeO,SAAS,4BAA6D;CAC3E,OAAO,IAAI,IAAI,mBAAmB;AACpC;AAWO,SAAS,4BAAkC;CAChD,oBAAoB,MAAM;AAC5B;;;ACzTA,IAAI,UAAU;AAQP,SAAS,kBAAkB,OAAsB;CACtD,UAAU;AACZ;AAQO,SAAS,mBAA4B;CAC1C,OAAO;AACT;;;ACkEA,eAAsB,0BACpB,SACA,IACY;CACZ,MAAM,EACJ,WACA,cACA,UACA,mBAAmB,OACnB,UAAU,kBACR;CAeJ,IAAI,EAVF,OAAO,iBAAiB,YACpB,eACA,YACE,oBAAoB,SAAS,IAC7B,QAMK,OAAO,GAAG;CACvB,IAAI,iBAAiB,KAAK,gBAAgB,GAAG,OAAO,GAAG;CAKvD,IAAI,kBACF,OAAO,kBAAkB,EAAE;CAI7B,IAAI,OAAO,aAAa,YAAY,UAClC,OAAO,WAAW,EAAE,SAAS,GAAG,EAAE;CAIpC,IAAI,iBAAiB,GACnB,MAAM,IAAI,mBACR,wDAAwD,QAAO,6IAGjE;CAIF,OAAO,GAAG;AACZ;;;AC/GA,IAAM,SAAS,aAAa,EAAE,OAAO,OAAO,CAAC;AAiF7C,IAAM,kBAA4C,EAChD,gBAAgB,QAClB;AAYA,SAAS,kBACP,UACA,WACyB;CAQzB,MAAM,cAAe,SAAkC;CACvD,IAAI,OAAO,gBAAgB,YACzB,OAAO;EACL;EACA,GAAI,YAAY,KAAK,QAAQ;CAC/B;CAKF,MAAM,SAAkC,EAAE,UAAU;CACpD,MAAM,SAAS;CACf,KAAA,MAAW,OAAO,OAAO,KAAK,QAAQ,GAAG;EACvC,MAAM,QAAQ,OAAO;EACrB,IAAI,OAAO,UAAU,YACnB,OAAO,OAAO;CAElB;CACA,OAAO;AACT;AAwCO,SAAS,wBACd,UAAoC,CAAC,GACd;CACvB,MAAM,OAAO;EAAE,GAAG;EAAiB,GAAG;CAAQ;CAE9C,OAAO;EACL,MAAM;EACN,UAAU;;;;EAKV,WACE,WACA,aACA,SACyB;GAEzB,IAAI,CAAC,oBAAoB,SAAS,GAChC;GAIF,IAAI,mBAAmB,GACrB;GAIF,IAAI,gBAAgB,GAClB;GAGF,MAAM,SAAS,sBAAsB,SAAS;GAC9C,MAAM,gBAAgB,iBAAiB;GAGvC,IAAI,CAAC,eAAe;IAClB,IAAI,QAAQ,SAAS,YAAY;KAC/B,KAAK,mBAAmB,WAAW,QAAQ,OAAO;KAClD,MAAM,IAAI,mBACR,uCAAuC,UAAS,0DAElD;IACF;IACA;GACF;GAGA,MAAM,cAAc,QAAQ,SAAS;GACrC,MAAM,QAAQ,YAAY,SAAS,CAAC;GAGpC,IAAI,eAAe,OAAO;IAMxB,MAAM,iBAAiB,MAAM;IAC7B,MAAM,eAAe,MAAM,QAAQ,cAAc,IAC7C,iBACA,CAAC,cAAc;IAGnB,MAAM,iBAAiB,aAAa,WACjC,UAAU,UAAU,cAAc,QACrC;IACA,IAAI,mBAAmB,IAAI;KACzB,MAAM,YAAY,aAAa;KAC/B,KAAK,uBACH,WACA,cAAc,UACd,OAAO,SAAS,GAChB,OACF;KACA,MAAM,IAAI,qBACR,iCAAiC,UAAS,6BAClB,cAAc,SAAQ,0BAA2B,OAAO,SAAS,EAAC,IAC1F;MACE,UAAU,cAAc;MACxB,mBAAmB,OAAO,SAAS;KACrC,CACF;IACF;IACA;GACF;GAGA,OAAO;IACL,GAAG;IACH,OAAO;KACL,GAAG;MACF,cAAc,cAAc;IAC/B;GACF;EACF;;;;EAKA,UACE,WACA,QACA,SAC8C;GAC9C,IAAI,CAAC,oBAAoB,SAAS,GAChC;GAGF,IAAI,mBAAmB,GACrB;GAIF,IAAI,gBAAgB,GAClB;GAGF,MAAM,SAAS,sBAAsB,SAAS;GAC9C,MAAM,gBAAgB,iBAAiB;GAEvC,IAAI,CAAC,eAAe;IAClB,IAAI,QAAQ,SAAS,YAAY;KAC/B,KAAK,mBAAmB,WAAW,OAAO,OAAO;KACjD,MAAM,IAAI,mBACR,uCAAuC,UAAS,0DAElD;IACF;IACA;GACF;GAEA,MAAM,cAAc,QAAQ,SAAS;GAGrC,IAAI,OAAO,WAAW,UACpB,OAAO;IACL,IAAI;KACH,cAAc,cAAc;GAC/B;GAIF,IAAI,EAAE,eAAe,SACnB,OAAO;IACL,GAAG;KACF,cAAc,cAAc;GAC/B;GAMF,MAAM,iBAAiB,OAAO;GAC9B,MAAM,eAAe,MAAM,QAAQ,cAAc,IAC7C,iBACA,CAAC,cAAc;GAGnB,MAAM,iBAAiB,aAAa,WACjC,UAAU,UAAU,cAAc,QACrC;GACA,IAAI,mBAAmB,IAAI;IACzB,MAAM,YAAY,aAAa;IAC/B,KAAK,uBACH,WACA,cAAc,UACd,OAAO,SAAS,GAChB,OACF;IACA,MAAM,IAAI,qBACR,iCAAiC,UAAS,2BAClB,cAAc,SAAQ,0BAA2B,OAAO,SAAS,EAAC,IAC1F;KACE,UAAU,cAAc;KACxB,mBAAmB,OAAO,SAAS;IACrC,CACF;GACF;EAGF;;;;EAKA,YACE,WACA,cACA,SACkC;GAClC,IAAI,CAAC,oBAAoB,SAAS,GAChC;GAIF,IAAI,aAAa,wBAAwB;IACvC,KAAK,aAAa,WAAW,aAAa,KAAK,OAAO;IACtD;GACF;GAEA,IAAI,mBAAmB,GAAG;IACxB,KAAK,aAAa,WAAW,aAAa,KAAK,OAAO;IACtD;GACF;GAGA,IAAI,gBAAgB,GAAG;IACrB,KAAK,aAAa,WAAW,aAAa,KAAK,OAAO;IACtD;GACF;GAGA,MAAM,UACJ,kDAAkD,UAAS;GAK7D,KAAK,aAAa,WAAW,aAAa,KAAK,OAAO;GAEtD,QAAQ,KAAK,gBAAb;IACE,KAAK,SACH,MAAM,IAAI,qBAAqB,OAAO;IAExC,KAAK;KACH,OAAO,KAAK,2BAA2B,SAAS;KAChD;IACF,SACE;GACJ;EACF;;;;EAKA,WAAW,UAAsB,SAAmC;GAGlE,MAAM,YAAY,QAAQ;GAG1B,IAAI,KAAK,kBAAkB,SAAS,SAAS,GAAG;IAC9C,MAAM,KAAM,SAAgD;IAC5D,QAAQ,WAAW;KACjB,GAAG,QAAQ;KACX,iBAAiB,OAAO,KAAA,KAAa,OAAO;IAC9C;GACF;GAEA,IAAI,CAAC,oBAAoB,SAAS,GAChC;GAGF,IAAI,mBAAmB,GACrB;GAIF,IAAI,gBAAgB,GAClB;GAGF,MAAM,SAAS,sBAAsB,SAAS;GAC9C,MAAM,cAAc,QAAQ,SAAS;GACrC,MAAM,iBAAiB;GACvB,MAAM,mBAAmB,eAAe;GAExC,MAAM,gBAAgB,iBAAiB;GAGvC,IAAI,CAAC,eAAe;IAClB,IAAI,QAAQ,SAAS,YAAY;KAC/B,KAAK,mBAAmB,WAAW,QAAQ,OAAO;KAClD,MAAM,IAAI,mBACR,sCAAsC,UAAS,0DAEjD;IACF;IACA;GACF;GAGA,IAAI,CAAC,oBAAoB,QAAQ,iBAAiB,OAAO;IACvD,eAAe,eAAe,cAAc;IAC5C;GACF;GAGA,IAAI,oBAAoB,qBAAqB,cAAc,UAAU;IACnE,MAAM,oBAAoB,OAAO,gBAAgB;IACjD,KAAK,uBACH,WACA,cAAc,UACd,mBACA,OACF;IACA,MAAM,IAAI,qBACR,2CAA2C,UAAS,kBACrC,kBAAiB,0BAA2B,cAAc,SAAQ,IACjF;KACE,UAAU,cAAc;KACxB;IACF,CACF;GACF;EACF;;;;EAKA,aAAa,UAAsB,SAAmC;GAEpE,MAAM,YAAY,QAAQ;GAE1B,IAAI,CAAC,oBAAoB,SAAS,GAChC;GAGF,IAAI,mBAAmB,GACrB;GAIF,IAAI,gBAAgB,GAClB;GAGF,MAAM,SAAS,sBAAsB,SAAS;GAE9C,MAAM,mBAAoB,SADN,QAAQ,SAAS;GAKrC,MAAM,gBAAgB,iBAAiB;GAEvC,IAAI,CAAC,eAAe;IAClB,IAAI,QAAQ,SAAS,YAAY;KAC/B,KAAK,mBAAmB,WAAW,UAAU,OAAO;KACpD,MAAM,IAAI,mBACR,wCAAwC,UAAS,0DAEnD;IACF;IACA;GACF;GAGA,IAAI,oBAAoB,qBAAqB,cAAc,UAAU;IACnE,MAAM,oBAAoB,OAAO,gBAAgB;IACjD,KAAK,uBACH,WACA,cAAc,UACd,mBACA,OACF;IACA,MAAM,IAAI,qBACR,6CAA6C,UAAS,kBACvC,kBAAiB,0BAA2B,cAAc,SAAQ,IACjF;KACE,UAAU,cAAc;KACxB;IACF,CACF;GACF;EACF;;;;EAKA,MAAM,UACJ,UACA,SACe;GACf,IACE,CAAC,KAAK,eACN,CAAC,KAAK,kBAAkB,SAAS,QAAQ,SAAS,GAElD;GAEF,MAAM,aAAc,SAAgD;GACpE,MAAM,WAAW,OAAO,eAAe,WAAW,aAAa,KAAA;GAC/D,MAAM,WAAW,QAAQ,UAAU;GAGnC,MAAM,SADJ,OAAO,aAAa,YAAY,WAAW,cAAc,QAEvD,aAAa,QAAQ,UAAU,YAAY,EAAC,YAC5C,aAAa,QAAQ,UAAU,YAAY,EAAC;GAEhD,MAAM,KAAK,YAAY,KACrB,OACA,kBAAkB,UAAU,QAAQ,SAAS,GAC7C;IACE,QAAQ;IACR;GACF,CACF;EACF;;;;EAKA,MAAM,YACJ,UACA,SACe;GACf,IACE,CAAC,KAAK,eACN,CAAC,KAAK,kBAAkB,SAAS,QAAQ,SAAS,GAElD;GAEF,MAAM,aAAc,SAAgD;GACpE,MAAM,KAAK,YAAY,KACrB,aAAa,QAAQ,UAAU,YAAY,EAAC,WAC5C,kBAAkB,UAAU,QAAQ,SAAS,GAC7C;IACE,QAAQ;IACR,UAAU,OAAO,eAAe,WAAW,aAAa,KAAA;GAC1D,CACF;EACF;CACF;AACF;AASA,IAAI,wBAAsD;AAsBnD,SAAS,cAAc,UAAoC,CAAC,GAAS;CAC1E,IAAI,iBAAiB,GAAG;EACtB,OAAO,KACL,wFACF;EACA;CACF;CAEA,wBAAwB,wBAAwB,OAAO;CACvD,mBAAmB,SAAS,qBAAqB;CAMjD,gCAAgC,YAAY,CAAC;CAK7C,0BAA0B,yBAAyB;CAKnD,8BAA8B,cAAc,oBAAoB,SAAS,CAAC;CAE1E,kBAAkB,IAAI;AACxB;AAwBO,SAAS,iBAAuB;CACrC,IAAI,CAAC,iBAAiB,KAAK,CAAC,uBAC1B;CAGF,mBAAmB,WAAW,qBAAqB;CAGnD,0BAA0B,KAAA,CAAS;CAEnC,0BAA0B,KAAA,CAAS;CAEnC,6BAA6B,KAAA,CAAS;CACtC,wBAAwB;CACxB,kBAAkB,KAAK;AACzB;;;ACvqBO,SAAS,eAAqB;CACnC,eAAe;CACf,0BAA0B;AAC5B;AAkBA,eAAsB,wBACpB,SACA,IACY;CACZ,OAAO,WAAW,SAAS,EAAE;AAC/B;AAwBA,eAAsB,oBACpB,WACA,IAGY;CACZ,MAAM,UACJ,CAAC;CAEH,KAAA,MAAW,YAAY,WACrB,QAAQ,YAAY,OAAU,WAA6B;EACzD,OAAO,WAAW,EAAE,SAAS,GAAG,MAAM;CACxC;CAGF,OAAO,GAAG,OAAO;AACnB;AAuCO,SAAS,iBAAiB,UAAmC,CAAC,GAAS;CAC5E,MAAM,EAAE,qBAAqB,MAAM,iBAAiB,YAAY;CAGhE,aAAa;CAGb,IAAI,oBACF,cAAc,EAAE,eAAe,CAAC;AAEpC;AA0BA,eAAsB,4BACpB,IACA,iBACe;CACf,IAAI;EACF,MAAM,GAAG;EACT,MAAM,IAAI,MAAM,qDAAqD;CACvE,SAAS,OAAgB;EACvB,MAAM,MAAM;EACZ,IAAI,IAAI,SAAS,2BACf,MAAM,IAAI,MACR,uCAAuC,IAAI,YAAY,KAAI,IAAK,IAAI,SACtE;EAEF,IAAI,mBAAmB,CAAC,IAAI,QAAQ,SAAS,eAAe,GAC1D,MAAM,IAAI,MACR,sCAAsC,gBAAe,aAAc,IAAI,SACzE;CAEJ;AACF;AA4BA,eAAsB,+BACpB,IACA,iBACe;CACf,IAAI;EACF,MAAM,GAAG;EACT,MAAM,IAAI,MAAM,uDAAuD;CACzE,SAAS,OAAgB;EACvB,MAAM,MAAM;EACZ,IAAI,IAAI,SAAS,8BACf,MAAM,IAAI,MACR,yCAAyC,IAAI,YAAY,KAAI,IAAK,IAAI,SACxE;EAEF,IAAI,mBAAmB,CAAC,IAAI,QAAQ,SAAS,eAAe,GAC1D,MAAM,IAAI,MACR,sCAAsC,gBAAe,aAAc,IAAI,SACzE;CAEJ;AACF"}
|
|
1
|
+
{"version":3,"file":"testing-CxhEt3bu.js","names":[],"sources":["../../src/registry.ts","../../src/enabled-state.ts","../../src/entry-point.ts","../../src/interceptor.ts","../../src/testing.ts"],"sourcesContent":["/**\n * Tenant-Scoped Class Registry\n *\n * Tracks which classes are tenant-scoped and their configuration.\n * Used by the interceptor to determine how to handle operations.\n *\n * This registry supports two patterns:\n * 1. @TenantScoped() decorator + tenantId field (original pattern)\n * 2. @smrt({ tenantScoped: true }) in smrt-core (Issue #688 pattern)\n *\n * Both patterns are automatically recognized by the interceptor.\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n * @see https://github.com/happyvertical/smrt/issues/688\n */\n\nimport { ObjectRegistry } from '@happyvertical/smrt-core';\n\n/**\n * Resolved tenancy configuration for a single class, as stored in the registry.\n *\n * Every field has a concrete (non-optional) value — defaults are applied by\n * `registerTenantScopedClass()` when the class is registered via `@TenantScoped()`.\n *\n * @see TenantScopedOptions\n * @see registerTenantScopedClass\n */\nexport interface TenantScopedConfig {\n /**\n * Tenancy mode for this class\n * - 'required': Must have tenant context for all operations\n * - 'optional': Works with or without tenant context\n * @default 'required'\n */\n mode: 'required' | 'optional';\n\n /**\n * Field name containing tenant ID\n * @default 'tenantId'\n */\n field: string;\n\n /**\n * Auto-filter all queries by tenant\n * @default true\n */\n autoFilter: boolean;\n\n /**\n * Auto-populate tenant ID from context on create\n * @default true\n */\n autoPopulate: boolean;\n\n /**\n * Allow super admin bypass for this class\n * @default false\n */\n allowSuperAdminBypass: boolean;\n}\n\nconst DEFAULT_CONFIG: TenantScopedConfig = {\n mode: 'required',\n field: 'tenantId',\n autoFilter: true,\n autoPopulate: true,\n allowSuperAdminBypass: false,\n};\n\n// Registry storing tenant-scoped class configurations\nconst tenantScopedClasses = new Map<string, TenantScopedConfig>();\n\n/**\n * Register a class as tenant-scoped with the given configuration.\n *\n * Called automatically by the `@TenantScoped()` decorator. You can also call\n * this directly when you cannot use decorators (e.g., third-party classes or\n * plain objects in tests). Defaults from `DEFAULT_CONFIG` are merged over any\n * omitted options.\n *\n * Calling this again for the same `className` overwrites the previous entry.\n *\n * @param className - The class's `name` property (e.g., `'Document'`).\n * @param config - Partial tenancy configuration; omitted fields receive defaults.\n *\n * @example\n * ```typescript\n * // Manually register a class (e.g., for testing)\n * registerTenantScopedClass('Document', { mode: 'optional' });\n * ```\n *\n * @see TenantScoped\n * @see unregisterTenantScopedClass\n */\nexport function registerTenantScopedClass(\n className: string,\n config: Partial<TenantScopedConfig> = {},\n): void {\n tenantScopedClasses.set(className, {\n ...DEFAULT_CONFIG,\n ...config,\n });\n}\n\n/**\n * Remove a class from the tenant-scoped registry.\n *\n * Primarily intended for test teardown — use `clearTenantScopedRegistry()` to\n * reset the entire registry at once.\n *\n * @param className - The class name to remove (e.g., `'Document'`).\n *\n * @see clearTenantScopedRegistry\n * @see registerTenantScopedClass\n */\nexport function unregisterTenantScopedClass(className: string): void {\n tenantScopedClasses.delete(className);\n}\n\n/**\n * Strip a qualified `@scope/pkg:ClassName` name down to its bare class name.\n *\n * `@TenantScoped` registers classes by their simple name (`target.name`), but\n * `ObjectRegistry.getInheritanceChain()` emits **qualified** names where a\n * class has package context. The simple-name bridge this enables is confined to\n * the inheritance walk (`getInheritedTenantScopedConfig`) — never the direct\n * lookup — so a *direct* qualified lookup can't strip the namespace and\n * cross-match a same-simple-name class in another package.\n */\nfunction toSimpleClassName(className: string): string {\n const idx = className.lastIndexOf(':');\n return idx === -1 ? className : className.slice(idx + 1);\n}\n\n/**\n * Return a shallow copy of a config so callers can never mutate the stored\n * registration. The same backing object is shared by the base and every\n * inheriting descendant, so handing out the reference would let an accidental\n * caller mutation silently corrupt the base (and all children). (#1598 review)\n */\nfunction cloneConfig(config: TenantScopedConfig): TenantScopedConfig {\n return { ...config };\n}\n\n/**\n * Resolve a class's OWN tenancy configuration — no STI inheritance, EXACT name\n * match only.\n *\n * Checks the two registration mechanisms in order, with the local registry\n * taking precedence:\n * 1. The local registry populated by `@TenantScoped()` (keyed by simple name).\n * 2. The core `ObjectRegistry` populated by `@smrt({ tenantScoped: true })`.\n *\n * Lookups are by exact name only — no simple-name fallback — so a qualified\n * lookup (e.g. `@happyvertical/smrt-affiliates:Payout`, explicitly not scoped)\n * can never strip its namespace and match a same-simple-name scoped class in\n * another package (e.g. `@happyvertical/smrt-commerce:Payout`). The\n * namespace-stripping bridge lives only in the inheritance walk. (#1598 review)\n */\nfunction getDirectTenantScopedConfig(\n className: string,\n): TenantScopedConfig | undefined {\n // 1. Local registry (explicit @TenantScoped decorator).\n const localConfig = tenantScopedClasses.get(className);\n if (localConfig) {\n return cloneConfig(localConfig);\n }\n\n // 2. Core registry (@smrt({ tenantScoped: true }) pattern - Issue #688).\n // findClass() resolves qualified names package-safely, so this branch is\n // already disambiguated.\n const coreConfig = ObjectRegistry.getTenantScopedConfig(className);\n if (coreConfig) {\n // Convert core config to TenantScopedConfig format\n return {\n mode: coreConfig.mode,\n field: coreConfig.field,\n autoFilter: coreConfig.autoFilter,\n autoPopulate: coreConfig.autoPopulate,\n allowSuperAdminBypass: coreConfig.allowSuperAdminBypass,\n };\n }\n\n return undefined;\n}\n\n/**\n * Resolve tenancy configuration inherited from an STI/ancestor class.\n *\n * `@TenantScoped` (and `@smrt({ tenantScoped })`) register ONLY the exact class\n * decorated — recognition does NOT propagate to subclasses. Before #1596 this\n * meant an STI child with its own collection (the child is the collection's\n * `_itemClass`) was treated as non-tenant-scoped at runtime: the interceptor\n * skipped tenant filtering on its `list()`/`get()` (cross-tenant reads), skipped\n * tenant population in `beforeSave`, and skipped the raw-SQL policy. Manual\n * re-declaration on every child was the fragile pattern that already bit images\n * (#1407) and messages.\n *\n * We now walk the STI inheritance chain so any descendant of a tenant-scoped\n * base is recognized automatically and inherits the base's config. A subclass\n * of a tenant-scoped class is always itself tenant-scoped — there is no safe\n * reason for it to opt out — so the walk intentionally covers any inheritance\n * (the motivating leak is STI child collections, but this is correct for CTI\n * hierarchies too).\n *\n * Ancestors are walked from nearest-to-self toward the root, returning the\n * first tenant-scoped ancestor's config so a closer ancestor wins. The class's\n * OWN declaration is resolved by the direct lookup in `getTenantScopedConfig`\n * and always takes precedence over anything inherited here.\n */\nfunction getInheritedTenantScopedConfig(\n className: string,\n): TenantScopedConfig | undefined {\n // getInheritanceChain returns [root, ..., self] (qualified names where the\n // class has package context). It is cached by core and only reached here when\n // the direct lookup misses, so the per-call cost on non-tenant classes is a\n // cache hit plus this short loop. Returns [] for unregistered classes.\n const chain = ObjectRegistry.getInheritanceChain(className);\n // chain[length - 1] is the class itself (already covered by the direct\n // lookup); walk its ancestors from nearest to root.\n for (let i = chain.length - 2; i >= 0; i--) {\n const ancestor = chain[i];\n\n // Exact, package-safe match first — covers `@smrt({ tenantScoped })` bases\n // (resolved through the core registry by qualified name) and any class\n // whose @TenantScoped key matches the chain entry verbatim.\n const direct = getDirectTenantScopedConfig(ancestor);\n if (direct) {\n return direct;\n }\n\n // @TenantScoped registers by SIMPLE name (`target.name`), but the chain\n // emits QUALIFIED names. Bridge to the simple-keyed local registry here —\n // scoped to the inheritance walk only, so a direct qualified lookup never\n // strips the namespace (see getDirectTenantScopedConfig). The chain entry\n // is a verified ancestor of `className`, so matching its simple name is the\n // intended hop. (Residual: the @TenantScoped registry is simple-keyed, so\n // two DISTINCT same-simple-name classes that are BOTH @TenantScoped across\n // packages could cross-match here — a pre-existing decorator-keying limit,\n // not the direct-lookup hazard fixed above.)\n const simple = toSimpleClassName(ancestor);\n if (simple !== ancestor) {\n const bySimple = tenantScopedClasses.get(simple);\n if (bySimple) {\n return cloneConfig(bySimple);\n }\n }\n }\n return undefined;\n}\n\n/**\n * Retrieve the resolved tenancy configuration for a class.\n *\n * Resolution order:\n * 1. The class's OWN declaration — local `@TenantScoped()` registry first, then\n * the core `@smrt({ tenantScoped: true })` registry.\n * 2. STI inheritance — the nearest tenant-scoped ancestor's config (#1596).\n *\n * A class that declares its own tenancy never reaches step 2, so an explicit\n * child `@TenantScoped` always overrides the inherited base config.\n *\n * @param className - The class name to look up.\n * @returns The `TenantScopedConfig` if the class is tenant-scoped directly or\n * by inheritance, or `undefined` if it is not.\n *\n * @see isTenantScopedClass\n * @see getAllTenantScopedClasses\n */\nexport function getTenantScopedConfig(\n className: string,\n): TenantScopedConfig | undefined {\n // A class's own @TenantScoped / @smrt({ tenantScoped }) declaration wins.\n const direct = getDirectTenantScopedConfig(className);\n if (direct) {\n return direct;\n }\n // Otherwise inherit recognition from a tenant-scoped STI ancestor (#1596).\n return getInheritedTenantScopedConfig(className);\n}\n\n/**\n * Return `true` if the named class is tenant-scoped — directly (via\n * `@TenantScoped()` / `@smrt({ tenantScoped: true })`) or by inheriting from a\n * tenant-scoped STI ancestor (#1596).\n *\n * @param className - The class name to look up (e.g., `'Document'`).\n * @returns `true` if the class is tenant-scoped by any mechanism.\n *\n * @see getTenantScopedConfig\n * @see registerTenantScopedClass\n */\nexport function isTenantScopedClass(className: string): boolean {\n return getTenantScopedConfig(className) !== undefined;\n}\n\n/**\n * Return a snapshot of all classes registered via `@TenantScoped()`.\n *\n * Returns a new `Map` so mutations to the returned value do not affect the\n * internal registry. Note that classes registered only through the core\n * `ObjectRegistry` (`@smrt({ tenantScoped: true })`) are **not** included in\n * this map.\n *\n * @returns A copy of the local tenant-scoped class registry, keyed by class name.\n *\n * @see isTenantScopedClass\n * @see getTenantScopedConfig\n */\nexport function getAllTenantScopedClasses(): Map<string, TenantScopedConfig> {\n return new Map(tenantScopedClasses);\n}\n\n/**\n * Remove all entries from the local tenant-scoped class registry.\n *\n * Intended for test teardown via `resetTenancy()`. Does not affect\n * registrations held by the core `ObjectRegistry`.\n *\n * @see resetTenancy\n * @see unregisterTenantScopedClass\n */\nexport function clearTenantScopedRegistry(): void {\n tenantScopedClasses.clear();\n}\n","/**\n * Shared tenancy-enabled flag.\n *\n * Holds the single boolean toggled by `enableTenancy()` / `disableTenancy()`.\n * It lives in its own leaf module (importing nothing from the package) so that\n * both `interceptor.ts` and `entry-point.ts` can read it without forming a\n * circular import: `interceptor.ts` imports `runTenantScopedEntryPoint` from\n * `entry-point.ts`, and `entry-point.ts` needs the enabled flag — routing the\n * flag through here keeps that dependency one-directional.\n */\n\nlet enabled = false;\n\n/**\n * Set the global tenancy-enabled flag. Internal — called by `enableTenancy()` /\n * `disableTenancy()` in `interceptor.ts`.\n *\n * @param value - `true` to mark tenancy enabled, `false` to clear it.\n */\nexport function setTenancyEnabled(value: boolean): void {\n enabled = value;\n}\n\n/**\n * Return `true` if tenant enforcement is currently active.\n *\n * @returns Whether `enableTenancy()` has been called without a later\n * `disableTenancy()`.\n */\nexport function isTenancyEnabled(): boolean {\n return enabled;\n}\n","/**\n * Fail-closed tenant-context establishment for non-web entry points (#1554).\n *\n * The SvelteKit/Express adapters establish tenant context from the authenticated\n * request principal, so the web surface of a `@TenantScoped({ mode: 'optional' })`\n * model never reads across tenants without an active context. The generated\n * **CLI** and **MCP** entry points have no request principal, so an invocation\n * with no active context would fall through the interceptor's optional-mode\n * pass-through and return rows across **all** tenants.\n *\n * `runTenantScopedEntryPoint()` closes that gap. It is the single fail-closed\n * gate both generated surfaces wrap their per-command/per-tool execution in.\n *\n * @see createCliContext for the richer CLI runner (resolveTenantId, super-admin).\n */\n\nimport {\n hasTenantContext,\n isSystemContext,\n TenantContextError,\n withSystemContext,\n withTenant,\n} from './context.js';\nimport { isTenancyEnabled } from './enabled-state.js';\nimport { isTenantScopedClass } from './registry.js';\n\n/**\n * Inputs for {@link runTenantScopedEntryPoint}.\n *\n * Provide **either** `className` (the gate resolves tenant-scoping from the\n * authoritative tenancy registry — the same source the interceptor uses, so it\n * covers both `@TenantScoped` and `@smrt({ tenantScoped })` registrations) or an\n * explicit `tenantScoped` boolean (when the caller already resolved it, e.g. a\n * build-time generated surface). An explicit boolean wins when both are given.\n */\nexport interface TenantEntryPointOptions {\n /**\n * Class name of the target model. When provided, tenant-scoping is resolved\n * via `isTenantScopedClass(className)`.\n */\n className?: string;\n\n /**\n * Explicit tenant-scoping decision. Overrides `className` resolution when set.\n * Non-scoped models always pass through unchanged — the gate is a no-op.\n */\n tenantScoped?: boolean;\n\n /**\n * Explicit operator-provided tenant selector (CLI `--tenant <id>`, MCP\n * `context.tenantId`). When present (and no context is already active) the\n * function runs inside this tenant's context.\n */\n tenantId?: string | null;\n\n /**\n * Explicit operator opt-in to cross-tenant / system access (CLI\n * `--all-tenants`, an MCP host that trusts the caller as an operator). When\n * set the function runs in system context, bypassing tenant filtering.\n *\n * @default false\n */\n allowCrossTenant?: boolean;\n\n /**\n * Human-facing surface name used in the fail-closed error message, e.g.\n * `'CLI'` or `'MCP'`.\n *\n * @default 'entry point'\n */\n surface?: string;\n}\n\n/**\n * Run `fn` inside an appropriate tenant context for a generated CLI/MCP entry\n * point, failing closed for tenant-scoped models when no authorized context can\n * be established.\n *\n * Resolution order (tenant-scoped models only):\n * 1. A tenant context is already active, or an explicit `withSystemContext()`\n * bypass is in effect (e.g. `runAsSystem()`, migrations) → run as-is.\n * 2. `allowCrossTenant` was explicitly set → run in system context. Checked\n * before `tenantId` so an explicit cross-tenant opt-in wins over a default\n * principal/host tenant rather than being silently scoped.\n * 3. An explicit `tenantId` was provided → run inside that tenant.\n * 4. Tenancy is enabled but none of the above → **throw** `TenantContextError`\n * (the fail-closed branch — never silently read across tenants).\n * 5. Tenancy is disabled (single-/no-tenant deployment) → pass through.\n *\n * Non-tenant-scoped models always pass straight through.\n *\n * @param options - {@link TenantEntryPointOptions}.\n * @param fn - The command/tool body to execute.\n * @returns The resolved value of `fn`.\n * @throws {TenantContextError} When a tenant-scoped model is reached with\n * tenancy enabled and no tenant/cross-tenant selector.\n */\nexport async function runTenantScopedEntryPoint<T>(\n options: TenantEntryPointOptions,\n fn: () => Promise<T>,\n): Promise<T> {\n const {\n className,\n tenantScoped,\n tenantId,\n allowCrossTenant = false,\n surface = 'entry point',\n } = options;\n\n // Resolve tenant-scoping: an explicit boolean wins; otherwise consult the\n // authoritative tenancy registry by class name (matches the interceptor).\n const scoped =\n typeof tenantScoped === 'boolean'\n ? tenantScoped\n : className\n ? isTenantScopedClass(className)\n : false;\n\n // Non-scoped models run as-is. So do calls already inside a tenant context\n // (an upstream handle) or an explicit system-context bypass — the interceptor\n // honors `withSystemContext()` (migrations, `runAsSystem()`), so the gate must\n // not fail-close over it (hasTenantContext() is false for the system marker).\n if (!scoped) return fn();\n if (hasTenantContext() || isSystemContext()) return fn();\n\n // Explicit operator opt-in to cross-tenant access. Checked before the tenant\n // selector so a deliberate `--all-tenants` / `allowCrossTenant` overrides a\n // default host/principal tenant instead of being silently scoped to it.\n if (allowCrossTenant) {\n return withSystemContext(fn);\n }\n\n // Explicit tenant selector.\n if (typeof tenantId === 'string' && tenantId) {\n return withTenant({ tenantId }, fn);\n }\n\n // Fail closed: tenancy is on but the caller gave us nothing to scope by.\n if (isTenancyEnabled()) {\n throw new TenantContextError(\n `Tenant context required for tenant-scoped access via ${surface}. ` +\n 'Pass an explicit tenant (e.g. --tenant <id> / a tenantId) or opt into ' +\n 'cross-tenant access (e.g. --all-tenants) to read across all tenants.',\n );\n }\n\n // Tenancy disabled → single-tenant deployment, pass through.\n return fn();\n}\n","/**\n * Tenant Interceptor - Core enforcement mechanism\n *\n * Registers with GlobalInterceptors in smrt-core to automatically:\n * - Filter queries by tenant ID\n * - Validate tenant context on save/delete\n * - Block or audit raw SQL on tenant-scoped classes\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { SmrtObject } from '@happyvertical/smrt-core';\nimport {\n type CollectionInterceptor,\n type DispatchBus,\n GlobalInterceptors,\n type InterceptorContext,\n type ListOptions,\n type QueryInterceptResult,\n type QueryOptions,\n setDispatchTenantResolver,\n setTenantEntryPointRunner,\n setTenantScopedClassResolver,\n} from '@happyvertical/smrt-core';\nimport {\n getCurrentTenant,\n getTenantId,\n isSuperAdminBypass,\n isSystemContext,\n TenantContextError,\n TenantIsolationError,\n} from './context.js';\nimport { isTenancyEnabled, setTenancyEnabled } from './enabled-state.js';\nimport { runTenantScopedEntryPoint } from './entry-point.js';\nimport { getTenantScopedConfig, isTenantScopedClass } from './registry.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Policy controlling what happens when raw SQL is executed against a\n * tenant-scoped class without an explicit bypass.\n *\n * - `'throw'` — Raises a `TenantIsolationError` (most secure; default).\n * - `'warn'` — Logs a `console.warn` but allows the query to proceed (useful\n * during migration periods).\n * - `'allow'` — Silently allows the query; not recommended for production.\n *\n * @see TenantInterceptorOptions.rawQueryPolicy\n * @see enableTenancy\n */\nexport type RawQueryPolicy = 'throw' | 'warn' | 'allow';\n\n/**\n * Configuration options accepted by `createTenantInterceptor()` and\n * `enableTenancy()`.\n *\n * All options are optional; reasonable defaults are applied. The callback\n * hooks (`onRawQuery`, `onMissingContext`, `onIsolationViolation`) are useful\n * for logging and alerting without altering the enforcement behaviour.\n *\n * @see createTenantInterceptor\n * @see enableTenancy\n */\nexport interface TenantInterceptorOptions {\n /**\n * Policy for raw SQL queries on tenant-scoped classes\n * - 'throw': Throw error (most secure, default)\n * - 'warn': Log warning but allow (for migration)\n * - 'allow': Silently allow (not recommended for production)\n * @default 'throw'\n */\n rawQueryPolicy?: RawQueryPolicy;\n\n /**\n * Called when a raw query is attempted on a tenant-scoped class\n * Useful for logging/auditing\n */\n onRawQuery?: (\n className: string,\n sql: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * Called when tenant context is missing for a tenant-scoped operation\n */\n onMissingContext?: (\n className: string,\n operation: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * Called when an isolation violation is detected\n */\n onIsolationViolation?: (\n className: string,\n expectedTenantId: string,\n actualTenantId: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * DispatchBus instance for emitting provisioning events on lifecycle changes.\n * When provided along with directoryClasses, afterSave/afterDelete hooks\n * emit dispatches like `directory.membership.created`.\n */\n dispatchBus?: DispatchBus;\n\n /**\n * Class names to emit directory dispatches for on save/delete lifecycle events.\n * Only classes listed here will trigger dispatch emissions.\n * @example ['Tenant', 'Membership', 'User']\n */\n directoryClasses?: string[];\n}\n\nconst DEFAULT_OPTIONS: TenantInterceptorOptions = {\n rawQueryPolicy: 'throw',\n};\n\n/**\n * Extract a plain-object snapshot of an instance for dispatch payloads.\n *\n * Prefers `toJSON()` when available (all real SmrtObject instances) because\n * it returns only data fields and excludes internal handles like `_db`, `_ai`,\n * and `_fs` which may contain circular references (e.g. connection pools with\n * Timeout objects).\n *\n * @see https://github.com/happyvertical/smrt/issues/946\n */\nfunction serializeInstance(\n instance: SmrtObject,\n className: string,\n): Record<string, unknown> {\n // Documented exception to the \"never call toJSON() directly\" convention\n // (docs/content/standards.md §7): the interceptor must serialize whatever\n // instance is handed to it, including workspace stubs and plain-object\n // doubles used in unit tests whose classes may not extend SmrtObject and\n // therefore have no `transformJSON()` hook. Using `toJSON()` here is a\n // duck-typed fallback — when present, it strips framework-internal handles\n // for us; when absent, we fall through to manual key iteration below.\n const maybeToJSON = (instance as { toJSON?: unknown }).toJSON;\n if (typeof maybeToJSON === 'function') {\n return {\n className,\n ...(maybeToJSON.call(instance) as Record<string, unknown>),\n };\n }\n\n // Fallback for plain-object stubs (e.g. in unit tests):\n // skip functions and framework-internal properties\n const result: Record<string, unknown> = { className };\n const record = instance as unknown as Record<string, unknown>;\n for (const key of Object.keys(instance)) {\n const value = record[key];\n if (typeof value !== 'function') {\n result[key] = value;\n }\n }\n return result;\n}\n\n/**\n * Create a `CollectionInterceptor` that enforces tenant isolation on all\n * `SmrtCollection` operations.\n *\n * The returned interceptor hooks into the smrt-core `GlobalInterceptors`\n * pipeline at priority 100 (runs before all other interceptors) and\n * handles the following lifecycle hooks:\n *\n * | Hook | Behaviour |\n * |---------------|-----------|\n * | `beforeList` | Injects tenant filter into `WHERE`; validates explicit filters. |\n * | `beforeGet` | Converts ID lookups to `{ id, tenantId }` filter objects. |\n * | `beforeSave` | Auto-populates `tenantId`; validates existing values. |\n * | `beforeDelete`| Validates the instance's `tenantId` matches context. |\n * | `beforeQuery` | Enforces `rawQueryPolicy` on raw SQL calls. |\n * | `afterSave` | Emits `directory.<class>.created/updated` via `dispatchBus`. |\n * | `afterDelete` | Emits `directory.<class>.deleted` via `dispatchBus`. |\n *\n * Use `enableTenancy()` to register the interceptor globally. Call this\n * directly only when you need multiple interceptor instances (e.g., for\n * isolated tests or feature flags).\n *\n * @param options - Configuration for the interceptor.\n * @returns A `CollectionInterceptor` ready to be registered with\n * `GlobalInterceptors.register()`.\n *\n * @example\n * ```typescript\n * import { createTenantInterceptor } from '@happyvertical/smrt-tenancy';\n * import { GlobalInterceptors } from '@happyvertical/smrt-core';\n *\n * const interceptor = createTenantInterceptor({ rawQueryPolicy: 'warn' });\n * GlobalInterceptors.register(interceptor);\n * ```\n *\n * @see enableTenancy\n * @see TenantInterceptorOptions\n */\nexport function createTenantInterceptor(\n options: TenantInterceptorOptions = {},\n): CollectionInterceptor {\n const opts = { ...DEFAULT_OPTIONS, ...options };\n\n return {\n name: 'smrt-tenancy',\n priority: 100, // High priority - should run first\n\n /**\n * Before list: Add tenant filter to queries\n */\n beforeList(\n className: string,\n listOptions: ListOptions,\n context: InterceptorContext,\n ): ListOptions | undefined {\n // Check if this class is tenant-scoped\n if (!isTenantScopedClass(className)) {\n return; // Not tenant-scoped, pass through\n }\n\n // Check for super admin bypass\n if (isSuperAdminBypass()) {\n return; // Bypass enabled, pass through\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantContext = getCurrentTenant();\n\n // If no tenant context and mode is 'required', throw\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'list', context);\n throw new TenantContextError(\n `Tenant context required for listing ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return; // Mode is 'optional', allow without filtering\n }\n\n // Add tenant filter to where clause\n const tenantField = config?.field || 'tenantId';\n const where = listOptions.where || {};\n\n // Check if tenant filter is already present\n if (tenantField in where) {\n // Validate it matches context. The filter may be a scalar\n // (`tenantId: 'x'`) or an IN-style array (`tenantId: ['x']`) —\n // smrt-core auto-converts array values to SQL IN clauses, so an\n // array containing only the context tenant is a valid filter.\n // See https://github.com/happyvertical/smrt/issues/1495\n const existingFilter = where[tenantField];\n const filterValues = Array.isArray(existingFilter)\n ? existingFilter\n : [existingFilter];\n // findIndex (not find) so a literal null/undefined filter value is\n // still flagged as a violation rather than mistaken for \"not found\"\n const offendingIndex = filterValues.findIndex(\n (value) => value !== tenantContext.tenantId,\n );\n if (offendingIndex !== -1) {\n const offending = filterValues[offendingIndex];\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n String(offending),\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation in ${className} query: ` +\n `context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: String(offending),\n },\n );\n }\n return; // Filter already correct\n }\n\n // Inject tenant filter\n return {\n ...listOptions,\n where: {\n ...where,\n [tenantField]: tenantContext.tenantId,\n },\n };\n },\n\n /**\n * Before get: Add tenant filter to single record fetches\n */\n beforeGet(\n className: string,\n filter: string | Record<string, unknown>,\n context: InterceptorContext,\n ): string | Record<string, unknown> | undefined {\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantContext = getCurrentTenant();\n\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'get', context);\n throw new TenantContextError(\n `Tenant context required for getting ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return;\n }\n\n const tenantField = config?.field || 'tenantId';\n\n // If filter is a string (ID), convert to object filter with tenant\n if (typeof filter === 'string') {\n return {\n id: filter,\n [tenantField]: tenantContext.tenantId,\n };\n }\n\n // Add tenant filter to object\n if (!(tenantField in filter)) {\n return {\n ...filter,\n [tenantField]: tenantContext.tenantId,\n };\n }\n\n // Validate existing filter. Like beforeList, accept scalar or\n // IN-style array filters (smrt-core auto-converts arrays to SQL IN).\n // See https://github.com/happyvertical/smrt/issues/1495\n const existingFilter = filter[tenantField];\n const filterValues = Array.isArray(existingFilter)\n ? existingFilter\n : [existingFilter];\n // findIndex (not find) so a literal null/undefined filter value is\n // still flagged as a violation rather than mistaken for \"not found\"\n const offendingIndex = filterValues.findIndex(\n (value) => value !== tenantContext.tenantId,\n );\n if (offendingIndex !== -1) {\n const offending = filterValues[offendingIndex];\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n String(offending),\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation in ${className} get: ` +\n `context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: String(offending),\n },\n );\n }\n\n return;\n },\n\n /**\n * Before query: Handle raw SQL on tenant-scoped classes\n */\n beforeQuery(\n className: string,\n queryOptions: QueryOptions,\n context: InterceptorContext,\n ): QueryInterceptResult | undefined {\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n // Check for explicit bypass flag\n if (queryOptions.allowRawOnTenantScoped) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return; // Explicitly allowed\n }\n\n if (isSuperAdminBypass()) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return;\n }\n\n // Handle based on policy\n const message =\n `Raw SQL query attempted on tenant-scoped class ${className}. ` +\n `Use list()/get() for automatic tenant filtering, or call ` +\n `query() with { allowRawOnTenantScoped: true } if you're handling ` +\n `tenant filtering manually.`;\n\n opts.onRawQuery?.(className, queryOptions.sql, context);\n\n switch (opts.rawQueryPolicy) {\n case 'throw':\n throw new TenantIsolationError(message);\n\n case 'warn':\n logger.warn(`[smrt-tenancy] WARNING: ${message}`);\n return;\n default:\n return;\n }\n },\n\n /**\n * Before save: Validate tenant ID is set and matches context\n */\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n // Use context.className which is always correct\n // (instance.constructor.name may not match for proxies or plain objects in tests)\n const className = context.className;\n\n // Stash isNew flag for afterSave dispatch detection\n if (opts.directoryClasses?.includes(className)) {\n const id = (instance as unknown as Record<string, unknown>).id;\n context.metadata = {\n ...context.metadata,\n _directoryIsNew: id === undefined || id === null,\n };\n }\n\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantField = config?.field || 'tenantId';\n const instanceRecord = instance as unknown as Record<string, unknown>;\n const instanceTenantId = instanceRecord[tenantField];\n\n const tenantContext = getCurrentTenant();\n\n // Check if tenant context is required\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'save', context);\n throw new TenantContextError(\n `Tenant context required for saving ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return; // Mode is 'optional'\n }\n\n // Auto-populate tenant ID if not set\n if (!instanceTenantId && config?.autoPopulate !== false) {\n instanceRecord[tenantField] = tenantContext.tenantId;\n return;\n }\n\n // Validate tenant ID matches context\n if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {\n const attemptedTenantId = String(instanceTenantId);\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n attemptedTenantId,\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation: cannot save ${className} with ` +\n `tenantId '${attemptedTenantId}' in context of tenant '${tenantContext.tenantId}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId,\n },\n );\n }\n },\n\n /**\n * Before delete: Validate instance belongs to current tenant\n */\n beforeDelete(instance: SmrtObject, context: InterceptorContext): void {\n // Use context.className which is always correct\n const className = context.className;\n\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantField = config?.field || 'tenantId';\n const instanceTenantId = (instance as unknown as Record<string, unknown>)[\n tenantField\n ];\n\n const tenantContext = getCurrentTenant();\n\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'delete', context);\n throw new TenantContextError(\n `Tenant context required for deleting ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return;\n }\n\n // Validate tenant ID matches\n if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {\n const attemptedTenantId = String(instanceTenantId);\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n attemptedTenantId,\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation: cannot delete ${className} with ` +\n `tenantId '${attemptedTenantId}' in context of tenant '${tenantContext.tenantId}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId,\n },\n );\n }\n },\n\n /**\n * After save: Emit directory dispatch for configured classes\n */\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n if (\n !opts.dispatchBus ||\n !opts.directoryClasses?.includes(context.className)\n )\n return;\n\n const instanceId = (instance as unknown as Record<string, unknown>).id;\n const sourceId = typeof instanceId === 'string' ? instanceId : undefined;\n const rawIsNew = context.metadata?._directoryIsNew;\n const isNew =\n typeof rawIsNew === 'boolean' ? rawIsNew : instanceId == null;\n const event = isNew\n ? `directory.${context.className.toLowerCase()}.created`\n : `directory.${context.className.toLowerCase()}.updated`;\n\n await opts.dispatchBus.emit(\n event,\n serializeInstance(instance, context.className),\n {\n source: 'smrt-tenancy',\n sourceId,\n },\n );\n },\n\n /**\n * After delete: Emit directory dispatch for configured classes\n */\n async afterDelete(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n if (\n !opts.dispatchBus ||\n !opts.directoryClasses?.includes(context.className)\n )\n return;\n\n const instanceId = (instance as unknown as Record<string, unknown>).id;\n await opts.dispatchBus.emit(\n `directory.${context.className.toLowerCase()}.deleted`,\n serializeInstance(instance, context.className),\n {\n source: 'smrt-tenancy',\n sourceId: typeof instanceId === 'string' ? instanceId : undefined,\n },\n );\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Registration Functions\n// ─────────────────────────────────────────────────────────────────────────────\n\n// The enabled flag lives in `enabled-state.ts` (a leaf module) so `entry-point.ts`\n// can read it without importing this module — breaking the otherwise-circular\n// interceptor ↔ entry-point dependency.\nlet registeredInterceptor: CollectionInterceptor | null = null;\n\n/**\n * Enable tenant enforcement globally\n *\n * Call this once at application startup to enable automatic tenant isolation.\n *\n * @param options - Configuration options\n *\n * @example\n * ```typescript\n * // In your app initialization\n * import { enableTenancy } from '@happyvertical/smrt-tenancy';\n *\n * enableTenancy({\n * rawQueryPolicy: 'throw',\n * onMissingContext: (className, operation) => {\n * console.error(`Missing tenant context for ${operation} on ${className}`);\n * }\n * });\n * ```\n */\nexport function enableTenancy(options: TenantInterceptorOptions = {}): void {\n if (isTenancyEnabled()) {\n logger.warn(\n '[smrt-tenancy] Tenancy is already enabled. Call disableTenancy() first to reconfigure.',\n );\n return;\n }\n\n registeredInterceptor = createTenantInterceptor(options);\n GlobalInterceptors.register(registeredInterceptor);\n\n // Wire the DispatchBus tenant-scope resolver (S5 #1398). Core cannot depend\n // on tenancy, so it reads the active tenant through this injected hook; the\n // bus stamps/filters dispatches by the active tenant only while tenancy is\n // enabled. Mirrors the GlobalInterceptors inversion above.\n setDispatchTenantResolver(() => getTenantId());\n\n // Wire the fail-closed tenant gate for generated CLI/MCP entry points (#1554).\n // Core invokes this runner around tenant-scoped CLI/MCP execution; without it\n // (tenancy disabled) those surfaces pass through unchanged.\n setTenantEntryPointRunner(runTenantScopedEntryPoint);\n\n // Wire the tenant-scoped-class resolver so core-side fail-closed read guards\n // (generated REST read scope, #1782) recognize `@TenantScoped()`-decorated\n // classes, which record their config only in the tenancy registry.\n setTenantScopedClassResolver((className) => isTenantScopedClass(className));\n\n setTenancyEnabled(true);\n}\n\n/**\n * Disable global tenant enforcement.\n *\n * Unregisters the interceptor previously installed by `enableTenancy()` and\n * resets the internal enabled flag so `enableTenancy()` can be called again.\n * Idempotent — safe to call even when tenancy was never enabled.\n *\n * Common use-cases:\n * - Test teardown (via `resetTenancy()`).\n * - Temporarily disabling tenancy before reconfiguring with new options.\n *\n * @example\n * ```typescript\n * afterAll(() => {\n * disableTenancy();\n * });\n * ```\n *\n * @see enableTenancy\n * @see isTenancyEnabled\n * @see resetTenancy\n */\nexport function disableTenancy(): void {\n if (!isTenancyEnabled() || !registeredInterceptor) {\n return;\n }\n\n GlobalInterceptors.unregister(registeredInterceptor);\n // Clear the DispatchBus tenant resolver so the bus reverts to its no-op\n // (pre-tenancy) behavior when tenancy is disabled.\n setDispatchTenantResolver(undefined);\n // Clear the CLI/MCP tenant gate so those surfaces pass through (#1554).\n setTenantEntryPointRunner(undefined);\n // Clear the tenant-scoped-class resolver (#1782).\n setTenantScopedClassResolver(undefined);\n registeredInterceptor = null;\n setTenancyEnabled(false);\n}\n\n/**\n * Return `true` if tenant enforcement is currently active.\n *\n * Reflects whether `enableTenancy()` has been called and the interceptor has not\n * yet been removed by `disableTenancy()`. Re-exported from `enabled-state.ts`\n * (the shared leaf module) so the public API surface is unchanged.\n *\n * @see enableTenancy\n * @see disableTenancy\n */\nexport { isTenancyEnabled };\n","/**\n * Testing Utilities for smrt-tenancy\n *\n * Helpers for testing tenant-scoped applications.\n *\n * @example\n * ```typescript\n * import { createTestTenantContext, resetTenancy } from '@happyvertical/smrt-tenancy/testing';\n *\n * beforeEach(() => {\n * resetTenancy(); // Clear all state\n * });\n *\n * it('should filter by tenant', async () => {\n * await createTestTenantContext({ tenantId: 'tenant-1' }, async () => {\n * const docs = await collection.list({});\n * // Only tenant-1 documents\n * });\n * });\n * ```\n */\n\nimport {\n type MinimalTenantContext,\n type TenantContextData,\n withTenant,\n} from './context.js';\nimport { disableTenancy, enableTenancy } from './interceptor.js';\nimport { clearTenantScopedRegistry } from './registry.js';\n\n/**\n * Reset all tenancy state (for use in beforeEach/afterEach)\n *\n * This clears:\n * - Registered interceptors\n * - Tenant-scoped class registry\n *\n * @example\n * ```typescript\n * afterEach(() => {\n * resetTenancy();\n * });\n * ```\n */\nexport function resetTenancy(): void {\n disableTenancy();\n clearTenantScopedRegistry();\n}\n\n/**\n * Create a test tenant context and run code within it\n *\n * Convenience wrapper around withTenant() with sensible defaults for testing.\n *\n * @param context - Tenant context (can be minimal, just tenantId)\n * @param fn - Async function to run in the context\n *\n * @example\n * ```typescript\n * await createTestTenantContext({ tenantId: 'test-tenant' }, async () => {\n * const product = await collection.create({ name: 'Test' });\n * expect(product.tenantId).toBe('test-tenant');\n * });\n * ```\n */\nexport async function createTestTenantContext<T>(\n context: MinimalTenantContext | TenantContextData,\n fn: () => Promise<T>,\n): Promise<T> {\n return withTenant(context, fn);\n}\n\n/**\n * Create multiple tenant contexts for isolation testing\n *\n * @param tenantIds - Array of tenant IDs to create contexts for\n * @param fn - Function that receives an object mapping tenant IDs to context runners\n *\n * @example\n * ```typescript\n * await testTenantIsolation(['tenant-a', 'tenant-b'], async (tenants) => {\n * // Create in tenant A\n * const docA = await tenants['tenant-a'](async () => {\n * return collection.create({ title: 'A doc' });\n * });\n *\n * // Verify not visible in tenant B\n * await tenants['tenant-b'](async () => {\n * const found = await collection.get(docA.id);\n * expect(found).toBeNull();\n * });\n * });\n * ```\n */\nexport async function testTenantIsolation<T>(\n tenantIds: string[],\n fn: (\n tenants: Record<string, <R>(runner: () => Promise<R>) => Promise<R>>,\n ) => Promise<T>,\n): Promise<T> {\n const tenants: Record<string, <R>(runner: () => Promise<R>) => Promise<R>> =\n {};\n\n for (const tenantId of tenantIds) {\n tenants[tenantId] = async <R>(runner: () => Promise<R>) => {\n return withTenant({ tenantId }, runner);\n };\n }\n\n return fn(tenants);\n}\n\n/**\n * Options for `setupTestTenancy()`.\n *\n * @see setupTestTenancy\n */\nexport interface SetupTestTenancyOptions {\n /**\n * Enable tenancy interceptors\n * @default true\n */\n enableInterceptors?: boolean;\n\n /**\n * Raw query policy for tests\n * @default 'throw'\n */\n rawQueryPolicy?: 'throw' | 'warn' | 'allow';\n}\n\n/**\n * Set up tenancy for a test suite\n *\n * Call in beforeAll or at the start of tests to configure tenancy.\n *\n * @param options - Setup options\n *\n * @example\n * ```typescript\n * beforeAll(() => {\n * setupTestTenancy({ enableInterceptors: true });\n * });\n *\n * afterAll(() => {\n * resetTenancy();\n * });\n * ```\n */\nexport function setupTestTenancy(options: SetupTestTenancyOptions = {}): void {\n const { enableInterceptors = true, rawQueryPolicy = 'throw' } = options;\n\n // Clear any existing state\n resetTenancy();\n\n // Enable interceptors if requested\n if (enableInterceptors) {\n enableTenancy({ rawQueryPolicy });\n }\n}\n\n/**\n * Assert that executing `fn` throws a `TenantContextError`.\n *\n * Fails with a descriptive message if `fn` completes without throwing, or if\n * it throws a different error type. Optionally verifies that the error message\n * contains a specific substring.\n *\n * Useful for testing that business-logic code correctly rejects calls that are\n * made outside a tenant context.\n *\n * @param fn - Async function that should throw `TenantContextError`.\n * @param messageContains - Optional substring the error message must include.\n *\n * @example\n * ```typescript\n * await assertTenantContextRequired(async () => {\n * // No withTenant() in scope\n * await documentCollection.list({});\n * });\n * ```\n *\n * @see assertTenantIsolationViolation\n * @see TenantContextError\n */\nexport async function assertTenantContextRequired(\n fn: () => Promise<unknown>,\n messageContains?: string,\n): Promise<void> {\n try {\n await fn();\n throw new Error('Expected TenantContextError but no error was thrown');\n } catch (error: unknown) {\n const err = error as Error & { code?: string };\n if (err.code !== 'TENANT_CONTEXT_REQUIRED') {\n throw new Error(\n `Expected TenantContextError but got ${err.constructor.name}: ${err.message}`,\n );\n }\n if (messageContains && !err.message.includes(messageContains)) {\n throw new Error(\n `Expected error message to contain '${messageContains}' but got: ${err.message}`,\n );\n }\n }\n}\n\n/**\n * Assert that executing `fn` throws a `TenantIsolationError`.\n *\n * Fails with a descriptive message if `fn` completes without throwing, or if\n * it throws a different error type. Optionally verifies that the error message\n * contains a specific substring.\n *\n * Use this to verify that cross-tenant data access attempts are correctly\n * blocked by the interceptor.\n *\n * @param fn - Async function that should throw `TenantIsolationError`.\n * @param messageContains - Optional substring the error message must include.\n *\n * @example\n * ```typescript\n * await withTenant({ tenantId: 'tenant-a' }, async () => {\n * await assertTenantIsolationViolation(async () => {\n * // Attempt to filter by a different tenant\n * await collection.list({ where: { tenantId: 'tenant-b' } });\n * });\n * });\n * ```\n *\n * @see assertTenantContextRequired\n * @see TenantIsolationError\n */\nexport async function assertTenantIsolationViolation(\n fn: () => Promise<unknown>,\n messageContains?: string,\n): Promise<void> {\n try {\n await fn();\n throw new Error('Expected TenantIsolationError but no error was thrown');\n } catch (error: unknown) {\n const err = error as Error & { code?: string };\n if (err.code !== 'TENANT_ISOLATION_VIOLATION') {\n throw new Error(\n `Expected TenantIsolationError but got ${err.constructor.name}: ${err.message}`,\n );\n }\n if (messageContains && !err.message.includes(messageContains)) {\n throw new Error(\n `Expected error message to contain '${messageContains}' but got: ${err.message}`,\n );\n }\n }\n}\n"],"mappings":"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"}
|
package/dist/context.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB;IAChC,mCAAmC;IACnC,QAAQ,EAAE,MAAM,CAAC;IAEjB,qEAAqE;IACrE,MAAM,CAAC,EAAE,OAAO,CAAC;IAEjB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB,mEAAmE;IACnE,IAAI,CAAC,EAAE,OAAO,CAAC;IAEf,wDAAwD;IACxD,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAEzB,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAE7B,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,oBAAoB;IACnC,yBAAyB;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1B,+FAA+F;IAC/F,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { a as getCurrentTenant, c as isSuperAdminBypass, d as requireTenantId, f as withSuperAdminBypass, h as withTenantSync, i as enterTenantContext, l as isSystemContext, m as withTenant, n as TenantContextError, o as getTenantId, p as withSystemContext, r as TenantIsolationError, s as hasTenantContext, t as TenantContext, u as requireTenant } from "./chunks/context-
|
|
2
|
-
import { n as createExpressMiddleware, r as createCliContext, t as createSvelteKitHandle } from "./chunks/adapters-
|
|
3
|
-
import { _ as unregisterTenantScopedClass, a as setupTestTenancy, c as disableTenancy, d as isTenancyEnabled, f as clearTenantScopedRegistry, g as registerTenantScopedClass, h as isTenantScopedClass, i as resetTenancy, l as enableTenancy, m as getTenantScopedConfig, n as assertTenantIsolationViolation, o as testTenantIsolation, p as getAllTenantScopedClasses, r as createTestTenantContext, s as createTenantInterceptor, t as assertTenantContextRequired, u as runTenantScopedEntryPoint } from "./chunks/testing-
|
|
1
|
+
import { a as getCurrentTenant, c as isSuperAdminBypass, d as requireTenantId, f as withSuperAdminBypass, h as withTenantSync, i as enterTenantContext, l as isSystemContext, m as withTenant, n as TenantContextError, o as getTenantId, p as withSystemContext, r as TenantIsolationError, s as hasTenantContext, t as TenantContext, u as requireTenant } from "./chunks/context-CwbLwyIV.js";
|
|
2
|
+
import { n as createExpressMiddleware, r as createCliContext, t as createSvelteKitHandle } from "./chunks/adapters-B4hMNcuw.js";
|
|
3
|
+
import { _ as unregisterTenantScopedClass, a as setupTestTenancy, c as disableTenancy, d as isTenancyEnabled, f as clearTenantScopedRegistry, g as registerTenantScopedClass, h as isTenantScopedClass, i as resetTenancy, l as enableTenancy, m as getTenantScopedConfig, n as assertTenantIsolationViolation, o as testTenantIsolation, p as getAllTenantScopedClasses, r as createTestTenantContext, s as createTenantInterceptor, t as assertTenantContextRequired, u as runTenantScopedEntryPoint } from "./chunks/testing-CxhEt3bu.js";
|
|
4
4
|
import { ObjectRegistry, applyPendingDecoratorRegistrations, registerCompatibleFieldDecorator } from "@happyvertical/smrt-core";
|
|
5
5
|
//#region src/__smrt-register__.ts
|
|
6
|
-
ObjectRegistry.registerPackageManifest(JSON.parse("{\"version\":\"1.0.0\",\"timestamp\":
|
|
6
|
+
ObjectRegistry.registerPackageManifest(JSON.parse("{\"version\":\"1.0.0\",\"timestamp\":1784569958698,\"packageName\":\"@happyvertical/smrt-tenancy\",\"packageVersion\":\"0.40.18\",\"objects\":{},\"moduleType\":\"smrt\",\"smrtDependencies\":[\"@happyvertical/smrt-core\"]}"));
|
|
7
7
|
//#endregion
|
|
8
8
|
//#region src/decorators.ts
|
|
9
9
|
function TenantScoped(options = {}) {
|
package/dist/manifest.json
CHANGED
package/dist/smrt-knowledge.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-20T17:52:42.215Z",
|
|
4
4
|
"packageName": "@happyvertical/smrt-tenancy",
|
|
5
|
-
"packageVersion": "0.40.
|
|
5
|
+
"packageVersion": "0.40.18",
|
|
6
6
|
"sourceManifestPath": "dist/manifest.json",
|
|
7
7
|
"agentDocPath": "AGENTS.md",
|
|
8
8
|
"sourceHashes": {
|
|
9
|
-
"manifest": "
|
|
10
|
-
"packageJson": "
|
|
11
|
-
"agents": "
|
|
9
|
+
"manifest": "972e2d44ab67b4e5d7bbc9c4e4d2483ca454eeea45e51acc8600cbfdb6bdd031",
|
|
10
|
+
"packageJson": "b0534c6c792f4c91f4fc530527aca669c3e24034e0e54eb1af053ee2e8596b75",
|
|
11
|
+
"agents": "edcc75b3900fc0b7e1d6554c4b6cff89fcedfd4b0ee0300fe84c5f02ba8233de"
|
|
12
12
|
},
|
|
13
13
|
"exports": [
|
|
14
14
|
".",
|
|
@@ -61,5 +61,5 @@
|
|
|
61
61
|
"polymorphicAssociations": 0,
|
|
62
62
|
"uuidColumns": 0
|
|
63
63
|
},
|
|
64
|
-
"agentDoc": "# @happyvertical/smrt-tenancy\n\nMulti-tenancy via AsyncLocalStorage context propagation with automatic query filtering and tenant ID population.\n\n## Context Propagation\n\n```typescript\nimport { withTenant, getTenantId, withSystemContext } from '@happyvertical/smrt-tenancy';\n\nawait withTenant({ tenantId: 'tenant-123' }, async () => {\n // All SmrtCollection queries auto-filtered by tenantId\n // All creates auto-populate tenantId\n const docs = await collection.list({}); // WHERE tenant_id = 'tenant-123'\n});\n\nawait withSystemContext(async () => { /* bypasses all tenant checks */ });\n```\n\n**Critical distinction**: `withSystemContext()` sets a SYSTEM_CONTEXT_MARKER sentinel — different from \"no context\" (undefined). Interceptor can distinguish intentional bypass from missing context.\n\n## Interceptor System\n\nHooks into SmrtCollection via `GlobalInterceptors.register()` (priority 100, runs first):\n\n| Hook | Behavior |\n|------|----------|\n| `beforeList` | Injects `tenantId` into WHERE clause; validates existing filters match context |\n| `beforeGet` | Same — converts ID lookup to `{ id, tenantId }` |\n| `beforeSave` | Auto-populates tenantId if empty + `autoPopulate: true`; validates if already set |\n| `beforeDelete` | Validates instance.tenantId matches context |\n| `beforeQuery` | Enforces raw SQL policy on tenant-scoped classes (`throw`/`warn`/`allow`) |\n| `afterSave` | Emits `directory.<class>.created`/`updated` via `dispatchBus` for configured `directoryClasses` |\n| `afterDelete` | Emits `directory.<class>.deleted` via `dispatchBus` for configured `directoryClasses` |\n\nMismatches throw `TenantIsolationError`. Missing required context throws `TenantContextError`.\n\n**Optional-mode reads with no context pass through UNFILTERED at the interceptor.** That is intentional for trusted/admin call paths, but it means the interceptor alone does not protect a tenant-scoped model exposed as `@smrt({ api: { public } })`: an anonymous HTTP read has no context, so the interceptor would return every tenant's rows. The generated REST + SvelteKit read routes close this by injecting a `{ tenantId: null }` filter when tenancy is enabled but no context is active, so public/anonymous reads fail closed to **global (NULL-tenant) rows only** — mirroring the dispatch resolver's *enforced, no active tenant → global rows only* rule (#1782). Authenticated reads still scope to the caller's tenant via the interceptor.\n\n## Registration — Two Patterns\n\n```typescript\n// Pattern 1: Tenancy decorator\n@TenantScoped({ mode: 'optional' })\nclass Doc extends SmrtObject { @tenantId({ nullable: true }) tenantId: string | null = null; }\n\n// Pattern 2: Core decorator (tenancy package reads this too)\n@smrt({ tenantScoped: { mode: 'optional' } })\nclass Doc extends SmrtObject { tenantId: string | null = null; }\n```\n\nModes: `'required'` (default — throws without context) or `'optional'` (passes through if no context).\n\n## Adapters\n\n- **Express**: `createExpressMiddleware()` — uses `enterTenantContext()` (not withTenant, because middleware returns before handlers run)\n- **SvelteKit**: `createSvelteKitHandle()` — stores context in `event.locals`\n- **CLI**: `createCliContext()` — `run()`, `runWithTenant()`, `runAsSystem()`, `runAsSuperAdmin()`\n\n## Super Admin Bypass\n\n`withSuperAdminBypass()` keeps tenant context but disables auto-filtering. Different from `withSystemContext()` which removes context entirely.\n\n## Gotchas\n\n- **Context lost in callbacks**: `setTimeout(() => getTenantId(), 100)` → undefined. Fix: `TenantContext.bind(fn)`\n- **Nested contexts override**: inner `withTenant()` overrides outer; restores on exit\n- **Auto-populate only if empty**: if tenantId already set, interceptor validates (not overwrites)\n- **Isolation checked at query time**: `list({ where: { tenantId: 'other' } })` throws immediately\n- **Testing**: `resetTenancy()` + `setupTestTenancy()` in beforeEach; `testTenantIsolation()` helper\n\n## Known exceptions to monorepo standards\n\n- **`serializeInstance()` in `src/interceptor.ts` calls `instance.toJSON()` directly** (standards.md §7 forbids this in favor of `transformJSON()`). The interceptor must serialize arbitrary instances handed to it — including workspace stubs and plain-object test doubles whose classes may not extend `SmrtObject` and therefore have no `transformJSON()` hook. The call is duck-typed and falls back to manual key iteration when `toJSON` is absent. See the inline comment at the call site for the full rationale.\n"
|
|
64
|
+
"agentDoc": "# @happyvertical/smrt-tenancy\n\nMulti-tenancy via AsyncLocalStorage context propagation with automatic query filtering and tenant ID population.\n\n## Context Propagation\n\n```typescript\nimport { withTenant, getTenantId, withSystemContext } from '@happyvertical/smrt-tenancy';\n\nawait withTenant({ tenantId: 'tenant-123' }, async () => {\n // All SmrtCollection queries auto-filtered by tenantId\n // All creates auto-populate tenantId\n const docs = await collection.list({}); // WHERE tenant_id = 'tenant-123'\n});\n\nawait withSystemContext(async () => { /* bypasses all tenant checks */ });\n```\n\n**Critical distinction**: `withSystemContext()` sets a SYSTEM_CONTEXT_MARKER sentinel — different from \"no context\" (undefined). Interceptor can distinguish intentional bypass from missing context.\n\n**Duplication-safe storage**: the underlying `AsyncLocalStorage` is a `Symbol.for`-keyed singleton on `globalThis`, so context survives Vite/vitest/SvelteKit pipelines that evaluate the module more than once — context entered through one module instance is visible to guards in another (#2077).\n\n## Interceptor System\n\nHooks into SmrtCollection via `GlobalInterceptors.register()` (priority 100, runs first):\n\n| Hook | Behavior |\n|------|----------|\n| `beforeList` | Injects `tenantId` into WHERE clause; validates existing filters match context |\n| `beforeGet` | Same — converts ID lookup to `{ id, tenantId }` |\n| `beforeSave` | Auto-populates tenantId if empty + `autoPopulate: true`; validates if already set |\n| `beforeDelete` | Validates instance.tenantId matches context |\n| `beforeQuery` | Enforces raw SQL policy on tenant-scoped classes (`throw`/`warn`/`allow`) |\n| `afterSave` | Emits `directory.<class>.created`/`updated` via `dispatchBus` for configured `directoryClasses` |\n| `afterDelete` | Emits `directory.<class>.deleted` via `dispatchBus` for configured `directoryClasses` |\n\nMismatches throw `TenantIsolationError`. Missing required context throws `TenantContextError`.\n\n**Optional-mode reads with no context pass through UNFILTERED at the interceptor.** That is intentional for trusted/admin call paths, but it means the interceptor alone does not protect a tenant-scoped model exposed as `@smrt({ api: { public } })`: an anonymous HTTP read has no context, so the interceptor would return every tenant's rows. The generated REST + SvelteKit read routes close this by injecting a `{ tenantId: null }` filter when tenancy is enabled but no context is active, so public/anonymous reads fail closed to **global (NULL-tenant) rows only** — mirroring the dispatch resolver's *enforced, no active tenant → global rows only* rule (#1782). Authenticated reads still scope to the caller's tenant via the interceptor.\n\n## Registration — Two Patterns\n\n```typescript\n// Pattern 1: Tenancy decorator\n@TenantScoped({ mode: 'optional' })\nclass Doc extends SmrtObject { @tenantId({ nullable: true }) tenantId: string | null = null; }\n\n// Pattern 2: Core decorator (tenancy package reads this too)\n@smrt({ tenantScoped: { mode: 'optional' } })\nclass Doc extends SmrtObject { tenantId: string | null = null; }\n```\n\nModes: `'required'` (default — throws without context) or `'optional'` (passes through if no context).\n\n## Adapters\n\n- **Express**: `createExpressMiddleware()` — uses `enterTenantContext()` (not withTenant, because middleware returns before handlers run)\n- **SvelteKit**: `createSvelteKitHandle()` — stores context in `event.locals`\n- **CLI**: `createCliContext()` — `run()`, `runWithTenant()`, `runAsSystem()`, `runAsSuperAdmin()`\n\n## Super Admin Bypass\n\n`withSuperAdminBypass()` keeps tenant context but disables auto-filtering. Different from `withSystemContext()` which removes context entirely.\n\n## Gotchas\n\n- **Context lost in callbacks**: `setTimeout(() => getTenantId(), 100)` → undefined. Fix: `TenantContext.bind(fn)`\n- **Nested contexts override**: inner `withTenant()` overrides outer; restores on exit\n- **Auto-populate only if empty**: if tenantId already set, interceptor validates (not overwrites)\n- **Isolation checked at query time**: `list({ where: { tenantId: 'other' } })` throws immediately\n- **Testing**: `resetTenancy()` + `setupTestTenancy()` in beforeEach; `testTenantIsolation()` helper\n\n## Known exceptions to monorepo standards\n\n- **`serializeInstance()` in `src/interceptor.ts` calls `instance.toJSON()` directly** (standards.md §7 forbids this in favor of `transformJSON()`). The interceptor must serialize arbitrary instances handed to it — including workspace stubs and plain-object test doubles whose classes may not extend `SmrtObject` and therefore have no `transformJSON()` hook. The call is duck-typed and falls back to manual key iteration when `toJSON` is absent. See the inline comment at the call site for the full rationale.\n"
|
|
65
65
|
}
|
package/dist/testing.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as setupTestTenancy, i as resetTenancy, n as assertTenantIsolationViolation, o as testTenantIsolation, r as createTestTenantContext, t as assertTenantContextRequired } from "./chunks/testing-
|
|
1
|
+
import { a as setupTestTenancy, i as resetTenancy, n as assertTenantIsolationViolation, o as testTenantIsolation, r as createTestTenantContext, t as assertTenantContextRequired } from "./chunks/testing-CxhEt3bu.js";
|
|
2
2
|
export { assertTenantContextRequired, assertTenantIsolationViolation, createTestTenantContext, resetTenancy, setupTestTenancy, testTenantIsolation };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-tenancy",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.18",
|
|
4
4
|
"description": "Production-ready multi-tenancy framework for SMRT with automatic tenant isolation and enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"smrtRawPrimitives": "strict",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"@happyvertical/logger": "^0.80.2",
|
|
45
45
|
"@happyvertical/sql": "^0.80.2",
|
|
46
46
|
"@happyvertical/utils": "^0.80.2",
|
|
47
|
-
"@happyvertical/smrt-core": "0.40.
|
|
48
|
-
"@happyvertical/smrt-types": "0.40.
|
|
49
|
-
"@happyvertical/smrt-ui": "0.40.
|
|
47
|
+
"@happyvertical/smrt-core": "0.40.18",
|
|
48
|
+
"@happyvertical/smrt-types": "0.40.18",
|
|
49
|
+
"@happyvertical/smrt-ui": "0.40.18"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"svelte": "^5.56.4"
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"typescript": "5.9.3",
|
|
66
66
|
"vite": "8.1.4",
|
|
67
67
|
"vitest": "4.1.10",
|
|
68
|
-
"@happyvertical/smrt-vitest": "0.40.
|
|
68
|
+
"@happyvertical/smrt-vitest": "0.40.18"
|
|
69
69
|
},
|
|
70
70
|
"keywords": [
|
|
71
71
|
"ai",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"context-ChVERhZz.js","names":[],"sources":["../../src/context.ts"],"sourcesContent":["/**\n * TenantContext - AsyncLocalStorage-based tenant context propagation\n *\n * Provides request-scoped tenant context that flows through async operations.\n * This is the core of the tenancy system - all tenant isolation depends on\n * having a valid context.\n *\n * @example Basic usage with middleware\n * ```typescript\n * import { withTenant, requireTenantId } from '@happyvertical/smrt-tenancy';\n *\n * // In middleware (SvelteKit, Express, etc.)\n * await withTenant({ tenantId: 'tenant-123' }, async () => {\n * // All code in this async tree has access to tenant context\n * const id = requireTenantId(); // 'tenant-123'\n * });\n * ```\n *\n * @example Background job binding\n * ```typescript\n * import { TenantContext } from '@happyvertical/smrt-tenancy';\n *\n * // Bind a callback to preserve context across async boundaries\n * setTimeout(TenantContext.bind(() => {\n * console.log(requireTenantId()); // Works!\n * }), 1000);\n * ```\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n */\n\nimport { AsyncLocalStorage } from 'node:async_hooks';\nimport type { DatabaseInterface } from '@happyvertical/sql';\n\n/**\n * Full data stored in tenant context for the current async execution scope.\n *\n * Created by `withTenant()` / `enterTenantContext()` and read by `getCurrentTenant()`,\n * `getTenantId()`, and the interceptor hooks. All fields except `tenantId` and\n * `permissions` are optional and may be populated lazily by higher-level packages\n * (e.g., `smrt-users`).\n *\n * @see withTenant\n * @see MinimalTenantContext\n */\nexport interface TenantContextData {\n /** Current tenant ID (required) */\n tenantId: string;\n\n /** Current tenant object (lazy-loaded if smrt-users is available) */\n tenant?: unknown;\n\n /** Current user ID (optional) */\n userId?: string;\n\n /** Current user object (lazy-loaded if smrt-users is available) */\n user?: unknown;\n\n /** Resolved permissions for this user in this tenant */\n permissions: Set<string>;\n\n /** Database connection for this tenant (if database-per-tenant strategy) */\n database?: DatabaseInterface;\n\n /** Super admin bypass enabled - allows cross-tenant operations */\n superAdminBypass?: boolean;\n\n /** Custom metadata for application-specific data */\n metadata?: Record<string, unknown>;\n}\n\n/**\n * Minimal context accepted by `withTenant()` and `withTenantSync()` when only a\n * tenant ID is known.\n *\n * `permissions` defaults to an empty `Set` when omitted. Use `TenantContextData`\n * when you also need to carry user info, database handles, or resolved permissions.\n *\n * @see TenantContextData\n * @see withTenant\n */\nexport interface MinimalTenantContext {\n /** Tenant identifier. */\n tenantId: string;\n /** Resolved permissions; defaults to an empty Set when omitted. */\n permissions?: Set<string>;\n /** When `true`, tenant auto-filtering is skipped for classes that allow super admin bypass. */\n superAdminBypass?: boolean;\n /** Arbitrary application-specific metadata to carry through the context. */\n metadata?: Record<string, unknown>;\n}\n\n// Sentinel symbol to mark system context (distinct from \"no context\")\nconst SYSTEM_CONTEXT_MARKER = Symbol.for('smrt:system-context');\n\n// Storage type includes the marker for system context\ntype ContextStoreValue = TenantContextData | typeof SYSTEM_CONTEXT_MARKER;\n\n// AsyncLocalStorage instance for tenant context\nconst tenantStorage = new AsyncLocalStorage<ContextStoreValue>();\n\n/**\n * Get the current tenant context for this async execution scope.\n *\n * Returns `undefined` when called outside any tenant scope or inside a\n * `withSystemContext()` block (the system context marker is treated as \"no\n * tenant data\"). Prefer `requireTenant()` when a context is mandatory.\n *\n * @returns The active `TenantContextData`, or `undefined` if none is set.\n *\n * @example\n * ```typescript\n * const ctx = getCurrentTenant();\n * if (ctx) {\n * console.log('Current tenant:', ctx.tenantId);\n * }\n * ```\n *\n * @see requireTenant\n * @see hasTenantContext\n */\nexport function getCurrentTenant(): TenantContextData | undefined {\n const store = tenantStorage.getStore();\n // Return undefined for system context marker (no tenant data available)\n if (store === SYSTEM_CONTEXT_MARKER) {\n return undefined;\n }\n return store;\n}\n\n/**\n * Get the current tenant context or throw if one is not available.\n *\n * Use this in business-logic code that must run inside a tenant scope.\n * For a non-throwing alternative use `getCurrentTenant()`.\n *\n * @returns The active `TenantContextData`.\n * @throws {TenantContextError} When no tenant context is set (code is outside\n * any `withTenant()` call or the enclosing middleware has not run).\n *\n * @example\n * ```typescript\n * const { tenantId, permissions } = requireTenant();\n * ```\n *\n * @see getCurrentTenant\n * @see requireTenantId\n */\nexport function requireTenant(): TenantContextData {\n const ctx = tenantStorage.getStore();\n if (!ctx || ctx === SYSTEM_CONTEXT_MARKER) {\n throw new TenantContextError(\n 'No tenant context available. ' +\n 'Ensure request is wrapped in withTenant() or middleware is configured.',\n );\n }\n return ctx;\n}\n\n/**\n * Get the current tenant ID or throw if no tenant context is available.\n *\n * Shorthand for `requireTenant().tenantId`.\n *\n * @returns The active tenant ID string.\n * @throws {TenantContextError} When no tenant context is set.\n *\n * @example\n * ```typescript\n * const tenantId = requireTenantId();\n * const rows = await db.query(`SELECT * FROM docs WHERE tenant_id = ?`, [tenantId]);\n * ```\n *\n * @see getTenantId\n * @see requireTenant\n */\nexport function requireTenantId(): string {\n return requireTenant().tenantId;\n}\n\n/**\n * Get the current tenant ID without throwing.\n *\n * Returns `undefined` when called outside any tenant scope or inside a\n * `withSystemContext()` block. Use `requireTenantId()` when a missing context\n * should be treated as an error.\n *\n * @returns The active tenant ID, or `undefined` if none is set.\n *\n * @example\n * ```typescript\n * const tenantId = getTenantId();\n * if (tenantId) {\n * // Optional tenant-scoped logic\n * }\n * ```\n *\n * @see requireTenantId\n * @see hasTenantContext\n */\nexport function getTenantId(): string | undefined {\n const store = tenantStorage.getStore();\n if (store === SYSTEM_CONTEXT_MARKER) {\n return undefined;\n }\n return store?.tenantId;\n}\n\n/**\n * Check whether the current async execution scope has an active tenant context.\n *\n * Returns `false` both when there is no context at all and when code is running\n * inside `withSystemContext()` (the system marker is not a tenant context).\n *\n * @returns `true` if a `TenantContextData` is active, `false` otherwise.\n *\n * @example\n * ```typescript\n * if (hasTenantContext()) {\n * console.log('Tenant:', getTenantId());\n * }\n * ```\n *\n * @see getTenantId\n * @see isSystemContext\n */\nexport function hasTenantContext(): boolean {\n const store = tenantStorage.getStore();\n // System context marker means no tenant context (even though storage is set)\n return store !== undefined && store !== SYSTEM_CONTEXT_MARKER;\n}\n\n/**\n * Check whether the current async execution scope was entered via `withSystemContext()`.\n *\n * A system context is explicitly set to bypass all tenant checks; it is distinct\n * from \"no context\" (undefined store). When the store is undefined the\n * interceptor enforces tenant requirements; when it holds the system marker the\n * interceptor skips all checks.\n *\n * @returns `true` if inside a `withSystemContext()` call, `false` otherwise.\n *\n * @see withSystemContext\n * @see hasTenantContext\n */\nexport function isSystemContext(): boolean {\n return tenantStorage.getStore() === SYSTEM_CONTEXT_MARKER;\n}\n\n/**\n * Check whether the super admin bypass flag is set in the current tenant context.\n *\n * When `true`, the interceptor skips tenant auto-filtering for classes that have\n * `allowSuperAdminBypass: true` in their `@TenantScoped()` config. Returns\n * `false` inside a system context (no tenant data is available).\n *\n * @returns `true` if super admin bypass is active, `false` otherwise.\n *\n * @see withSuperAdminBypass\n * @see TenantScopedOptions.allowSuperAdminBypass\n */\nexport function isSuperAdminBypass(): boolean {\n const store = tenantStorage.getStore();\n if (store === SYSTEM_CONTEXT_MARKER) {\n return false;\n }\n return store?.superAdminBypass === true;\n}\n\n/**\n * Run code within a tenant context (async version)\n *\n * @param context - Tenant context data (at minimum, tenantId)\n * @param fn - Async function to run within the tenant context\n * @returns Promise resolving to the function's return value\n *\n * @example\n * ```typescript\n * await withTenant({ tenantId: 'tenant-123' }, async () => {\n * const id = requireTenantId(); // 'tenant-123'\n * await doSomething();\n * });\n * ```\n */\nexport async function withTenant<T>(\n context: TenantContextData | MinimalTenantContext,\n fn: () => Promise<T>,\n): Promise<T> {\n const fullContext: TenantContextData = {\n permissions: new Set(),\n ...context,\n };\n return tenantStorage.run(fullContext, fn);\n}\n\n/**\n * Run synchronous code within a tenant context.\n *\n * Prefer `withTenant()` for async code. Use this variant only when the\n * callback must be synchronous (e.g., initializing a module-level value that\n * is consumed synchronously downstream).\n *\n * @param context - Tenant context data (at minimum, `tenantId`).\n * @param fn - Synchronous function to run within the tenant context.\n * @returns The return value of `fn`.\n *\n * @example\n * ```typescript\n * const result = withTenantSync({ tenantId: 'tenant-123' }, () => {\n * return computeSomethingSync();\n * });\n * ```\n *\n * @see withTenant\n */\nexport function withTenantSync<T>(\n context: TenantContextData | MinimalTenantContext,\n fn: () => T,\n): T {\n const fullContext: TenantContextData = {\n permissions: new Set(),\n ...context,\n };\n return tenantStorage.run(fullContext, fn);\n}\n\n/**\n * Enter tenant context for the remainder of the current async execution\n *\n * This uses AsyncLocalStorage.enterWith() to establish context that persists\n * until the async resource completes. Useful for Express middleware where\n * the route handler executes after the middleware returns.\n *\n * @param context - Tenant context data\n *\n * @example Express middleware\n * ```typescript\n * app.use((req, res, next) => {\n * const tenantId = req.headers['x-tenant-id'] as string;\n * enterTenantContext({ tenantId });\n * next(); // Route handlers now have tenant context\n * });\n * ```\n */\nexport function enterTenantContext(\n context: TenantContextData | MinimalTenantContext,\n): void {\n const fullContext: TenantContextData = {\n permissions: new Set(),\n ...context,\n };\n tenantStorage.enterWith(fullContext);\n}\n\n/**\n * Run code in system context (bypasses tenant checks)\n *\n * Use this for:\n * - Migration scripts\n * - Admin tools that need cross-tenant access\n * - Background jobs that process multiple tenants\n *\n * System context is explicitly different from \"no context\" - it signals\n * that tenant checks should be bypassed, while no context means the\n * interceptor should enforce tenant requirements.\n *\n * @param fn - Async function to run without tenant context\n *\n * @example\n * ```typescript\n * await withSystemContext(async () => {\n * // No tenant context - can access all data\n * const allDocuments = await documentCollection.list({});\n * });\n * ```\n */\nexport async function withSystemContext<T>(fn: () => Promise<T>): Promise<T> {\n // Run with system context marker (distinct from undefined/no context)\n return tenantStorage.run(SYSTEM_CONTEXT_MARKER, fn);\n}\n\n/**\n * Run async code with the super admin bypass flag enabled on the current\n * tenant context.\n *\n * Unlike `withSystemContext()`, this does **not** remove the tenant context —\n * the caller's `tenantId` remains intact. The interceptor skips\n * auto-filtering only for classes that have `allowSuperAdminBypass: true` in\n * their `@TenantScoped()` config.\n *\n * A tenant context must already be active (i.e., this must be called from\n * within a `withTenant()` scope). Use `withSystemContext()` if no tenant\n * context is available at all.\n *\n * @param fn - Async function to run with super admin bypass enabled.\n * @returns Promise resolving to the return value of `fn`.\n * @throws {TenantContextError} If called outside any tenant context.\n *\n * @example\n * ```typescript\n * await withTenant({ tenantId: 'admin-tenant' }, async () => {\n * await withSuperAdminBypass(async () => {\n * // Can read any tenant's AuditLog (if allowSuperAdminBypass: true)\n * const logs = await auditLogCollection.list({});\n * });\n * });\n * ```\n *\n * @see withSystemContext\n * @see isSuperAdminBypass\n */\nexport async function withSuperAdminBypass<T>(\n fn: () => Promise<T>,\n): Promise<T> {\n const current = tenantStorage.getStore();\n if (!current || current === SYSTEM_CONTEXT_MARKER) {\n throw new TenantContextError(\n 'Cannot enable super admin bypass without a tenant context. ' +\n 'Use withTenant() first or withSystemContext() instead.',\n );\n }\n\n const bypassContext: TenantContextData = {\n ...current,\n superAdminBypass: true,\n };\n\n return tenantStorage.run(bypassContext, fn);\n}\n\n/**\n * Namespace object providing advanced tenant context utilities.\n *\n * Contains helpers for binding callbacks, inspecting context state, and\n * running code with the context stored in a queued job payload. These\n * utilities supplement the standalone exported functions for situations where\n * async context might otherwise be lost (e.g., `setTimeout`, event emitters,\n * message queue consumers).\n *\n * @example\n * ```typescript\n * import { TenantContext } from '@happyvertical/smrt-tenancy';\n *\n * // Preserve context across a setTimeout\n * setTimeout(TenantContext.bind(() => {\n * console.log(getTenantId()); // context is intact\n * }), 500);\n *\n * // Process a queued job\n * await TenantContext.runWithJobContext(job, async () => {\n * await processJob(job);\n * });\n * ```\n */\nexport const TenantContext = {\n /**\n * Bind a callback to the current tenant context\n *\n * Use this when passing callbacks to setTimeout, event emitters,\n * or other APIs that might lose the async context.\n *\n * @param fn - Function to bind to current context\n * @returns Wrapped function that will run in the original context\n *\n * @example\n * ```typescript\n * // Without bind - context is lost\n * setTimeout(() => {\n * console.log(getTenantId()); // undefined!\n * }, 1000);\n *\n * // With bind - context is preserved\n * setTimeout(TenantContext.bind(() => {\n * console.log(getTenantId()); // 'tenant-123'\n * }), 1000);\n * ```\n */\n bind<T extends (...args: unknown[]) => unknown>(fn: T): T {\n const store = tenantStorage.getStore();\n if (!store) {\n // No context to bind, return function as-is\n return fn;\n }\n\n // Preserve the context (including system context marker)\n return ((...args: unknown[]) => {\n return tenantStorage.run(store, () => fn(...args));\n }) as T;\n },\n\n /**\n * Get the current context data (or undefined for system/no context)\n */\n get current(): TenantContextData | undefined {\n return getCurrentTenant();\n },\n\n /**\n * Check if we're in system context\n */\n get isSystem(): boolean {\n return isSystemContext();\n },\n\n /**\n * Run code with context from a job/message payload\n *\n * Useful for processing queued jobs that include tenant metadata.\n *\n * @param job - Job object with tenantId in metadata\n * @param fn - Function to run in the job's tenant context\n *\n * @example\n * ```typescript\n * const job = await queue.pop();\n * await TenantContext.runWithJobContext(job, async () => {\n * await processJob(job);\n * });\n * ```\n */\n async runWithJobContext<T>(\n job: { metadata?: { tenantId?: string }; tenantId?: string },\n fn: () => Promise<T>,\n ): Promise<T> {\n const tenantId = job.metadata?.tenantId ?? job.tenantId;\n if (!tenantId) {\n throw new TenantContextError(\n 'Job does not contain tenant information. ' +\n 'Ensure jobs include tenantId in metadata or as a top-level field.',\n );\n }\n\n return withTenant({ tenantId }, fn);\n },\n};\n\n/**\n * Error thrown when a tenant context is required but not available.\n *\n * Raised by `requireTenant()`, `requireTenantId()`, and the tenant interceptor\n * when a `@TenantScoped({ mode: 'required' })` operation is attempted outside\n * any `withTenant()` scope.\n *\n * The `code` property is always `'TENANT_CONTEXT_REQUIRED'` and can be used for\n * programmatic error handling.\n *\n * @example\n * ```typescript\n * try {\n * const tenantId = requireTenantId();\n * } catch (err) {\n * if (err instanceof TenantContextError) {\n * // err.code === 'TENANT_CONTEXT_REQUIRED'\n * }\n * }\n * ```\n *\n * @see requireTenant\n * @see requireTenantId\n * @see TenantIsolationError\n */\nexport class TenantContextError extends Error {\n /** Stable error code; always `'TENANT_CONTEXT_REQUIRED'`. */\n readonly code = 'TENANT_CONTEXT_REQUIRED';\n\n constructor(message: string) {\n super(message);\n this.name = 'TenantContextError';\n }\n}\n\n/**\n * Error thrown when a tenant isolation boundary is crossed.\n *\n * Raised by the tenant interceptor when:\n * - A `list()` or `get()` query explicitly filters by a tenant ID that does not\n * match the current context tenant.\n * - A `save()` or `delete()` is attempted on an object whose `tenantId` field\n * belongs to a different tenant than the current context.\n * - A raw SQL query is executed against a tenant-scoped class without an\n * explicit bypass (when `rawQueryPolicy` is `'throw'`).\n *\n * The `code` property is always `'TENANT_ISOLATION_VIOLATION'`.\n *\n * @example\n * ```typescript\n * try {\n * await collection.list({ where: { tenantId: 'other-tenant' } });\n * } catch (err) {\n * if (err instanceof TenantIsolationError) {\n * // err.tenantId — context tenant\n * // err.attemptedTenantId — tenant that was attempted\n * }\n * }\n * ```\n *\n * @see TenantContextError\n * @see createTenantInterceptor\n */\nexport class TenantIsolationError extends Error {\n /** Stable error code; always `'TENANT_ISOLATION_VIOLATION'`. */\n readonly code = 'TENANT_ISOLATION_VIOLATION';\n /** The tenant ID that is active in the current context. */\n readonly tenantId?: string;\n /** The tenant ID that was attempted (and rejected). */\n readonly attemptedTenantId?: string;\n\n constructor(\n message: string,\n details?: { tenantId?: string; attemptedTenantId?: string },\n ) {\n super(message);\n this.name = 'TenantIsolationError';\n this.tenantId = details?.tenantId;\n this.attemptedTenantId = details?.attemptedTenantId;\n }\n}\n"],"mappings":";;AA6FA,IAAM,wBAAwB,uBAAO,IAAI,qBAAqB;AAM9D,IAAM,gBAAgB,IAAI,kBAAqC;AAsBxD,SAAS,mBAAkD;CAChE,MAAM,QAAQ,cAAc,SAAS;CAErC,IAAI,UAAU,uBACZ;CAEF,OAAO;AACT;AAoBO,SAAS,gBAAmC;CACjD,MAAM,MAAM,cAAc,SAAS;CACnC,IAAI,CAAC,OAAO,QAAQ,uBAClB,MAAM,IAAI,mBACR,qGAEF;CAEF,OAAO;AACT;AAmBO,SAAS,kBAA0B;CACxC,OAAO,cAAc,CAAA,CAAE;AACzB;AAsBO,SAAS,cAAkC;CAChD,MAAM,QAAQ,cAAc,SAAS;CACrC,IAAI,UAAU,uBACZ;CAEF,OAAO,OAAO;AAChB;AAoBO,SAAS,mBAA4B;CAC1C,MAAM,QAAQ,cAAc,SAAS;CAErC,OAAO,UAAU,KAAA,KAAa,UAAU;AAC1C;AAeO,SAAS,kBAA2B;CACzC,OAAO,cAAc,SAAS,MAAM;AACtC;AAcO,SAAS,qBAA8B;CAC5C,MAAM,QAAQ,cAAc,SAAS;CACrC,IAAI,UAAU,uBACZ,OAAO;CAET,OAAO,OAAO,qBAAqB;AACrC;AAiBA,eAAsB,WACpB,SACA,IACY;CACZ,MAAM,cAAiC;EACrC,6BAAa,IAAI,IAAI;EACrB,GAAG;CACL;CACA,OAAO,cAAc,IAAI,aAAa,EAAE;AAC1C;AAsBO,SAAS,eACd,SACA,IACG;CACH,MAAM,cAAiC;EACrC,6BAAa,IAAI,IAAI;EACrB,GAAG;CACL;CACA,OAAO,cAAc,IAAI,aAAa,EAAE;AAC1C;AAoBO,SAAS,mBACd,SACM;CACN,MAAM,cAAiC;EACrC,6BAAa,IAAI,IAAI;EACrB,GAAG;CACL;CACA,cAAc,UAAU,WAAW;AACrC;AAwBA,eAAsB,kBAAqB,IAAkC;CAE3E,OAAO,cAAc,IAAI,uBAAuB,EAAE;AACpD;AAgCA,eAAsB,qBACpB,IACY;CACZ,MAAM,UAAU,cAAc,SAAS;CACvC,IAAI,CAAC,WAAW,YAAY,uBAC1B,MAAM,IAAI,mBACR,mHAEF;CAGF,MAAM,gBAAmC;EACvC,GAAG;EACH,kBAAkB;CACpB;CAEA,OAAO,cAAc,IAAI,eAAe,EAAE;AAC5C;AA0BO,IAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;CAuB3B,KAAgD,IAAU;EACxD,MAAM,QAAQ,cAAc,SAAS;EACrC,IAAI,CAAC,OAEH,OAAO;EAIT,SAAQ,GAAI,SAAoB;GAC9B,OAAO,cAAc,IAAI,aAAa,GAAG,GAAG,IAAI,CAAC;EACnD;CACF;;;;CAKA,IAAI,UAAyC;EAC3C,OAAO,iBAAiB;CAC1B;;;;CAKA,IAAI,WAAoB;EACtB,OAAO,gBAAgB;CACzB;;;;;;;;;;;;;;;;;CAkBA,MAAM,kBACJ,KACA,IACY;EACZ,MAAM,WAAW,IAAI,UAAU,YAAY,IAAI;EAC/C,IAAI,CAAC,UACH,MAAM,IAAI,mBACR,4GAEF;EAGF,OAAO,WAAW,EAAE,SAAS,GAAG,EAAE;CACpC;AACF;AA2BO,IAAM,qBAAN,cAAiC,MAAM;;CAEnC,OAAO;CAEhB,YAAY,SAAiB;EAC3B,MAAM,OAAO;EACb,KAAK,OAAO;CACd;AACF;AA8BO,IAAM,uBAAN,cAAmC,MAAM;;CAErC,OAAO;;CAEP;;CAEA;CAET,YACE,SACA,SACA;EACA,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,WAAW,SAAS;EACzB,KAAK,oBAAoB,SAAS;CACpC;AACF"}
|