@happyvertical/smrt-tenancy 0.37.4 → 0.37.5

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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { n as createExpressMiddleware, r as createCliContext, t as createSvelteK
3
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-Dz-3sQ9r.js";
4
4
  import { ObjectRegistry, applyPendingDecoratorRegistrations, registerCompatibleFieldDecorator } from "@happyvertical/smrt-core";
5
5
  //#region src/__smrt-register__.ts
6
- ObjectRegistry.registerPackageManifest(new URL("./manifest.json", "" + import.meta.url));
6
+ ObjectRegistry.registerPackageManifest(JSON.parse("{\"version\":\"1.0.0\",\"timestamp\":1782953250253,\"packageName\":\"@happyvertical/smrt-tenancy\",\"packageVersion\":\"0.37.5\",\"objects\":{},\"moduleType\":\"smrt\",\"smrtDependencies\":[\"@happyvertical/smrt-core\"]}"));
7
7
  //#endregion
8
8
  //#region src/decorators.ts
9
9
  function TenantScoped(options = {}) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/__smrt-register__.ts","../src/decorators.ts","../src/fields.ts","../src/tenant-global-queries.ts"],"sourcesContent":["/**\n * Self-registers this package's build-time manifest before any @smrt() decorator\n * in the package fires. Fixes issue #1132: in consumer runtimes (tsx, SvelteKit\n * SSR, plain `vite dev`) the decorator's synchronous manifest lookup previously\n * missed because no step populated the global manifest cache — classes got\n * registered with zero fields and `save()` / `toJSON()` silently dropped every\n * declared property.\n *\n * Import this module as the first statement in `src/index.ts` so its top-level\n * side effect runs ahead of any class module's @smrt() decorator.\n *\n * Silent no-op in dev/test, where the vitest plugin already populates manifests\n * via a different path. Only needs to succeed in the published dist output.\n *\n * @see https://github.com/happyvertical/smrt/issues/1132\n */\nimport { ObjectRegistry } from '@happyvertical/smrt-core';\n\n// `new URL('./manifest.json', import.meta.url)` resolves at runtime to the\n// manifest sitting next to this module's compiled output. Vite warns at build\n// time that it cannot pre-resolve the URL; that is the intended behavior —\n// the URL must resolve to dist/manifest.json at runtime, not be inlined.\nObjectRegistry.registerPackageManifest(\n new URL('./manifest.json', import.meta.url),\n);\n","/**\n * Tenancy Decorators\n *\n * Provides class and property decorators for tenant-scoped SMRT objects.\n *\n * @example\n * ```typescript\n * import { smrt, SmrtObject } from '@happyvertical/smrt-core';\n * import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\n *\n * @smrt()\n * @TenantScoped({ mode: 'optional' })\n * class Document extends SmrtObject {\n * @tenantId({ nullable: true })\n * tenantId: string | null = null; // null = global document\n *\n * title: string = '';\n * }\n * ```\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n * @see https://github.com/happyvertical/smrt/issues/829\n */\n\nimport {\n applyPendingDecoratorRegistrations,\n type CompatiblePropertyDecorator,\n type CompatiblePropertyDecoratorContext,\n type LegacyPropertyDecoratorTarget,\n ObjectRegistry,\n registerCompatibleFieldDecorator,\n} from '@happyvertical/smrt-core';\nimport type { TenantIdFieldOptions } from './fields.js';\nimport {\n registerTenantScopedClass,\n type TenantScopedConfig,\n} from './registry.js';\n\n/**\n * Options accepted by the `@TenantScoped()` class decorator.\n *\n * All fields are optional; defaults match the most restrictive safe behaviour\n * (required mode, auto-filter and auto-populate enabled, no super-admin bypass).\n *\n * @see TenantScoped\n * @see TenantScopedConfig\n */\nexport interface TenantScopedOptions {\n /**\n * Tenancy mode for this class\n * - 'required': Must have tenant context for all operations (default)\n * - 'optional': Works with or without tenant context\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 - must be explicitly enabled\n */\n allowSuperAdminBypass?: boolean;\n}\n\n/**\n * Mark a class as tenant-scoped\n *\n * This decorator registers the class with the tenancy system so that:\n * - list()/get() queries are automatically filtered by tenant\n * - save() validates tenant ID matches current context\n * - delete() validates tenant ownership\n * - Raw SQL queries trigger policy enforcement\n *\n * @param options - Configuration options\n *\n * @example Basic usage (required tenancy)\n * ```typescript\n * @smrt()\n * @TenantScoped()\n * class Document extends SmrtObject {\n * @tenantId()\n * tenantId: string = '';\n *\n * title: string = '';\n * }\n * ```\n *\n * @example With super admin bypass enabled\n * ```typescript\n * @smrt()\n * @TenantScoped({ allowSuperAdminBypass: true })\n * class AuditLog extends SmrtObject {\n * @tenantId()\n * tenantId: string = '';\n *\n * action: string = '';\n * }\n * ```\n *\n * @example Optional tenancy (works with or without context)\n * ```typescript\n * @smrt()\n * @TenantScoped({ mode: 'optional' })\n * class GlobalConfig extends SmrtObject {\n * @tenantId({ nullable: true })\n * tenantId: string | null = null; // null = global, string = tenant-specific\n *\n * key: string = '';\n * value: string = '';\n * }\n * ```\n */\nexport function TenantScoped(options: TenantScopedOptions = {}) {\n return <T extends Function>(\n target: T,\n decoratorContext?: ClassDecoratorContext,\n ): T => {\n applyPendingDecoratorRegistrations(target, decoratorContext);\n\n const className = target.name;\n\n // Merge with defaults\n const config: Partial<TenantScopedConfig> = {\n mode: options.mode ?? 'required',\n field: options.field ?? 'tenantId',\n autoFilter: options.autoFilter ?? true,\n autoPopulate: options.autoPopulate ?? true,\n allowSuperAdminBypass: options.allowSuperAdminBypass ?? false,\n };\n\n // Register with the tenancy system\n registerTenantScopedClass(className, config);\n\n // Return the class unchanged\n return target;\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Property Decorator: @tenantId\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Tenant ID property decorator\n *\n * Marks a property as the tenant identifier field. This decorator registers\n * the field metadata with ObjectRegistry, keeping the property value clean\n * (no descriptor objects that could be accidentally saved to the database).\n *\n * @param options - Field options (nullable, autoFilter, autoPopulate, etc.)\n * @returns Property decorator\n *\n * @example Basic usage (required tenancy)\n * ```typescript\n * @smrt()\n * @TenantScoped()\n * class Document extends SmrtObject {\n * @tenantId()\n * tenantId: string = '';\n *\n * title: string = '';\n * }\n * ```\n *\n * @example Nullable tenant ID (for global resources)\n * ```typescript\n * @smrt()\n * @TenantScoped({ mode: 'optional' })\n * class GlobalConfig extends SmrtObject {\n * @tenantId({ nullable: true })\n * tenantId: string | null = null; // null = global, string = tenant-specific\n *\n * key: string = '';\n * }\n * ```\n *\n * @see https://github.com/happyvertical/smrt/issues/829 - Why decorators over field helpers\n */\nexport function tenantId(options: TenantIdFieldOptions = {}) {\n const opts = {\n autoFilter: true,\n required: true,\n autoPopulate: true,\n nullable: false,\n ...options,\n };\n\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n type: 'foreignKey',\n related: 'Tenant',\n sqlType: 'UUID',\n required: opts.required,\n nullable: opts.nullable,\n __tenancy: {\n ...opts,\n isTenantIdField: true,\n },\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n","/**\n * Tenancy Field Types and Utilities\n *\n * This module provides types and utility functions for tenant ID fields.\n * The actual field decorator is in decorators.ts.\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n * @see https://github.com/happyvertical/smrt/issues/829\n */\n\n/**\n * Options for the `@tenantId()` property decorator.\n *\n * Controls how the decorated field interacts with the tenancy interceptor.\n * All options default to the strictest safe values: auto-filter on, required,\n * auto-populate on, not nullable.\n *\n * @see tenantId\n * @see TenantScopedOptions\n */\nexport interface TenantIdFieldOptions {\n /**\n * Auto-filter queries by this field\n * @default true\n */\n autoFilter?: boolean;\n\n /**\n * Require this field to have a value on save\n * @default true\n */\n required?: boolean;\n\n /**\n * Auto-populate from context on create if not set\n * @default true\n */\n autoPopulate?: boolean;\n\n /**\n * Allow null values (for global resources)\n * @default false\n */\n nullable?: boolean;\n}\n\n// Symbol to identify tenantId fields\nexport const TENANT_ID_SYMBOL = Symbol('tenantId');\n\n/**\n * Internal field descriptor stored in `ObjectRegistry` when `@tenantId()` is\n * applied to a property.\n *\n * Consumers should use `isTenantIdField()` and `getTenantIdFieldOptions()`\n * to inspect these descriptors rather than reading the raw properties directly.\n *\n * @see isTenantIdField\n * @see getTenantIdFieldOptions\n */\nexport interface TenantIdFieldDefinition {\n /** Field type marker */\n type: 'foreignKey';\n /** Reference to Tenant class (placeholder - actual class resolved at runtime) */\n reference: 'Tenant';\n /** SQL type */\n sqlType: 'UUID';\n /** Field is required */\n required: boolean;\n /** Field allows null */\n nullable: boolean;\n /** Tenancy-specific options */\n __tenancy: TenantIdFieldOptions & { isTenantIdField: true };\n}\n\n/**\n * Return `true` if the given field definition was produced by the `@tenantId()`\n * decorator (i.e., it has an `__tenancy.isTenantIdField` marker).\n *\n * Used internally by the interceptor and code generators to locate the tenant\n * ID field on a class without knowing its property name in advance.\n *\n * @param field - A raw field definition object, typically from `ObjectRegistry`.\n * @returns `true` if `field` is a tenant ID field definition, `false` otherwise.\n *\n * @example\n * ```typescript\n * const fields = ObjectRegistry.getFields('Document');\n * const tenantField = Object.entries(fields).find(([, def]) => isTenantIdField(def));\n * ```\n *\n * @see getTenantIdFieldOptions\n * @see TenantIdFieldDefinition\n */\nexport function isTenantIdField(field: unknown): boolean {\n if (!field || typeof field !== 'object') {\n return false;\n }\n const def = field as Record<string, unknown>;\n const tenancy = def.__tenancy as Record<string, unknown> | undefined;\n return tenancy?.isTenantIdField === true;\n}\n\n/**\n * Extract the `TenantIdFieldOptions` from a field definition.\n *\n * Returns the tenancy-specific options (autoFilter, required, autoPopulate,\n * nullable) stored inside the field descriptor's `__tenancy` property.\n * Returns `null` if the field was not produced by `@tenantId()`.\n *\n * @param field - A raw field definition object, typically from `ObjectRegistry`.\n * @returns The `TenantIdFieldOptions` if the field is a tenant ID field,\n * `null` otherwise.\n *\n * @see isTenantIdField\n * @see TenantIdFieldOptions\n */\nexport function getTenantIdFieldOptions(\n field: unknown,\n): TenantIdFieldOptions | null {\n if (!isTenantIdField(field)) {\n return null;\n }\n const def = field as { __tenancy: TenantIdFieldOptions };\n return def.__tenancy;\n}\n","/**\n * Shared raw-SQL helpers for tenant-scoped collections' \"global\" and\n * \"tenant + globals\" lookups (#1600).\n *\n * Most domain models are `@TenantScoped`. Their collections historically\n * hand-rolled two helpers the OLD way:\n *\n * ```typescript\n * async findGlobal() { return this.list({ where: { tenantId: null } }); }\n * async findWithGlobals(tid) { return this.query(\n * `SELECT * FROM ${this.tableName} WHERE tenant_id = ? OR tenant_id IS NULL`, [tid]); }\n * ```\n *\n * Under an ACTIVE tenant context with tenancy enabled (default\n * `rawQueryPolicy: 'throw'`) BOTH break:\n * - `findGlobal()` routes an explicit `tenant_id IS NULL` filter through\n * `list()`, which the interceptor flags as an isolation violation → throws.\n * - `findWithGlobals()` issues unflagged raw SQL on a tenant-scoped class,\n * which `beforeQuery` blocks → throws.\n * - `findWithGlobals()` also trusts the caller-supplied `tenantId`, so once the\n * raw bypass is added a caller under tenant-A could read tenant-B by passing\n * B's id.\n *\n * These helpers run raw with `{ allowRawOnTenantScoped: true }` (carrying the\n * tenant predicate themselves), and `queryWithGlobals` re-implements the\n * isolation guard the bypass disables (`assertTenantReadAllowed`): a caller\n * under tenant-A must not read tenant-B's rows by passing tenant-B's id. A\n * system / super-admin-bypass context keeps the deliberate cross-tenant\n * capability for admin paths.\n *\n * STI scoping is derived automatically from the collection's item class via\n * `collection.getStiChildMetaType()` (smrt-core), which mirrors the\n * `_meta_type` scoping `list()` applies: STI **child** collections scope the\n * shared table to their own subtype, while STI **base** and CTI collections do\n * not (a base legitimately spans subtypes; CTI tables have no `_meta_type`).\n * Callers never hand-classify their collection. Promoted from\n * `@happyvertical/smrt-messages` (#1596) so every package shares one\n * implementation.\n */\n\nimport type { SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';\nimport {\n getCurrentTenant,\n isSuperAdminBypass,\n TenantIsolationError,\n} from './context.js';\n\n/**\n * Fail closed when an active tenant context requests a different tenant's rows.\n *\n * @param tenantId - The tenant id the caller asked for.\n * @param label - `Class.method` identifier for the error message.\n * @throws {TenantIsolationError} when a non-bypass tenant context is active and\n * does not match `tenantId`.\n */\nexport function assertTenantReadAllowed(tenantId: string, label: string): void {\n const tenantContext = getCurrentTenant();\n if (\n tenantContext &&\n !isSuperAdminBypass() &&\n tenantContext.tenantId !== tenantId\n ) {\n throw new TenantIsolationError(\n `Tenant isolation violation in ${label}: context tenant is ` +\n `'${tenantContext.tenantId}' but query requested '${tenantId}'`,\n { tenantId: tenantContext.tenantId, attemptedTenantId: tenantId },\n );\n }\n}\n\n/**\n * Return all global (tenant-less) rows for a tenant-scoped collection.\n *\n * STI child collections are auto-scoped to their own `_meta_type` (via\n * `collection.getStiChildMetaType()`) so the shared table never returns sibling\n * subtypes; STI base / CTI collections are not scoped.\n *\n * @param collection - The tenant-scoped collection to query.\n */\nexport async function queryGlobal<T, M extends SmrtObject = SmrtObject>(\n collection: SmrtCollection<M>,\n): Promise<T[]> {\n const metaType = collection.getStiChildMetaType();\n const where = metaType\n ? 'WHERE _meta_type = ? AND tenant_id IS NULL'\n : 'WHERE tenant_id IS NULL';\n const params = metaType ? [metaType] : [];\n // Two decoupled type params by design (STI). `M` is inferred from the\n // collection's declared item type — the STI *base* (e.g. `Email`) — and keeps\n // the parameter assignable despite `SmrtCollection`'s contravariant\n // `ModelType` positions. `T` is the caller-declared *row* type: an STI child\n // collection (e.g. `EmailAccountCollection`, statically `SmrtCollection<Email>`)\n // filters by `_meta_type` and hydrates child rows (`EmailAccount`) that differ\n // from `M`. `query()` is statically `M[]` but yields those child instances at\n // runtime, so the bridge cast is required — returning `M[]` would break every\n // STI-child caller.\n return (await collection.query(\n `SELECT * FROM ${collection.tableName} ${where}`,\n params,\n { allowRawOnTenantScoped: true },\n )) as unknown as T[];\n}\n\n/**\n * Return a tenant's rows plus all global rows for a tenant-scoped collection.\n *\n * Fails closed (`assertTenantReadAllowed`) before issuing the bypassed query.\n * STI child collections are auto-scoped to their own `_meta_type` (via\n * `collection.getStiChildMetaType()`); STI base / CTI collections are not.\n *\n * @param collection - The tenant-scoped collection to query.\n * @param tenantId - The tenant id to include alongside globals.\n * @param label - `Class.method` identifier for the isolation error message.\n */\nexport async function queryWithGlobals<T, M extends SmrtObject = SmrtObject>(\n collection: SmrtCollection<M>,\n tenantId: string,\n label: string,\n): Promise<T[]> {\n assertTenantReadAllowed(tenantId, label);\n const metaType = collection.getStiChildMetaType();\n const where = metaType\n ? 'WHERE _meta_type = ? AND (tenant_id = ? OR tenant_id IS NULL)'\n : 'WHERE tenant_id = ? OR tenant_id IS NULL';\n const params = metaType ? [metaType, tenantId] : [tenantId];\n // See `queryGlobal` above: `T` (caller's STI child row type) is intentionally\n // decoupled from `M` (the collection's inferred base type), so the cast\n // bridges `query()`'s static `M[]` to the hydrated child rows.\n return (await collection.query(\n `SELECT * FROM ${collection.tableName} ${where}`,\n params,\n { allowRawOnTenantScoped: true },\n )) as unknown as T[];\n}\n"],"mappings":";;;;;AAsBA,eAAe,wBACb,IAAA,IAAA,mBAAA,KAAA,OAAA,KAAA,GAAA,CACF;;;ACwGO,SAAS,aAAa,UAA+B,CAAC,GAAG;CAC9D,QACE,QACA,qBACM;EACN,mCAAmC,QAAQ,gBAAgB;EAE3D,MAAM,YAAY,OAAO;EAYzB,0BAA0B,WAAW;GARnC,MAAM,QAAQ,QAAQ;GACtB,OAAO,QAAQ,SAAS;GACxB,YAAY,QAAQ,cAAc;GAClC,cAAc,QAAQ,gBAAgB;GACtC,uBAAuB,QAAQ,yBAAyB;EAIrB,CAAM;EAG3C,OAAO;CACT;AACF;AA0CO,SAAS,SAAS,UAAgC,CAAC,GAAG;CAC3D,MAAM,OAAO;EACX,YAAY;EACZ,UAAU;EACV,cAAc;EACd,UAAU;EACV,GAAG;CACL;CAEA,SACE,eACA,yBACG;EACH,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa;IAC5D,MAAM;IACN,SAAS;IACT,SAAS;IACT,UAAU,KAAK;IACf,UAAU,KAAK;IACf,WAAW;KACT,GAAG;KACH,iBAAiB;IACnB;GACF,CAAC;EACH,CACF;CACF;AACF;;;ACpIO,SAAS,gBAAgB,OAAyB;CACvD,IAAI,CAAC,SAAS,OAAO,UAAU,UAC7B,OAAO;CAIT,OADgB,MAAI,WACJ,oBAAoB;AACtC;AAgBO,SAAS,wBACd,OAC6B;CAC7B,IAAI,CAAC,gBAAgB,KAAK,GACxB,OAAO;CAGT,OAAO,MAAI;AACb;;;ACrEO,SAAS,wBAAwB,UAAkB,OAAqB;CAC7E,MAAM,gBAAgB,iBAAiB;CACvC,IACE,iBACA,CAAC,mBAAmB,KACpB,cAAc,aAAa,UAE3B,MAAM,IAAI,qBACR,iCAAiC,MAAK,uBAChC,cAAc,SAAQ,yBAA0B,SAAQ,IAC9D;EAAE,UAAU,cAAc;EAAU,mBAAmB;CAAS,CAClE;AAEJ;AAWA,eAAsB,YACpB,YACc;CACd,MAAM,WAAW,WAAW,oBAAoB;CAChD,MAAM,QAAQ,WACV,+CACA;CACJ,MAAM,SAAS,WAAW,CAAC,QAAQ,IAAI,CAAC;CAUxC,OAAQ,MAAM,WAAW,MACvB,iBAAiB,WAAW,UAAS,GAAI,SACzC,QACA,EAAE,wBAAwB,KAAK,CACjC;AACF;AAaA,eAAsB,iBACpB,YACA,UACA,OACc;CACd,wBAAwB,UAAU,KAAK;CACvC,MAAM,WAAW,WAAW,oBAAoB;CAChD,MAAM,QAAQ,WACV,kEACA;CACJ,MAAM,SAAS,WAAW,CAAC,UAAU,QAAQ,IAAI,CAAC,QAAQ;CAI1D,OAAQ,MAAM,WAAW,MACvB,iBAAiB,WAAW,UAAS,GAAI,SACzC,QACA,EAAE,wBAAwB,KAAK,CACjC;AACF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/__smrt-register__.ts","../src/decorators.ts","../src/fields.ts","../src/tenant-global-queries.ts"],"sourcesContent":["/**\n * Self-registers this package's build-time manifest before any @smrt() decorator\n * in the package fires. Fixes issue #1132: in consumer runtimes (tsx, SvelteKit\n * SSR, plain `vite dev`) the decorator's synchronous manifest lookup previously\n * missed because no step populated the global manifest cache — classes got\n * registered with zero fields and `save()` / `toJSON()` silently dropped every\n * declared property.\n *\n * Import this module as the first statement in `src/index.ts` so its top-level\n * side effect runs ahead of any class module's @smrt() decorator.\n *\n * Silent no-op in dev/test, where the vitest plugin already populates manifests\n * via a different path. Only needs to succeed in the published dist output.\n *\n * @see https://github.com/happyvertical/smrt/issues/1132\n */\nimport { ObjectRegistry } from '@happyvertical/smrt-core';\n\n// During library builds, smrtPlugin replaces this entire module with generated\n// code that embeds the scanned manifest inline (#1506/#1507) — published dists\n// never resolve this URL, so downstream bundlers cannot break registration by\n// relocating the compiled module away from dist/manifest.json. The runtime\n// lookup below is the fallback for source-mode runs without that transform.\nObjectRegistry.registerPackageManifest(\n new URL('./manifest.json', import.meta.url),\n);\n","/**\n * Tenancy Decorators\n *\n * Provides class and property decorators for tenant-scoped SMRT objects.\n *\n * @example\n * ```typescript\n * import { smrt, SmrtObject } from '@happyvertical/smrt-core';\n * import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\n *\n * @smrt()\n * @TenantScoped({ mode: 'optional' })\n * class Document extends SmrtObject {\n * @tenantId({ nullable: true })\n * tenantId: string | null = null; // null = global document\n *\n * title: string = '';\n * }\n * ```\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n * @see https://github.com/happyvertical/smrt/issues/829\n */\n\nimport {\n applyPendingDecoratorRegistrations,\n type CompatiblePropertyDecorator,\n type CompatiblePropertyDecoratorContext,\n type LegacyPropertyDecoratorTarget,\n ObjectRegistry,\n registerCompatibleFieldDecorator,\n} from '@happyvertical/smrt-core';\nimport type { TenantIdFieldOptions } from './fields.js';\nimport {\n registerTenantScopedClass,\n type TenantScopedConfig,\n} from './registry.js';\n\n/**\n * Options accepted by the `@TenantScoped()` class decorator.\n *\n * All fields are optional; defaults match the most restrictive safe behaviour\n * (required mode, auto-filter and auto-populate enabled, no super-admin bypass).\n *\n * @see TenantScoped\n * @see TenantScopedConfig\n */\nexport interface TenantScopedOptions {\n /**\n * Tenancy mode for this class\n * - 'required': Must have tenant context for all operations (default)\n * - 'optional': Works with or without tenant context\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 - must be explicitly enabled\n */\n allowSuperAdminBypass?: boolean;\n}\n\n/**\n * Mark a class as tenant-scoped\n *\n * This decorator registers the class with the tenancy system so that:\n * - list()/get() queries are automatically filtered by tenant\n * - save() validates tenant ID matches current context\n * - delete() validates tenant ownership\n * - Raw SQL queries trigger policy enforcement\n *\n * @param options - Configuration options\n *\n * @example Basic usage (required tenancy)\n * ```typescript\n * @smrt()\n * @TenantScoped()\n * class Document extends SmrtObject {\n * @tenantId()\n * tenantId: string = '';\n *\n * title: string = '';\n * }\n * ```\n *\n * @example With super admin bypass enabled\n * ```typescript\n * @smrt()\n * @TenantScoped({ allowSuperAdminBypass: true })\n * class AuditLog extends SmrtObject {\n * @tenantId()\n * tenantId: string = '';\n *\n * action: string = '';\n * }\n * ```\n *\n * @example Optional tenancy (works with or without context)\n * ```typescript\n * @smrt()\n * @TenantScoped({ mode: 'optional' })\n * class GlobalConfig extends SmrtObject {\n * @tenantId({ nullable: true })\n * tenantId: string | null = null; // null = global, string = tenant-specific\n *\n * key: string = '';\n * value: string = '';\n * }\n * ```\n */\nexport function TenantScoped(options: TenantScopedOptions = {}) {\n return <T extends Function>(\n target: T,\n decoratorContext?: ClassDecoratorContext,\n ): T => {\n applyPendingDecoratorRegistrations(target, decoratorContext);\n\n const className = target.name;\n\n // Merge with defaults\n const config: Partial<TenantScopedConfig> = {\n mode: options.mode ?? 'required',\n field: options.field ?? 'tenantId',\n autoFilter: options.autoFilter ?? true,\n autoPopulate: options.autoPopulate ?? true,\n allowSuperAdminBypass: options.allowSuperAdminBypass ?? false,\n };\n\n // Register with the tenancy system\n registerTenantScopedClass(className, config);\n\n // Return the class unchanged\n return target;\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Property Decorator: @tenantId\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Tenant ID property decorator\n *\n * Marks a property as the tenant identifier field. This decorator registers\n * the field metadata with ObjectRegistry, keeping the property value clean\n * (no descriptor objects that could be accidentally saved to the database).\n *\n * @param options - Field options (nullable, autoFilter, autoPopulate, etc.)\n * @returns Property decorator\n *\n * @example Basic usage (required tenancy)\n * ```typescript\n * @smrt()\n * @TenantScoped()\n * class Document extends SmrtObject {\n * @tenantId()\n * tenantId: string = '';\n *\n * title: string = '';\n * }\n * ```\n *\n * @example Nullable tenant ID (for global resources)\n * ```typescript\n * @smrt()\n * @TenantScoped({ mode: 'optional' })\n * class GlobalConfig extends SmrtObject {\n * @tenantId({ nullable: true })\n * tenantId: string | null = null; // null = global, string = tenant-specific\n *\n * key: string = '';\n * }\n * ```\n *\n * @see https://github.com/happyvertical/smrt/issues/829 - Why decorators over field helpers\n */\nexport function tenantId(options: TenantIdFieldOptions = {}) {\n const opts = {\n autoFilter: true,\n required: true,\n autoPopulate: true,\n nullable: false,\n ...options,\n };\n\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n type: 'foreignKey',\n related: 'Tenant',\n sqlType: 'UUID',\n required: opts.required,\n nullable: opts.nullable,\n __tenancy: {\n ...opts,\n isTenantIdField: true,\n },\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n","/**\n * Tenancy Field Types and Utilities\n *\n * This module provides types and utility functions for tenant ID fields.\n * The actual field decorator is in decorators.ts.\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n * @see https://github.com/happyvertical/smrt/issues/829\n */\n\n/**\n * Options for the `@tenantId()` property decorator.\n *\n * Controls how the decorated field interacts with the tenancy interceptor.\n * All options default to the strictest safe values: auto-filter on, required,\n * auto-populate on, not nullable.\n *\n * @see tenantId\n * @see TenantScopedOptions\n */\nexport interface TenantIdFieldOptions {\n /**\n * Auto-filter queries by this field\n * @default true\n */\n autoFilter?: boolean;\n\n /**\n * Require this field to have a value on save\n * @default true\n */\n required?: boolean;\n\n /**\n * Auto-populate from context on create if not set\n * @default true\n */\n autoPopulate?: boolean;\n\n /**\n * Allow null values (for global resources)\n * @default false\n */\n nullable?: boolean;\n}\n\n// Symbol to identify tenantId fields\nexport const TENANT_ID_SYMBOL = Symbol('tenantId');\n\n/**\n * Internal field descriptor stored in `ObjectRegistry` when `@tenantId()` is\n * applied to a property.\n *\n * Consumers should use `isTenantIdField()` and `getTenantIdFieldOptions()`\n * to inspect these descriptors rather than reading the raw properties directly.\n *\n * @see isTenantIdField\n * @see getTenantIdFieldOptions\n */\nexport interface TenantIdFieldDefinition {\n /** Field type marker */\n type: 'foreignKey';\n /** Reference to Tenant class (placeholder - actual class resolved at runtime) */\n reference: 'Tenant';\n /** SQL type */\n sqlType: 'UUID';\n /** Field is required */\n required: boolean;\n /** Field allows null */\n nullable: boolean;\n /** Tenancy-specific options */\n __tenancy: TenantIdFieldOptions & { isTenantIdField: true };\n}\n\n/**\n * Return `true` if the given field definition was produced by the `@tenantId()`\n * decorator (i.e., it has an `__tenancy.isTenantIdField` marker).\n *\n * Used internally by the interceptor and code generators to locate the tenant\n * ID field on a class without knowing its property name in advance.\n *\n * @param field - A raw field definition object, typically from `ObjectRegistry`.\n * @returns `true` if `field` is a tenant ID field definition, `false` otherwise.\n *\n * @example\n * ```typescript\n * const fields = ObjectRegistry.getFields('Document');\n * const tenantField = Object.entries(fields).find(([, def]) => isTenantIdField(def));\n * ```\n *\n * @see getTenantIdFieldOptions\n * @see TenantIdFieldDefinition\n */\nexport function isTenantIdField(field: unknown): boolean {\n if (!field || typeof field !== 'object') {\n return false;\n }\n const def = field as Record<string, unknown>;\n const tenancy = def.__tenancy as Record<string, unknown> | undefined;\n return tenancy?.isTenantIdField === true;\n}\n\n/**\n * Extract the `TenantIdFieldOptions` from a field definition.\n *\n * Returns the tenancy-specific options (autoFilter, required, autoPopulate,\n * nullable) stored inside the field descriptor's `__tenancy` property.\n * Returns `null` if the field was not produced by `@tenantId()`.\n *\n * @param field - A raw field definition object, typically from `ObjectRegistry`.\n * @returns The `TenantIdFieldOptions` if the field is a tenant ID field,\n * `null` otherwise.\n *\n * @see isTenantIdField\n * @see TenantIdFieldOptions\n */\nexport function getTenantIdFieldOptions(\n field: unknown,\n): TenantIdFieldOptions | null {\n if (!isTenantIdField(field)) {\n return null;\n }\n const def = field as { __tenancy: TenantIdFieldOptions };\n return def.__tenancy;\n}\n","/**\n * Shared raw-SQL helpers for tenant-scoped collections' \"global\" and\n * \"tenant + globals\" lookups (#1600).\n *\n * Most domain models are `@TenantScoped`. Their collections historically\n * hand-rolled two helpers the OLD way:\n *\n * ```typescript\n * async findGlobal() { return this.list({ where: { tenantId: null } }); }\n * async findWithGlobals(tid) { return this.query(\n * `SELECT * FROM ${this.tableName} WHERE tenant_id = ? OR tenant_id IS NULL`, [tid]); }\n * ```\n *\n * Under an ACTIVE tenant context with tenancy enabled (default\n * `rawQueryPolicy: 'throw'`) BOTH break:\n * - `findGlobal()` routes an explicit `tenant_id IS NULL` filter through\n * `list()`, which the interceptor flags as an isolation violation → throws.\n * - `findWithGlobals()` issues unflagged raw SQL on a tenant-scoped class,\n * which `beforeQuery` blocks → throws.\n * - `findWithGlobals()` also trusts the caller-supplied `tenantId`, so once the\n * raw bypass is added a caller under tenant-A could read tenant-B by passing\n * B's id.\n *\n * These helpers run raw with `{ allowRawOnTenantScoped: true }` (carrying the\n * tenant predicate themselves), and `queryWithGlobals` re-implements the\n * isolation guard the bypass disables (`assertTenantReadAllowed`): a caller\n * under tenant-A must not read tenant-B's rows by passing tenant-B's id. A\n * system / super-admin-bypass context keeps the deliberate cross-tenant\n * capability for admin paths.\n *\n * STI scoping is derived automatically from the collection's item class via\n * `collection.getStiChildMetaType()` (smrt-core), which mirrors the\n * `_meta_type` scoping `list()` applies: STI **child** collections scope the\n * shared table to their own subtype, while STI **base** and CTI collections do\n * not (a base legitimately spans subtypes; CTI tables have no `_meta_type`).\n * Callers never hand-classify their collection. Promoted from\n * `@happyvertical/smrt-messages` (#1596) so every package shares one\n * implementation.\n */\n\nimport type { SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';\nimport {\n getCurrentTenant,\n isSuperAdminBypass,\n TenantIsolationError,\n} from './context.js';\n\n/**\n * Fail closed when an active tenant context requests a different tenant's rows.\n *\n * @param tenantId - The tenant id the caller asked for.\n * @param label - `Class.method` identifier for the error message.\n * @throws {TenantIsolationError} when a non-bypass tenant context is active and\n * does not match `tenantId`.\n */\nexport function assertTenantReadAllowed(tenantId: string, label: string): void {\n const tenantContext = getCurrentTenant();\n if (\n tenantContext &&\n !isSuperAdminBypass() &&\n tenantContext.tenantId !== tenantId\n ) {\n throw new TenantIsolationError(\n `Tenant isolation violation in ${label}: context tenant is ` +\n `'${tenantContext.tenantId}' but query requested '${tenantId}'`,\n { tenantId: tenantContext.tenantId, attemptedTenantId: tenantId },\n );\n }\n}\n\n/**\n * Return all global (tenant-less) rows for a tenant-scoped collection.\n *\n * STI child collections are auto-scoped to their own `_meta_type` (via\n * `collection.getStiChildMetaType()`) so the shared table never returns sibling\n * subtypes; STI base / CTI collections are not scoped.\n *\n * @param collection - The tenant-scoped collection to query.\n */\nexport async function queryGlobal<T, M extends SmrtObject = SmrtObject>(\n collection: SmrtCollection<M>,\n): Promise<T[]> {\n const metaType = collection.getStiChildMetaType();\n const where = metaType\n ? 'WHERE _meta_type = ? AND tenant_id IS NULL'\n : 'WHERE tenant_id IS NULL';\n const params = metaType ? [metaType] : [];\n // Two decoupled type params by design (STI). `M` is inferred from the\n // collection's declared item type — the STI *base* (e.g. `Email`) — and keeps\n // the parameter assignable despite `SmrtCollection`'s contravariant\n // `ModelType` positions. `T` is the caller-declared *row* type: an STI child\n // collection (e.g. `EmailAccountCollection`, statically `SmrtCollection<Email>`)\n // filters by `_meta_type` and hydrates child rows (`EmailAccount`) that differ\n // from `M`. `query()` is statically `M[]` but yields those child instances at\n // runtime, so the bridge cast is required — returning `M[]` would break every\n // STI-child caller.\n return (await collection.query(\n `SELECT * FROM ${collection.tableName} ${where}`,\n params,\n { allowRawOnTenantScoped: true },\n )) as unknown as T[];\n}\n\n/**\n * Return a tenant's rows plus all global rows for a tenant-scoped collection.\n *\n * Fails closed (`assertTenantReadAllowed`) before issuing the bypassed query.\n * STI child collections are auto-scoped to their own `_meta_type` (via\n * `collection.getStiChildMetaType()`); STI base / CTI collections are not.\n *\n * @param collection - The tenant-scoped collection to query.\n * @param tenantId - The tenant id to include alongside globals.\n * @param label - `Class.method` identifier for the isolation error message.\n */\nexport async function queryWithGlobals<T, M extends SmrtObject = SmrtObject>(\n collection: SmrtCollection<M>,\n tenantId: string,\n label: string,\n): Promise<T[]> {\n assertTenantReadAllowed(tenantId, label);\n const metaType = collection.getStiChildMetaType();\n const where = metaType\n ? 'WHERE _meta_type = ? AND (tenant_id = ? OR tenant_id IS NULL)'\n : 'WHERE tenant_id = ? OR tenant_id IS NULL';\n const params = metaType ? [metaType, tenantId] : [tenantId];\n // See `queryGlobal` above: `T` (caller's STI child row type) is intentionally\n // decoupled from `M` (the collection's inferred base type), so the cast\n // bridges `query()`'s static `M[]` to the hydrated child rows.\n return (await collection.query(\n `SELECT * FROM ${collection.tableName} ${where}`,\n params,\n { allowRawOnTenantScoped: true },\n )) as unknown as T[];\n}\n"],"mappings":";;;;;;;;ACgIO,SAAS,aAAa,UAA+B,CAAC,GAAG;CAC9D,QACE,QACA,qBACM;EACN,mCAAmC,QAAQ,gBAAgB;EAE3D,MAAM,YAAY,OAAO;EAYzB,0BAA0B,WAAW;GARnC,MAAM,QAAQ,QAAQ;GACtB,OAAO,QAAQ,SAAS;GACxB,YAAY,QAAQ,cAAc;GAClC,cAAc,QAAQ,gBAAgB;GACtC,uBAAuB,QAAQ,yBAAyB;EAIrB,CAAM;EAG3C,OAAO;CACT;AACF;AA0CO,SAAS,SAAS,UAAgC,CAAC,GAAG;CAC3D,MAAM,OAAO;EACX,YAAY;EACZ,UAAU;EACV,cAAc;EACd,UAAU;EACV,GAAG;CACL;CAEA,SACE,eACA,yBACG;EACH,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa;IAC5D,MAAM;IACN,SAAS;IACT,SAAS;IACT,UAAU,KAAK;IACf,UAAU,KAAK;IACf,WAAW;KACT,GAAG;KACH,iBAAiB;IACnB;GACF,CAAC;EACH,CACF;CACF;AACF;;;ACpIO,SAAS,gBAAgB,OAAyB;CACvD,IAAI,CAAC,SAAS,OAAO,UAAU,UAC7B,OAAO;CAIT,OADgB,MAAI,WACJ,oBAAoB;AACtC;AAgBO,SAAS,wBACd,OAC6B;CAC7B,IAAI,CAAC,gBAAgB,KAAK,GACxB,OAAO;CAGT,OAAO,MAAI;AACb;;;ACrEO,SAAS,wBAAwB,UAAkB,OAAqB;CAC7E,MAAM,gBAAgB,iBAAiB;CACvC,IACE,iBACA,CAAC,mBAAmB,KACpB,cAAc,aAAa,UAE3B,MAAM,IAAI,qBACR,iCAAiC,MAAK,uBAChC,cAAc,SAAQ,yBAA0B,SAAQ,IAC9D;EAAE,UAAU,cAAc;EAAU,mBAAmB;CAAS,CAClE;AAEJ;AAWA,eAAsB,YACpB,YACc;CACd,MAAM,WAAW,WAAW,oBAAoB;CAChD,MAAM,QAAQ,WACV,+CACA;CACJ,MAAM,SAAS,WAAW,CAAC,QAAQ,IAAI,CAAC;CAUxC,OAAQ,MAAM,WAAW,MACvB,iBAAiB,WAAW,UAAS,GAAI,SACzC,QACA,EAAE,wBAAwB,KAAK,CACjC;AACF;AAaA,eAAsB,iBACpB,YACA,UACA,OACc;CACd,wBAAwB,UAAU,KAAK;CACvC,MAAM,WAAW,WAAW,oBAAoB;CAChD,MAAM,QAAQ,WACV,kEACA;CACJ,MAAM,SAAS,WAAW,CAAC,UAAU,QAAQ,IAAI,CAAC,QAAQ;CAI1D,OAAQ,MAAM,WAAW,MACvB,iBAAiB,WAAW,UAAS,GAAI,SACzC,QACA,EAAE,wBAAwB,KAAK,CACjC;AACF"}
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "version": "1.0.0",
3
- "timestamp": 1782943577760,
3
+ "timestamp": 1782953250253,
4
4
  "packageName": "@happyvertical/smrt-tenancy",
5
- "packageVersion": "0.37.4",
5
+ "packageVersion": "0.37.5",
6
6
  "objects": {},
7
7
  "moduleType": "smrt",
8
8
  "smrtDependencies": [
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-01T22:06:17.994Z",
3
+ "generatedAt": "2026-07-02T00:47:31.098Z",
4
4
  "packageName": "@happyvertical/smrt-tenancy",
5
- "packageVersion": "0.37.4",
5
+ "packageVersion": "0.37.5",
6
6
  "sourceManifestPath": "dist/manifest.json",
7
7
  "agentDocPath": "AGENTS.md",
8
8
  "sourceHashes": {
9
- "manifest": "9a51bc7690e376cac31bde3039e11e1cf57525acbdc7cf46de891f9b3d1d453e",
10
- "packageJson": "1730a78bb726eca3b8032426515a960bf685ead396827ad305c486cc594288d0",
9
+ "manifest": "93ac2be4550a4def5c65afc87c151934feef30703691c9fa415597ad0d4796e1",
10
+ "packageJson": "8c0943691180c604e033eec3b172af0c0512789208b3edaedceeda36e4c4c6e3",
11
11
  "agents": "6466580ac48829d3e51e940aaf42578919619e3a43fa7efbb741b744c80530c8"
12
12
  },
13
13
  "exports": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-tenancy",
3
- "version": "0.37.4",
3
+ "version": "0.37.5",
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.74.11",
45
45
  "@happyvertical/sql": "^0.74.11",
46
46
  "@happyvertical/utils": "^0.74.11",
47
- "@happyvertical/smrt-core": "0.37.4",
48
- "@happyvertical/smrt-types": "0.37.4",
49
- "@happyvertical/smrt-ui": "0.37.4"
47
+ "@happyvertical/smrt-core": "0.37.5",
48
+ "@happyvertical/smrt-types": "0.37.5",
49
+ "@happyvertical/smrt-ui": "0.37.5"
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.2",
67
67
  "vitest": "^4.1.9",
68
- "@happyvertical/smrt-vitest": "0.37.4"
68
+ "@happyvertical/smrt-vitest": "0.37.5"
69
69
  },
70
70
  "keywords": [
71
71
  "ai",