@happyvertical/smrt-tenancy 0.51.17 → 0.51.31
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 +14 -0
- package/README.md +61 -0
- package/dist/billing-relationship.d.ts +61 -0
- package/dist/billing-relationship.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +176 -3
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +237 -2
- package/dist/smrt-knowledge.json +85 -8
- package/package.json +9 -8
package/AGENTS.md
CHANGED
|
@@ -20,6 +20,20 @@ await withSystemContext(async () => { /* bypasses all tenant checks */ });
|
|
|
20
20
|
|
|
21
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
22
|
|
|
23
|
+
## Billing relationships (#3058)
|
|
24
|
+
|
|
25
|
+
`src/billing-relationship.ts` owns a separate reseller parent graph. It never
|
|
26
|
+
updates `smrt-users` `Tenant.parentTenantId` or tenant-scoped records. One row
|
|
27
|
+
per child stores the reseller and `self`/`reseller` owner mode; owner queries
|
|
28
|
+
derive one tenant ID. Mutations go through `BillingRelationshipService`, which
|
|
29
|
+
serializes graph writes and rejects cycles. The host supplies tenant existence
|
|
30
|
+
and optional authorization; no generated write surface is exposed. The model
|
|
31
|
+
stays a root export because generated consumer registration imports every
|
|
32
|
+
public non-collection manifest object from the root; withholding it breaks
|
|
33
|
+
every dependent's `register.js` (publish dry run, smrt#3074). The collection
|
|
34
|
+
stays unexported, as in smrt-chat. See the README for setup, access rules, and
|
|
35
|
+
migration guidance.
|
|
36
|
+
|
|
23
37
|
## Interceptor System
|
|
24
38
|
|
|
25
39
|
Hooks into SmrtCollection via `GlobalInterceptors.register()` (priority 100, runs first):
|
package/README.md
CHANGED
|
@@ -36,6 +36,67 @@ await withTenant({ tenantId: 'tenant-123' }, async () => {
|
|
|
36
36
|
|
|
37
37
|
## API
|
|
38
38
|
|
|
39
|
+
### Billing relationships
|
|
40
|
+
|
|
41
|
+
Billing parentage is separate from the `@happyvertical/smrt-users` tenant
|
|
42
|
+
hierarchy. Reassigning a reseller or changing who pays does not reparent the
|
|
43
|
+
tenant for permissions and does not move its records. A tenant with no billing
|
|
44
|
+
relationship is self-billed. Each child has at most one relationship; its
|
|
45
|
+
`billingOwnerMode` is `self` or `reseller`, and the owner is derived from that
|
|
46
|
+
single row. Tenant UUIDs are normalized to lowercase before storage and lookup.
|
|
47
|
+
A reseller can therefore manage a child that pays for itself.
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import {
|
|
51
|
+
BillingRelationshipService,
|
|
52
|
+
withSystemContext,
|
|
53
|
+
} from '@happyvertical/smrt-tenancy';
|
|
54
|
+
|
|
55
|
+
const billing = await BillingRelationshipService.create({
|
|
56
|
+
db,
|
|
57
|
+
// Resolve IDs against the application's tenant directory (for example,
|
|
58
|
+
// smrt-users TenantCollection). The tenancy package does not own Tenant.
|
|
59
|
+
tenantExists: async (id) => Boolean(await tenants.get({ id })),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await withSystemContext(() =>
|
|
63
|
+
billing.setRelationship({
|
|
64
|
+
childTenantId: childId,
|
|
65
|
+
resellerTenantId: resellerId,
|
|
66
|
+
billingOwnerMode: 'reseller',
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const ownerId = await withSystemContext(() =>
|
|
71
|
+
billing.resolveBillingOwner(childId),
|
|
72
|
+
);
|
|
73
|
+
const billedChildren = await withSystemContext(() =>
|
|
74
|
+
billing.listChildrenBilledTo(ownerId),
|
|
75
|
+
);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`setRelationship()` changes parentage and the default owner together in a
|
|
79
|
+
transaction. Setting `billingOwnerMode: 'self'` keeps reseller parentage while
|
|
80
|
+
making the child pay for itself. `clearRelationship()` removes parentage and
|
|
81
|
+
returns the child to self-billing. Cycles and self-parenting are rejected.
|
|
82
|
+
Relationship changes are serialized on supported SQL adapters; PostgreSQL
|
|
83
|
+
uses an advisory transaction lock. Local SQLite, DuckDB, and PostgreSQL are
|
|
84
|
+
supported; remote LibSQL is rejected because its writers cannot share the
|
|
85
|
+
in-process graph lock. The new `_smrt_billing_relationships` table
|
|
86
|
+
must be applied through the normal `smrt db:migrate` deployment flow.
|
|
87
|
+
|
|
88
|
+
Generated API, MCP, and CLI mutation surfaces for these rows are disabled.
|
|
89
|
+
`BillingRelationship` is a root export only because s-m-r-t consumer registration
|
|
90
|
+
imports every published manifest model from the package root; saving it
|
|
91
|
+
directly bypasses the service's tenant, cycle, and authorization checks.
|
|
92
|
+
`BillingRelationshipCollection` is not exported. Callers use the service.
|
|
93
|
+
Without an `authorize` callback, mutations require explicit system context.
|
|
94
|
+
Reads are limited to the child, its reseller, or system context; a host may
|
|
95
|
+
provide `authorize` for additional permission-checked access. Supply a tenant
|
|
96
|
+
resolver that does not depend on the caller's current tenant scope when the
|
|
97
|
+
operation intentionally crosses tenants, and enforce actor permissions in the
|
|
98
|
+
host callback before allowing relationship changes.
|
|
99
|
+
|
|
39
100
|
### Decorators
|
|
40
101
|
|
|
41
102
|
| Export | Description |
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { SmrtClassOptions, SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';
|
|
2
|
+
export type BillingOwnerMode = 'self' | 'reseller';
|
|
3
|
+
export interface BillingRelationshipView {
|
|
4
|
+
childTenantId: string;
|
|
5
|
+
resellerTenantId: string;
|
|
6
|
+
billingOwnerMode: BillingOwnerMode;
|
|
7
|
+
billingOwnerTenantId: string;
|
|
8
|
+
}
|
|
9
|
+
export type BillingRelationshipAction = 'read' | 'manage';
|
|
10
|
+
export interface BillingRelationshipServiceOptions extends SmrtClassOptions {
|
|
11
|
+
/** Resolve tenant IDs against the host tenant directory. */
|
|
12
|
+
tenantExists: (tenantId: string) => Promise<boolean>;
|
|
13
|
+
/** Optional host permission check for access beyond the built-in context rules. */
|
|
14
|
+
authorize?: (request: {
|
|
15
|
+
action: BillingRelationshipAction;
|
|
16
|
+
childTenantId: string;
|
|
17
|
+
resellerTenantId?: string;
|
|
18
|
+
}) => Promise<boolean>;
|
|
19
|
+
}
|
|
20
|
+
export declare class BillingRelationshipError extends Error {
|
|
21
|
+
readonly code: 'INVALID_TENANT' | 'INVALID_OWNER_MODE' | 'TENANT_NOT_FOUND' | 'CIRCULAR_RELATIONSHIP';
|
|
22
|
+
constructor(message: string, code: 'INVALID_TENANT' | 'INVALID_OWNER_MODE' | 'TENANT_NOT_FOUND' | 'CIRCULAR_RELATIONSHIP');
|
|
23
|
+
}
|
|
24
|
+
export declare class BillingRelationship extends SmrtObject {
|
|
25
|
+
childTenantId: string;
|
|
26
|
+
resellerTenantId: string;
|
|
27
|
+
billingOwnerMode: BillingOwnerMode;
|
|
28
|
+
protected validateBeforeSave(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare class BillingRelationshipCollection extends SmrtCollection<BillingRelationship> {
|
|
31
|
+
static readonly _itemClass: typeof BillingRelationship;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The one-row-per-child billing graph. The host supplies tenant existence and
|
|
35
|
+
* any additional authorization; absent a host authorizer, only an explicit
|
|
36
|
+
* system context can mutate relationships.
|
|
37
|
+
*/
|
|
38
|
+
export declare class BillingRelationshipService {
|
|
39
|
+
private readonly relationships;
|
|
40
|
+
private readonly options;
|
|
41
|
+
private constructor();
|
|
42
|
+
static create(options: BillingRelationshipServiceOptions): Promise<BillingRelationshipService>;
|
|
43
|
+
/** A tenant without a relationship always pays for itself. */
|
|
44
|
+
resolveBillingOwner(childTenantId: string): Promise<string>;
|
|
45
|
+
getRelationship(childTenantId: string): Promise<BillingRelationshipView | null>;
|
|
46
|
+
/** List direct children billed to an owner, with no collection page limit. */
|
|
47
|
+
listChildrenBilledTo(ownerTenantId: string): Promise<string[]>;
|
|
48
|
+
/** Create or replace parentage and its default billing owner in one row. */
|
|
49
|
+
setRelationship(input: {
|
|
50
|
+
childTenantId: string;
|
|
51
|
+
resellerTenantId: string;
|
|
52
|
+
billingOwnerMode: BillingOwnerMode;
|
|
53
|
+
}): Promise<BillingRelationshipView>;
|
|
54
|
+
/** Remove reseller parentage. The child becomes self-billed. */
|
|
55
|
+
clearRelationship(childTenantId: string): Promise<void>;
|
|
56
|
+
private assertAcyclic;
|
|
57
|
+
private assertExists;
|
|
58
|
+
private assertReadable;
|
|
59
|
+
private assertManage;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=billing-relationship.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"billing-relationship.d.ts","sourceRoot":"","sources":["../src/billing-relationship.ts"],"names":[],"mappings":"AAAA,iFAAiF;AACjF,OAAO,EAKL,KAAK,gBAAgB,EACrB,cAAc,EACd,UAAU,EAGX,MAAM,0BAA0B,CAAC;AAQlC,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,UAAU,CAAC;AAEnD,MAAM,WAAW,uBAAuB;IACtC,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,oBAAoB,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,yBAAyB,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE1D,MAAM,WAAW,iCAAkC,SAAQ,gBAAgB;IACzE,4DAA4D;IAC5D,YAAY,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACrD,mFAAmF;IACnF,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE;QACpB,MAAM,EAAE,yBAAyB,CAAC;QAClC,aAAa,EAAE,MAAM,CAAC;QACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACxB;AAED,qBAAa,wBAAyB,SAAQ,KAAK;aAG/B,IAAI,EAChB,gBAAgB,GAChB,oBAAoB,GACpB,kBAAkB,GAClB,uBAAuB;gBAL3B,OAAO,EAAE,MAAM,EACC,IAAI,EAChB,gBAAgB,GAChB,oBAAoB,GACpB,kBAAkB,GAClB,uBAAuB;CAK9B;AAED,qBAOa,mBAAoB,SAAQ,UAAU;IAGjD,aAAa,EAAE,MAAM,CAAM;IAG3B,gBAAgB,EAAE,MAAM,CAAM;IAC9B,gBAAgB,EAAE,gBAAgB,CAAU;cAE5B,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC;CAYpD;AAED,qBAAa,6BAA8B,SAAQ,cAAc,CAAC,mBAAmB,CAAC;IACpF,MAAM,CAAC,QAAQ,CAAC,UAAU,6BAAuB;CAClD;AAyCD;;;;GAIG;AACH,qBAAa,0BAA0B;IAEnC,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAF1B,OAAO;WAKM,MAAM,CACjB,OAAO,EAAE,iCAAiC,GACzC,OAAO,CAAC,0BAA0B,CAAC;IAgBtC,8DAA8D;IACxD,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAS3D,eAAe,CACnB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC;IAS1C,8EAA8E;IACxE,oBAAoB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAcpE,4EAA4E;IACtE,eAAe,CAAC,KAAK,EAAE;QAC3B,aAAa,EAAE,MAAM,CAAC;QACtB,gBAAgB,EAAE,MAAM,CAAC;QACzB,gBAAgB,EAAE,gBAAgB,CAAC;KACpC,GAAG,OAAO,CAAC,uBAAuB,CAAC;IAgDpC,gEAAgE;IAC1D,iBAAiB,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YA0B/C,aAAa;YAoBb,YAAY;YAUZ,cAAc;YAoBd,YAAY;CAiB3B"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { type CliContextOptions, createCliContext, createExpressMiddleware, createSvelteKitHandle, type ExpressMiddlewareOptions, type SvelteKitHandleOptions, } from './adapters/index.js';
|
|
2
|
+
export { type BillingOwnerMode, BillingRelationship, type BillingRelationshipAction, BillingRelationshipError, BillingRelationshipService, type BillingRelationshipServiceOptions, type BillingRelationshipView, } from './billing-relationship.js';
|
|
2
3
|
export { enterTenantContext, getCurrentTenant, getTenantId, hasTenantContext, isSuperAdminBypass, isSystemContext, type MinimalTenantContext, requireTenant, requireTenantId, TenantContext, type TenantContextData, TenantContextError, TenantIsolationError, withSuperAdminBypass, withSystemContext, withTenant, withTenantSync, } from './context.js';
|
|
3
4
|
export { TenantScoped, type TenantScopedOptions, tenantId, } from './decorators.js';
|
|
4
5
|
export { runTenantScopedEntryPoint, type TenantEntryPointOptions, } from './entry-point.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAKH,OAAO,wBAAwB,CAAC;AAKhC,OAAO,EACL,KAAK,iBAAiB,EAEtB,gBAAgB,EAEhB,uBAAuB,EAEvB,qBAAqB,EACrB,KAAK,wBAAwB,EAC7B,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAKH,OAAO,wBAAwB,CAAC;AAKhC,OAAO,EACL,KAAK,iBAAiB,EAEtB,gBAAgB,EAEhB,uBAAuB,EAEvB,qBAAqB,EACrB,KAAK,wBAAwB,EAC7B,KAAK,sBAAsB,GAC5B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,KAAK,gBAAgB,EACrB,mBAAmB,EACnB,KAAK,yBAAyB,EAC9B,wBAAwB,EACxB,0BAA0B,EAC1B,KAAK,iCAAiC,EACtC,KAAK,uBAAuB,GAC7B,MAAM,2BAA2B,CAAC;AAInC,OAAO,EACL,kBAAkB,EAElB,gBAAgB,EAChB,WAAW,EACX,gBAAgB,EAChB,kBAAkB,EAElB,eAAe,EACf,KAAK,oBAAoB,EACzB,aAAa,EACb,eAAe,EAEf,aAAa,EAEb,KAAK,iBAAiB,EAEtB,kBAAkB,EAClB,oBAAoB,EACpB,oBAAoB,EACpB,iBAAiB,EAEjB,UAAU,EACV,cAAc,GACf,MAAM,cAAc,CAAC;AAKtB,OAAO,EAEL,YAAY,EACZ,KAAK,mBAAmB,EAExB,QAAQ,GACT,MAAM,iBAAiB,CAAC;AAKzB,OAAO,EACL,yBAAyB,EACzB,KAAK,uBAAuB,GAC7B,MAAM,kBAAkB,CAAC;AAI1B,OAAO,EACL,uBAAuB,EACvB,eAAe,EACf,KAAK,uBAAuB,EAC5B,KAAK,oBAAoB,GAC1B,MAAM,aAAa,CAAC;AAIrB,OAAO,EAEL,uBAAuB,EACvB,cAAc,EAEd,aAAa,EACb,gBAAgB,EAChB,KAAK,cAAc,EAEnB,KAAK,wBAAwB,GAC9B,MAAM,kBAAkB,CAAC;AAI1B,OAAO,EACL,yBAAyB,EACzB,yBAAyB,EACzB,qBAAqB,EACrB,mBAAmB,EACnB,yBAAyB,EACzB,KAAK,kBAAkB,EACvB,2BAA2B,GAC5B,MAAM,eAAe,CAAC;AAIvB,OAAO,EACL,uBAAuB,EACvB,WAAW,EACX,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,4BAA4B,CAAC;AAKpC,OAAO,EACL,2BAA2B,EAC3B,8BAA8B,EAC9B,uBAAuB,EACvB,YAAY,EACZ,KAAK,uBAAuB,EAC5B,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,cAAc,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,182 @@
|
|
|
1
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
2
|
import { n as createExpressMiddleware, r as createCliContext, t as createSvelteKitHandle } from "./chunks/adapters-B4hMNcuw.js";
|
|
3
3
|
import { S as unregisterTenantScopedClass, _ as getAllTenantScopedClasses, a as setupTestTenancy, b as registerTenantScopedClass, c as disableTenancy, d as queryGlobal, f as queryWithGlobals, g as clearTenantScopedRegistry, h as isTenancyEnabled, i as resetTenancy, l as enableTenancy, m as runTenantScopedEntryPoint, n as assertTenantIsolationViolation, o as testTenantIsolation, p as withTenantGlobalRead, r as createTestTenantContext, s as createTenantInterceptor, t as assertTenantContextRequired, u as assertTenantReadAllowed, v as getTenantScopedConfig, x as registerTenantScopedConstructor, y as isTenantScopedClass } from "./chunks/testing-s12-pjzJ.js";
|
|
4
|
-
import { ObjectRegistry, applyPendingDecoratorRegistrations, registerCompatibleFieldDecorator } from "@happyvertical/smrt-core";
|
|
4
|
+
import { ObjectRegistry, SmrtCollection, SmrtObject, applyPendingDecoratorRegistrations, crossPackageRef, field, isEmbeddedDatabase, isPostgresDatabase, registerCompatibleFieldDecorator, smrt, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
|
|
5
5
|
//#region src/__smrt-register__.ts
|
|
6
|
-
ObjectRegistry.registerPackageManifest(JSON.parse("{\"version\":\"1.0.0\",\"timestamp\":0,\"packageName\":\"@happyvertical/smrt-tenancy\",\"packageVersion\":\"0.51.
|
|
6
|
+
ObjectRegistry.registerPackageManifest(JSON.parse("{\"version\":\"1.0.0\",\"timestamp\":0,\"packageName\":\"@happyvertical/smrt-tenancy\",\"packageVersion\":\"0.51.31\",\"objects\":{\"@happyvertical/smrt-tenancy:BillingRelationship\":{\"name\":\"billingrelationship\",\"className\":\"BillingRelationship\",\"qualifiedName\":\"@happyvertical/smrt-tenancy:BillingRelationship\",\"collection\":\"billingrelationships\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/tenancy/src/billing-relationship.ts\",\"packageName\":\"@happyvertical/smrt-tenancy\",\"fields\":{\"childTenantId\":{\"type\":\"crossPackageRef\",\"required\":true,\"related\":\"@happyvertical/smrt-users:Tenant\",\"_meta\":{\"required\":true}},\"resellerTenantId\":{\"type\":\"crossPackageRef\",\"required\":true,\"related\":\"@happyvertical/smrt-users:Tenant\",\"_meta\":{\"required\":true}},\"billingOwnerMode\":{\"type\":\"text\",\"required\":false,\"default\":\"self\"}},\"methods\":{},\"decoratorConfig\":{\"tableName\":\"_smrt_billing_relationships\",\"conflictColumns\":[\"child_tenant_id\"],\"api\":false,\"cli\":false,\"mcp\":false},\"extends\":\"SmrtObject\",\"exportName\":\"BillingRelationship\",\"collectionExportName\":\"BillingRelationshipCollection\",\"validationRules\":[{\"field\":\"childTenantId\",\"rule\":\"required\",\"fieldType\":\"crossPackageRef\"},{\"field\":\"resellerTenantId\",\"rule\":\"required\",\"fieldType\":\"crossPackageRef\"}],\"schema\":{\"tableName\":\"_smrt_billing_relationships\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"_smrt_billing_relationships\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"child_tenant_id\\\" UUID NOT NULL,\\n \\\"reseller_tenant_id\\\" UUID NOT NULL,\\n \\\"billing_owner_mode\\\" TEXT DEFAULT 'self'\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"child_tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":true,\"unique\":false},\"reseller_tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":true,\"unique\":false},\"billing_owner_mode\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false,\"default\":\"self\"}},\"indexes\":[{\"name\":\"_smrt_billing_relationships_child_tenant_id_idx\",\"columns\":[\"child_tenant_id\"],\"unique\":true},{\"name\":\"_smrt_billing_relationships_slug_context_idx\",\"columns\":[\"slug\",\"context\"]},{\"name\":\"_smrt_billing_relationships_created_at_idx\",\"columns\":[\"created_at\"]},{\"name\":\"_smrt_billing_relationships_reseller_tenant_id_idx\",\"columns\":[\"reseller_tenant_id\"]}],\"version\":\"4d7f410a\"},\"importPath\":\"@happyvertical/smrt-tenancy\"},\"@happyvertical/smrt-tenancy:BillingRelationshipCollection\":{\"name\":\"billingrelationshipcollection\",\"className\":\"BillingRelationshipCollection\",\"qualifiedName\":\"@happyvertical/smrt-tenancy:BillingRelationshipCollection\",\"collection\":\"billingrelationships\",\"filePath\":\"/home/runner/work/smrt/smrt/packages/tenancy/src/billing-relationship.ts\",\"packageName\":\"@happyvertical/smrt-tenancy\",\"fields\":{},\"methods\":{},\"decoratorConfig\":{\"tableName\":\"_smrt_billing_relationships\",\"api\":false,\"mcp\":false,\"cli\":false},\"extends\":\"SmrtCollection\",\"extendsTypeArg\":\"BillingRelationship\",\"exportName\":\"BillingRelationshipCollection\",\"collectionExportName\":\"BillingRelationshipCollectionCollection\",\"schema\":{\"tableName\":\"_smrt_billing_relationships\",\"ddl\":\"CREATE TABLE IF NOT EXISTS \\\"_smrt_billing_relationships\\\" (\\n \\\"id\\\" UUID PRIMARY KEY NOT NULL,\\n \\\"slug\\\" TEXT NOT NULL,\\n \\\"context\\\" TEXT NOT NULL DEFAULT '',\\n \\\"created_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"updated_at\\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\\"child_tenant_id\\\" UUID NOT NULL,\\n \\\"reseller_tenant_id\\\" UUID NOT NULL,\\n \\\"billing_owner_mode\\\" TEXT DEFAULT 'self'\\n);\",\"columns\":{\"id\":{\"type\":\"UUID\",\"primaryKey\":true,\"referenceKind\":\"id\",\"notNull\":true},\"slug\":{\"type\":\"TEXT\",\"notNull\":true},\"context\":{\"type\":\"TEXT\",\"notNull\":true,\"default\":\"\"},\"created_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"updated_at\":{\"type\":\"TIMESTAMP\",\"notNull\":true,\"default\":\"current_timestamp\"},\"child_tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":true,\"unique\":false},\"reseller_tenant_id\":{\"type\":\"UUID\",\"referenceKind\":\"crossPackageRef\",\"notNull\":true,\"unique\":false},\"billing_owner_mode\":{\"type\":\"TEXT\",\"notNull\":false,\"unique\":false,\"default\":\"self\"}},\"indexes\":[{\"name\":\"_smrt_billing_relationships_child_tenant_id_idx\",\"columns\":[\"child_tenant_id\"],\"unique\":true},{\"name\":\"_smrt_billing_relationships_slug_context_idx\",\"columns\":[\"slug\",\"context\"]},{\"name\":\"_smrt_billing_relationships_created_at_idx\",\"columns\":[\"created_at\"]},{\"name\":\"_smrt_billing_relationships_reseller_tenant_id_idx\",\"columns\":[\"reseller_tenant_id\"]}],\"version\":\"4d7f410a\"},\"importPath\":\"@happyvertical/smrt-tenancy\"}},\"moduleType\":\"smrt\",\"smrtDependencies\":[\"@happyvertical/smrt-core\"]}"));
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/billing-relationship.ts
|
|
9
|
+
var __defProp = Object.defineProperty;
|
|
10
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
11
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
12
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
13
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
14
|
+
if (kind && result) __defProp(target, key, result);
|
|
15
|
+
return result;
|
|
16
|
+
};
|
|
17
|
+
var BillingRelationshipError = class extends Error {
|
|
18
|
+
constructor(message, code) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.name = "BillingRelationshipError";
|
|
22
|
+
}
|
|
23
|
+
code;
|
|
24
|
+
};
|
|
25
|
+
var BillingRelationship = class extends SmrtObject {
|
|
26
|
+
childTenantId = "";
|
|
27
|
+
resellerTenantId = "";
|
|
28
|
+
billingOwnerMode = "self";
|
|
29
|
+
async validateBeforeSave() {
|
|
30
|
+
await super.validateBeforeSave();
|
|
31
|
+
this.childTenantId = canonicalTenantId(this.childTenantId);
|
|
32
|
+
this.resellerTenantId = canonicalTenantId(this.resellerTenantId);
|
|
33
|
+
assertMode(this.billingOwnerMode);
|
|
34
|
+
if (this.childTenantId === this.resellerTenantId) throw new BillingRelationshipError("A tenant cannot resell to itself.", "CIRCULAR_RELATIONSHIP");
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-users:Tenant"), field({ required: true })], BillingRelationship.prototype, "childTenantId", 2);
|
|
38
|
+
__decorateClass([crossPackageRef("@happyvertical/smrt-users:Tenant"), field({ required: true })], BillingRelationship.prototype, "resellerTenantId", 2);
|
|
39
|
+
BillingRelationship = __decorateClass([smrt({
|
|
40
|
+
tableName: "_smrt_billing_relationships",
|
|
41
|
+
conflictColumns: ["child_tenant_id"],
|
|
42
|
+
api: false,
|
|
43
|
+
cli: false,
|
|
44
|
+
mcp: false
|
|
45
|
+
})], BillingRelationship);
|
|
46
|
+
var BillingRelationshipCollection = class extends SmrtCollection {
|
|
47
|
+
static _itemClass = BillingRelationship;
|
|
48
|
+
};
|
|
49
|
+
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
50
|
+
function assertTenantId(id) {
|
|
51
|
+
if (typeof id !== "string" || !UUID_PATTERN.test(id)) throw new BillingRelationshipError("Tenant IDs must be UUIDs.", "INVALID_TENANT");
|
|
52
|
+
}
|
|
53
|
+
function canonicalTenantId(id) {
|
|
54
|
+
assertTenantId(id);
|
|
55
|
+
return id.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
function assertMode(mode) {
|
|
58
|
+
if (mode !== "self" && mode !== "reseller") throw new BillingRelationshipError("Billing owner mode must be self or reseller.", "INVALID_OWNER_MODE");
|
|
59
|
+
}
|
|
60
|
+
function view(row) {
|
|
61
|
+
const childTenantId = canonicalTenantId(row.childTenantId);
|
|
62
|
+
const resellerTenantId = canonicalTenantId(row.resellerTenantId);
|
|
63
|
+
assertMode(row.billingOwnerMode);
|
|
64
|
+
return {
|
|
65
|
+
childTenantId,
|
|
66
|
+
resellerTenantId,
|
|
67
|
+
billingOwnerMode: row.billingOwnerMode,
|
|
68
|
+
billingOwnerTenantId: row.billingOwnerMode === "reseller" ? resellerTenantId : childTenantId
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
var BillingRelationshipService = class BillingRelationshipService {
|
|
72
|
+
constructor(relationships, options) {
|
|
73
|
+
this.relationships = relationships;
|
|
74
|
+
this.options = options;
|
|
75
|
+
}
|
|
76
|
+
relationships;
|
|
77
|
+
options;
|
|
78
|
+
static async create(options) {
|
|
79
|
+
if (typeof options.tenantExists !== "function") throw new Error("Billing relationships require a tenantExists resolver.");
|
|
80
|
+
const relationships = await BillingRelationshipCollection.create(options);
|
|
81
|
+
if (/^(?:https?|libsql):\/\//iu.test(relationships.db.url ?? "")) throw new Error("Billing relationships require a local SQL or PostgreSQL database with serialized transactions.");
|
|
82
|
+
if (!relationships.db.transaction) throw new Error("Billing relationships require database transactions.");
|
|
83
|
+
return new BillingRelationshipService(relationships, options);
|
|
84
|
+
}
|
|
85
|
+
/** A tenant without a relationship always pays for itself. */
|
|
86
|
+
async resolveBillingOwner(childTenantId) {
|
|
87
|
+
childTenantId = canonicalTenantId(childTenantId);
|
|
88
|
+
const row = await this.relationships.get({ childTenantId });
|
|
89
|
+
await this.assertReadable(childTenantId, row?.resellerTenantId);
|
|
90
|
+
await this.assertExists(childTenantId);
|
|
91
|
+
if (row) await this.assertExists(row.resellerTenantId);
|
|
92
|
+
return row ? view(row).billingOwnerTenantId : childTenantId;
|
|
93
|
+
}
|
|
94
|
+
async getRelationship(childTenantId) {
|
|
95
|
+
childTenantId = canonicalTenantId(childTenantId);
|
|
96
|
+
const row = await this.relationships.get({ childTenantId });
|
|
97
|
+
await this.assertReadable(childTenantId, row?.resellerTenantId);
|
|
98
|
+
await this.assertExists(childTenantId);
|
|
99
|
+
if (row) await this.assertExists(row.resellerTenantId);
|
|
100
|
+
return row ? view(row) : null;
|
|
101
|
+
}
|
|
102
|
+
/** List direct children billed to an owner, with no collection page limit. */
|
|
103
|
+
async listChildrenBilledTo(ownerTenantId) {
|
|
104
|
+
ownerTenantId = canonicalTenantId(ownerTenantId);
|
|
105
|
+
await this.assertReadable(ownerTenantId);
|
|
106
|
+
await this.assertExists(ownerTenantId);
|
|
107
|
+
return (await this.relationships.db.query("SELECT child_tenant_id FROM _smrt_billing_relationships WHERE reseller_tenant_id = ? AND billing_owner_mode = ? ORDER BY child_tenant_id", ownerTenantId, "reseller")).rows.map((row) => {
|
|
108
|
+
return canonicalTenantId(String(row.child_tenant_id));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** Create or replace parentage and its default billing owner in one row. */
|
|
112
|
+
async setRelationship(input) {
|
|
113
|
+
const childTenantId = canonicalTenantId(input.childTenantId);
|
|
114
|
+
const resellerTenantId = canonicalTenantId(input.resellerTenantId);
|
|
115
|
+
const { billingOwnerMode } = input;
|
|
116
|
+
assertMode(billingOwnerMode);
|
|
117
|
+
await this.assertManage(childTenantId, resellerTenantId);
|
|
118
|
+
await this.assertExists(childTenantId);
|
|
119
|
+
await this.assertExists(resellerTenantId);
|
|
120
|
+
if (childTenantId === resellerTenantId) throw new BillingRelationshipError("A tenant cannot resell to itself.", "CIRCULAR_RELATIONSHIP");
|
|
121
|
+
return withEmbeddedWriteTransaction(this.relationships.db, isEmbeddedDatabase(this.relationships.db), async (db) => {
|
|
122
|
+
if (isPostgresDatabase(db)) await db.query("SELECT pg_advisory_xact_lock(hashtext('smrt-billing-relationships'))");
|
|
123
|
+
const relationships = await BillingRelationshipCollection.create({ db });
|
|
124
|
+
await this.assertAcyclic(relationships, childTenantId, resellerTenantId);
|
|
125
|
+
const existing = await relationships.get({ childTenantId });
|
|
126
|
+
if (existing && existing.resellerTenantId !== resellerTenantId) await this.assertManage(childTenantId, existing.resellerTenantId);
|
|
127
|
+
const row = existing ? Object.assign(existing, {
|
|
128
|
+
resellerTenantId,
|
|
129
|
+
billingOwnerMode
|
|
130
|
+
}) : await relationships.create({
|
|
131
|
+
childTenantId,
|
|
132
|
+
resellerTenantId,
|
|
133
|
+
billingOwnerMode
|
|
134
|
+
});
|
|
135
|
+
if (existing) await row.save();
|
|
136
|
+
return view(row);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** Remove reseller parentage. The child becomes self-billed. */
|
|
140
|
+
async clearRelationship(childTenantId) {
|
|
141
|
+
childTenantId = canonicalTenantId(childTenantId);
|
|
142
|
+
await withEmbeddedWriteTransaction(this.relationships.db, isEmbeddedDatabase(this.relationships.db), async (db) => {
|
|
143
|
+
if (isPostgresDatabase(db)) await db.query("SELECT pg_advisory_xact_lock(hashtext('smrt-billing-relationships'))");
|
|
144
|
+
const current = await (await BillingRelationshipCollection.create({ db })).get({ childTenantId });
|
|
145
|
+
await this.assertManage(childTenantId, current?.resellerTenantId);
|
|
146
|
+
if (current?.id) await db.delete("_smrt_billing_relationships", { id: current.id });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async assertAcyclic(relationships, childTenantId, resellerTenantId) {
|
|
150
|
+
const visited = /* @__PURE__ */ new Set([childTenantId]);
|
|
151
|
+
let cursor = resellerTenantId;
|
|
152
|
+
while (cursor) {
|
|
153
|
+
if (visited.has(cursor)) throw new BillingRelationshipError("Reseller relationships cannot form a cycle.", "CIRCULAR_RELATIONSHIP");
|
|
154
|
+
visited.add(cursor);
|
|
155
|
+
const row = await relationships.get({ childTenantId: cursor });
|
|
156
|
+
cursor = row ? canonicalTenantId(row.resellerTenantId) : void 0;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async assertExists(tenantId) {
|
|
160
|
+
assertTenantId(tenantId);
|
|
161
|
+
if (!await this.options.tenantExists(tenantId)) throw new BillingRelationshipError(`Tenant not found: ${tenantId}`, "TENANT_NOT_FOUND");
|
|
162
|
+
}
|
|
163
|
+
async assertReadable(childTenantId, resellerTenantId) {
|
|
164
|
+
if (isSystemContext() || isSuperAdminBypass() || getTenantId()?.toLowerCase() === childTenantId || resellerTenantId && getTenantId()?.toLowerCase() === resellerTenantId || await this.options.authorize?.({
|
|
165
|
+
action: "read",
|
|
166
|
+
childTenantId,
|
|
167
|
+
resellerTenantId
|
|
168
|
+
})) return;
|
|
169
|
+
throw new TenantIsolationError("Billing relationship access denied.");
|
|
170
|
+
}
|
|
171
|
+
async assertManage(childTenantId, resellerTenantId) {
|
|
172
|
+
if (isSystemContext() || isSuperAdminBypass() || await this.options.authorize?.({
|
|
173
|
+
action: "manage",
|
|
174
|
+
childTenantId,
|
|
175
|
+
resellerTenantId
|
|
176
|
+
})) return;
|
|
177
|
+
throw new TenantIsolationError("Billing relationship mutation denied.");
|
|
178
|
+
}
|
|
179
|
+
};
|
|
7
180
|
//#endregion
|
|
8
181
|
//#region src/decorators.ts
|
|
9
182
|
function TenantScoped(options = {}) {
|
|
@@ -63,6 +236,6 @@ function getTenantIdFieldOptions(field) {
|
|
|
63
236
|
return field.__tenancy;
|
|
64
237
|
}
|
|
65
238
|
//#endregion
|
|
66
|
-
export { TenantContext, TenantContextError, TenantIsolationError, TenantScoped, assertTenantContextRequired, assertTenantIsolationViolation, assertTenantReadAllowed, clearTenantScopedRegistry, createCliContext, createExpressMiddleware, createSvelteKitHandle, createTenantInterceptor, createTestTenantContext, disableTenancy, enableTenancy, enterTenantContext, getAllTenantScopedClasses, getCurrentTenant, getTenantId, getTenantIdFieldOptions, getTenantScopedConfig, hasTenantContext, isSuperAdminBypass, isSystemContext, isTenancyEnabled, isTenantIdField, isTenantScopedClass, queryGlobal, queryWithGlobals, registerTenantScopedClass, requireTenant, requireTenantId, resetTenancy, runTenantScopedEntryPoint, setupTestTenancy, tenantId, testTenantIsolation, unregisterTenantScopedClass, withSuperAdminBypass, withSystemContext, withTenant, withTenantGlobalRead, withTenantSync };
|
|
239
|
+
export { BillingRelationship, BillingRelationshipError, BillingRelationshipService, TenantContext, TenantContextError, TenantIsolationError, TenantScoped, assertTenantContextRequired, assertTenantIsolationViolation, assertTenantReadAllowed, clearTenantScopedRegistry, createCliContext, createExpressMiddleware, createSvelteKitHandle, createTenantInterceptor, createTestTenantContext, disableTenancy, enableTenancy, enterTenantContext, getAllTenantScopedClasses, getCurrentTenant, getTenantId, getTenantIdFieldOptions, getTenantScopedConfig, hasTenantContext, isSuperAdminBypass, isSystemContext, isTenancyEnabled, isTenantIdField, isTenantScopedClass, queryGlobal, queryWithGlobals, registerTenantScopedClass, requireTenant, requireTenantId, resetTenancy, runTenantScopedEntryPoint, setupTestTenancy, tenantId, testTenantIsolation, unregisterTenantScopedClass, withSuperAdminBypass, withSystemContext, withTenant, withTenantGlobalRead, withTenantSync };
|
|
67
240
|
|
|
68
241
|
//# sourceMappingURL=index.js.map
|
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"],"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 registerTenantScopedConstructor,\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 // 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 registerTenantScopedConstructor(target, config);\n\n // Support either class-decorator order. This declaration replaces only a\n // provisional field fallback; explicit @smrt and manifest policy retain\n // their documented precedence.\n ObjectRegistry.reconcileTenantScopedConfig(target, {\n mode: config.mode ?? 'required',\n field: config.field ?? 'tenantId',\n autoFilter: config.autoFilter ?? true,\n autoPopulate: config.autoPopulate ?? true,\n allowSuperAdminBypass: config.allowSuperAdminBypass ?? false,\n });\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, ctor) => {\n const fieldOptions: Parameters<\n typeof ObjectRegistry.registerFieldDecorator\n >[2] = {\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 ObjectRegistry.registerFieldDecorator(\n className,\n propertyKey,\n fieldOptions,\n ctor,\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"],"mappings":";;;;;;;;ACgIO,SAAS,aAAa,UAA+B,CAAC,GAAG;CAC9D,QACE,QACA,qBACM;EACN,mCAAmC,QAAQ,gBAAgB;EAG3D,MAAM,SAAsC;GAC1C,MAAM,QAAQ,QAAQ;GACtB,OAAO,QAAQ,SAAS;GACxB,YAAY,QAAQ,cAAc;GAClC,cAAc,QAAQ,gBAAgB;GACtC,uBAAuB,QAAQ,yBAAyB;EAC1D;EAGA,gCAAgC,QAAQ,MAAM;EAK9C,eAAe,4BAA4B,QAAQ;GACjD,MAAM,OAAO,QAAQ;GACrB,OAAO,OAAO,SAAS;GACvB,YAAY,OAAO,cAAc;GACjC,cAAc,OAAO,gBAAgB;GACrC,uBAAuB,OAAO,yBAAyB;EACzD,CAAC;EAGD,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,aAAa,SAAS;GAChC,MAAM,eAEC;IACL,MAAM;IACN,SAAS;IACT,SAAS;IACT,UAAU,KAAK;IACf,UAAU,KAAK;IACf,WAAW;KACT,GAAG;KACH,iBAAiB;IACnB;GACF;GACA,eAAe,uBACb,WACA,aACA,cACA,IACF;EACF,CACF;CACF;AACF;;;ACrJO,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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/__smrt-register__.ts","../src/billing-relationship.ts","../src/decorators.ts","../src/fields.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","/** Reseller parentage and billing ownership, independent of user permissions. */\nimport {\n crossPackageRef,\n field,\n isEmbeddedDatabase,\n isPostgresDatabase,\n type SmrtClassOptions,\n SmrtCollection,\n SmrtObject,\n smrt,\n withEmbeddedWriteTransaction,\n} from '@happyvertical/smrt-core';\nimport {\n getTenantId,\n isSuperAdminBypass,\n isSystemContext,\n TenantIsolationError,\n} from './context.js';\n\nexport type BillingOwnerMode = 'self' | 'reseller';\n\nexport interface BillingRelationshipView {\n childTenantId: string;\n resellerTenantId: string;\n billingOwnerMode: BillingOwnerMode;\n billingOwnerTenantId: string;\n}\n\nexport type BillingRelationshipAction = 'read' | 'manage';\n\nexport interface BillingRelationshipServiceOptions extends SmrtClassOptions {\n /** Resolve tenant IDs against the host tenant directory. */\n tenantExists: (tenantId: string) => Promise<boolean>;\n /** Optional host permission check for access beyond the built-in context rules. */\n authorize?: (request: {\n action: BillingRelationshipAction;\n childTenantId: string;\n resellerTenantId?: string;\n }) => Promise<boolean>;\n}\n\nexport class BillingRelationshipError extends Error {\n constructor(\n message: string,\n public readonly code:\n | 'INVALID_TENANT'\n | 'INVALID_OWNER_MODE'\n | 'TENANT_NOT_FOUND'\n | 'CIRCULAR_RELATIONSHIP',\n ) {\n super(message);\n this.name = 'BillingRelationshipError';\n }\n}\n\n@smrt({\n tableName: '_smrt_billing_relationships',\n conflictColumns: ['child_tenant_id'],\n api: false,\n cli: false,\n mcp: false,\n})\nexport class BillingRelationship extends SmrtObject {\n @crossPackageRef('@happyvertical/smrt-users:Tenant')\n @field({ required: true })\n childTenantId: string = '';\n @crossPackageRef('@happyvertical/smrt-users:Tenant')\n @field({ required: true })\n resellerTenantId: string = '';\n billingOwnerMode: BillingOwnerMode = 'self';\n\n protected async validateBeforeSave(): Promise<void> {\n await super.validateBeforeSave();\n this.childTenantId = canonicalTenantId(this.childTenantId);\n this.resellerTenantId = canonicalTenantId(this.resellerTenantId);\n assertMode(this.billingOwnerMode);\n if (this.childTenantId === this.resellerTenantId) {\n throw new BillingRelationshipError(\n 'A tenant cannot resell to itself.',\n 'CIRCULAR_RELATIONSHIP',\n );\n }\n }\n}\n\nexport class BillingRelationshipCollection extends SmrtCollection<BillingRelationship> {\n static readonly _itemClass = BillingRelationship;\n}\n\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nfunction assertTenantId(id: string): void {\n if (typeof id !== 'string' || !UUID_PATTERN.test(id)) {\n throw new BillingRelationshipError(\n 'Tenant IDs must be UUIDs.',\n 'INVALID_TENANT',\n );\n }\n}\n\nfunction canonicalTenantId(id: string): string {\n assertTenantId(id);\n return id.toLowerCase();\n}\n\nfunction assertMode(mode: BillingOwnerMode): void {\n if (mode !== 'self' && mode !== 'reseller') {\n throw new BillingRelationshipError(\n 'Billing owner mode must be self or reseller.',\n 'INVALID_OWNER_MODE',\n );\n }\n}\n\nfunction view(row: BillingRelationship): BillingRelationshipView {\n const childTenantId = canonicalTenantId(row.childTenantId);\n const resellerTenantId = canonicalTenantId(row.resellerTenantId);\n assertMode(row.billingOwnerMode);\n return {\n childTenantId,\n resellerTenantId,\n billingOwnerMode: row.billingOwnerMode,\n billingOwnerTenantId:\n row.billingOwnerMode === 'reseller' ? resellerTenantId : childTenantId,\n };\n}\n\n/**\n * The one-row-per-child billing graph. The host supplies tenant existence and\n * any additional authorization; absent a host authorizer, only an explicit\n * system context can mutate relationships.\n */\nexport class BillingRelationshipService {\n private constructor(\n private readonly relationships: BillingRelationshipCollection,\n private readonly options: BillingRelationshipServiceOptions,\n ) {}\n\n static async create(\n options: BillingRelationshipServiceOptions,\n ): Promise<BillingRelationshipService> {\n if (typeof options.tenantExists !== 'function') {\n throw new Error('Billing relationships require a tenantExists resolver.');\n }\n const relationships = await BillingRelationshipCollection.create(options);\n if (/^(?:https?|libsql):\\/\\//iu.test(relationships.db.url ?? '')) {\n throw new Error(\n 'Billing relationships require a local SQL or PostgreSQL database with serialized transactions.',\n );\n }\n if (!relationships.db.transaction) {\n throw new Error('Billing relationships require database transactions.');\n }\n return new BillingRelationshipService(relationships, options);\n }\n\n /** A tenant without a relationship always pays for itself. */\n async resolveBillingOwner(childTenantId: string): Promise<string> {\n childTenantId = canonicalTenantId(childTenantId);\n const row = await this.relationships.get({ childTenantId });\n await this.assertReadable(childTenantId, row?.resellerTenantId);\n await this.assertExists(childTenantId);\n if (row) await this.assertExists(row.resellerTenantId);\n return row ? view(row).billingOwnerTenantId : childTenantId;\n }\n\n async getRelationship(\n childTenantId: string,\n ): Promise<BillingRelationshipView | null> {\n childTenantId = canonicalTenantId(childTenantId);\n const row = await this.relationships.get({ childTenantId });\n await this.assertReadable(childTenantId, row?.resellerTenantId);\n await this.assertExists(childTenantId);\n if (row) await this.assertExists(row.resellerTenantId);\n return row ? view(row) : null;\n }\n\n /** List direct children billed to an owner, with no collection page limit. */\n async listChildrenBilledTo(ownerTenantId: string): Promise<string[]> {\n ownerTenantId = canonicalTenantId(ownerTenantId);\n await this.assertReadable(ownerTenantId);\n await this.assertExists(ownerTenantId);\n const result = await this.relationships.db.query(\n 'SELECT child_tenant_id FROM _smrt_billing_relationships WHERE reseller_tenant_id = ? AND billing_owner_mode = ? ORDER BY child_tenant_id',\n ownerTenantId,\n 'reseller',\n );\n return result.rows.map((row) => {\n return canonicalTenantId(String(row.child_tenant_id));\n });\n }\n\n /** Create or replace parentage and its default billing owner in one row. */\n async setRelationship(input: {\n childTenantId: string;\n resellerTenantId: string;\n billingOwnerMode: BillingOwnerMode;\n }): Promise<BillingRelationshipView> {\n const childTenantId = canonicalTenantId(input.childTenantId);\n const resellerTenantId = canonicalTenantId(input.resellerTenantId);\n const { billingOwnerMode } = input;\n assertMode(billingOwnerMode);\n await this.assertManage(childTenantId, resellerTenantId);\n await this.assertExists(childTenantId);\n await this.assertExists(resellerTenantId);\n if (childTenantId === resellerTenantId) {\n throw new BillingRelationshipError(\n 'A tenant cannot resell to itself.',\n 'CIRCULAR_RELATIONSHIP',\n );\n }\n return withEmbeddedWriteTransaction(\n this.relationships.db,\n isEmbeddedDatabase(this.relationships.db),\n async (db) => {\n if (isPostgresDatabase(db)) {\n await db.query(\n \"SELECT pg_advisory_xact_lock(hashtext('smrt-billing-relationships'))\",\n );\n }\n const relationships = await BillingRelationshipCollection.create({\n db,\n });\n await this.assertAcyclic(\n relationships,\n childTenantId,\n resellerTenantId,\n );\n const existing = await relationships.get({ childTenantId });\n if (existing && existing.resellerTenantId !== resellerTenantId) {\n await this.assertManage(childTenantId, existing.resellerTenantId);\n }\n const row = existing\n ? Object.assign(existing, { resellerTenantId, billingOwnerMode })\n : await relationships.create({\n childTenantId,\n resellerTenantId,\n billingOwnerMode,\n });\n if (existing) await row.save();\n return view(row);\n },\n );\n }\n\n /** Remove reseller parentage. The child becomes self-billed. */\n async clearRelationship(childTenantId: string): Promise<void> {\n childTenantId = canonicalTenantId(childTenantId);\n await withEmbeddedWriteTransaction(\n this.relationships.db,\n isEmbeddedDatabase(this.relationships.db),\n async (db) => {\n if (isPostgresDatabase(db)) {\n await db.query(\n \"SELECT pg_advisory_xact_lock(hashtext('smrt-billing-relationships'))\",\n );\n }\n const relationships = await BillingRelationshipCollection.create({\n db,\n });\n const current = await relationships.get({ childTenantId });\n await this.assertManage(childTenantId, current?.resellerTenantId);\n if (current?.id) {\n // SmrtObject.delete() starts its own cascade transaction, which\n // DuckDB cannot nest. This row has no dependents, so delete it on\n // the already-bound transaction handle.\n await db.delete('_smrt_billing_relationships', { id: current.id });\n }\n },\n );\n }\n\n private async assertAcyclic(\n relationships: BillingRelationshipCollection,\n childTenantId: string,\n resellerTenantId: string,\n ): Promise<void> {\n const visited = new Set([childTenantId]);\n let cursor: string | undefined = resellerTenantId;\n while (cursor) {\n if (visited.has(cursor)) {\n throw new BillingRelationshipError(\n 'Reseller relationships cannot form a cycle.',\n 'CIRCULAR_RELATIONSHIP',\n );\n }\n visited.add(cursor);\n const row = await relationships.get({ childTenantId: cursor });\n cursor = row ? canonicalTenantId(row.resellerTenantId) : undefined;\n }\n }\n\n private async assertExists(tenantId: string): Promise<void> {\n assertTenantId(tenantId);\n if (!(await this.options.tenantExists(tenantId))) {\n throw new BillingRelationshipError(\n `Tenant not found: ${tenantId}`,\n 'TENANT_NOT_FOUND',\n );\n }\n }\n\n private async assertReadable(\n childTenantId: string,\n resellerTenantId?: string,\n ): Promise<void> {\n if (\n isSystemContext() ||\n isSuperAdminBypass() ||\n getTenantId()?.toLowerCase() === childTenantId ||\n (resellerTenantId && getTenantId()?.toLowerCase() === resellerTenantId) ||\n (await this.options.authorize?.({\n action: 'read',\n childTenantId,\n resellerTenantId,\n }))\n ) {\n return;\n }\n throw new TenantIsolationError('Billing relationship access denied.');\n }\n\n private async assertManage(\n childTenantId: string,\n resellerTenantId?: string,\n ): Promise<void> {\n if (\n isSystemContext() ||\n isSuperAdminBypass() ||\n (await this.options.authorize?.({\n action: 'manage',\n childTenantId,\n resellerTenantId,\n }))\n ) {\n return;\n }\n throw new TenantIsolationError('Billing relationship mutation denied.');\n }\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 registerTenantScopedConstructor,\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 // 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 registerTenantScopedConstructor(target, config);\n\n // Support either class-decorator order. This declaration replaces only a\n // provisional field fallback; explicit @smrt and manifest policy retain\n // their documented precedence.\n ObjectRegistry.reconcileTenantScopedConfig(target, {\n mode: config.mode ?? 'required',\n field: config.field ?? 'tenantId',\n autoFilter: config.autoFilter ?? true,\n autoPopulate: config.autoPopulate ?? true,\n allowSuperAdminBypass: config.allowSuperAdminBypass ?? false,\n });\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, ctor) => {\n const fieldOptions: Parameters<\n typeof ObjectRegistry.registerFieldDecorator\n >[2] = {\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 ObjectRegistry.registerFieldDecorator(\n className,\n propertyKey,\n fieldOptions,\n ctor,\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"],"mappings":"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"}
|
package/dist/manifest.json
CHANGED
|
@@ -2,8 +2,243 @@
|
|
|
2
2
|
"version": "1.0.0",
|
|
3
3
|
"timestamp": 0,
|
|
4
4
|
"packageName": "@happyvertical/smrt-tenancy",
|
|
5
|
-
"packageVersion": "0.51.
|
|
6
|
-
"objects": {
|
|
5
|
+
"packageVersion": "0.51.31",
|
|
6
|
+
"objects": {
|
|
7
|
+
"@happyvertical/smrt-tenancy:BillingRelationship": {
|
|
8
|
+
"name": "billingrelationship",
|
|
9
|
+
"className": "BillingRelationship",
|
|
10
|
+
"qualifiedName": "@happyvertical/smrt-tenancy:BillingRelationship",
|
|
11
|
+
"collection": "billingrelationships",
|
|
12
|
+
"filePath": "/home/runner/work/smrt/smrt/packages/tenancy/src/billing-relationship.ts",
|
|
13
|
+
"packageName": "@happyvertical/smrt-tenancy",
|
|
14
|
+
"fields": {
|
|
15
|
+
"childTenantId": {
|
|
16
|
+
"type": "crossPackageRef",
|
|
17
|
+
"required": true,
|
|
18
|
+
"related": "@happyvertical/smrt-users:Tenant",
|
|
19
|
+
"_meta": {
|
|
20
|
+
"required": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"resellerTenantId": {
|
|
24
|
+
"type": "crossPackageRef",
|
|
25
|
+
"required": true,
|
|
26
|
+
"related": "@happyvertical/smrt-users:Tenant",
|
|
27
|
+
"_meta": {
|
|
28
|
+
"required": true
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"billingOwnerMode": {
|
|
32
|
+
"type": "text",
|
|
33
|
+
"required": false,
|
|
34
|
+
"default": "self"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"methods": {},
|
|
38
|
+
"decoratorConfig": {
|
|
39
|
+
"tableName": "_smrt_billing_relationships",
|
|
40
|
+
"conflictColumns": [
|
|
41
|
+
"child_tenant_id"
|
|
42
|
+
],
|
|
43
|
+
"api": false,
|
|
44
|
+
"cli": false,
|
|
45
|
+
"mcp": false
|
|
46
|
+
},
|
|
47
|
+
"extends": "SmrtObject",
|
|
48
|
+
"exportName": "BillingRelationship",
|
|
49
|
+
"collectionExportName": "BillingRelationshipCollection",
|
|
50
|
+
"validationRules": [
|
|
51
|
+
{
|
|
52
|
+
"field": "childTenantId",
|
|
53
|
+
"rule": "required",
|
|
54
|
+
"fieldType": "crossPackageRef"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"field": "resellerTenantId",
|
|
58
|
+
"rule": "required",
|
|
59
|
+
"fieldType": "crossPackageRef"
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"schema": {
|
|
63
|
+
"tableName": "_smrt_billing_relationships",
|
|
64
|
+
"ddl": "CREATE TABLE IF NOT EXISTS \"_smrt_billing_relationships\" (\n \"id\" UUID PRIMARY KEY NOT NULL,\n \"slug\" TEXT NOT NULL,\n \"context\" TEXT NOT NULL DEFAULT '',\n \"created_at\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\n \"updated_at\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\n \"child_tenant_id\" UUID NOT NULL,\n \"reseller_tenant_id\" UUID NOT NULL,\n \"billing_owner_mode\" TEXT DEFAULT 'self'\n);",
|
|
65
|
+
"columns": {
|
|
66
|
+
"id": {
|
|
67
|
+
"type": "UUID",
|
|
68
|
+
"primaryKey": true,
|
|
69
|
+
"referenceKind": "id",
|
|
70
|
+
"notNull": true
|
|
71
|
+
},
|
|
72
|
+
"slug": {
|
|
73
|
+
"type": "TEXT",
|
|
74
|
+
"notNull": true
|
|
75
|
+
},
|
|
76
|
+
"context": {
|
|
77
|
+
"type": "TEXT",
|
|
78
|
+
"notNull": true,
|
|
79
|
+
"default": ""
|
|
80
|
+
},
|
|
81
|
+
"created_at": {
|
|
82
|
+
"type": "TIMESTAMP",
|
|
83
|
+
"notNull": true,
|
|
84
|
+
"default": "current_timestamp"
|
|
85
|
+
},
|
|
86
|
+
"updated_at": {
|
|
87
|
+
"type": "TIMESTAMP",
|
|
88
|
+
"notNull": true,
|
|
89
|
+
"default": "current_timestamp"
|
|
90
|
+
},
|
|
91
|
+
"child_tenant_id": {
|
|
92
|
+
"type": "UUID",
|
|
93
|
+
"referenceKind": "crossPackageRef",
|
|
94
|
+
"notNull": true,
|
|
95
|
+
"unique": false
|
|
96
|
+
},
|
|
97
|
+
"reseller_tenant_id": {
|
|
98
|
+
"type": "UUID",
|
|
99
|
+
"referenceKind": "crossPackageRef",
|
|
100
|
+
"notNull": true,
|
|
101
|
+
"unique": false
|
|
102
|
+
},
|
|
103
|
+
"billing_owner_mode": {
|
|
104
|
+
"type": "TEXT",
|
|
105
|
+
"notNull": false,
|
|
106
|
+
"unique": false,
|
|
107
|
+
"default": "self"
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"indexes": [
|
|
111
|
+
{
|
|
112
|
+
"name": "_smrt_billing_relationships_child_tenant_id_idx",
|
|
113
|
+
"columns": [
|
|
114
|
+
"child_tenant_id"
|
|
115
|
+
],
|
|
116
|
+
"unique": true
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"name": "_smrt_billing_relationships_slug_context_idx",
|
|
120
|
+
"columns": [
|
|
121
|
+
"slug",
|
|
122
|
+
"context"
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"name": "_smrt_billing_relationships_created_at_idx",
|
|
127
|
+
"columns": [
|
|
128
|
+
"created_at"
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"name": "_smrt_billing_relationships_reseller_tenant_id_idx",
|
|
133
|
+
"columns": [
|
|
134
|
+
"reseller_tenant_id"
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
"version": "4d7f410a"
|
|
139
|
+
},
|
|
140
|
+
"importPath": "@happyvertical/smrt-tenancy"
|
|
141
|
+
},
|
|
142
|
+
"@happyvertical/smrt-tenancy:BillingRelationshipCollection": {
|
|
143
|
+
"name": "billingrelationshipcollection",
|
|
144
|
+
"className": "BillingRelationshipCollection",
|
|
145
|
+
"qualifiedName": "@happyvertical/smrt-tenancy:BillingRelationshipCollection",
|
|
146
|
+
"collection": "billingrelationships",
|
|
147
|
+
"filePath": "/home/runner/work/smrt/smrt/packages/tenancy/src/billing-relationship.ts",
|
|
148
|
+
"packageName": "@happyvertical/smrt-tenancy",
|
|
149
|
+
"fields": {},
|
|
150
|
+
"methods": {},
|
|
151
|
+
"decoratorConfig": {
|
|
152
|
+
"tableName": "_smrt_billing_relationships",
|
|
153
|
+
"api": false,
|
|
154
|
+
"mcp": false,
|
|
155
|
+
"cli": false
|
|
156
|
+
},
|
|
157
|
+
"extends": "SmrtCollection",
|
|
158
|
+
"extendsTypeArg": "BillingRelationship",
|
|
159
|
+
"exportName": "BillingRelationshipCollection",
|
|
160
|
+
"collectionExportName": "BillingRelationshipCollectionCollection",
|
|
161
|
+
"schema": {
|
|
162
|
+
"tableName": "_smrt_billing_relationships",
|
|
163
|
+
"ddl": "CREATE TABLE IF NOT EXISTS \"_smrt_billing_relationships\" (\n \"id\" UUID PRIMARY KEY NOT NULL,\n \"slug\" TEXT NOT NULL,\n \"context\" TEXT NOT NULL DEFAULT '',\n \"created_at\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\n \"updated_at\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\n \"child_tenant_id\" UUID NOT NULL,\n \"reseller_tenant_id\" UUID NOT NULL,\n \"billing_owner_mode\" TEXT DEFAULT 'self'\n);",
|
|
164
|
+
"columns": {
|
|
165
|
+
"id": {
|
|
166
|
+
"type": "UUID",
|
|
167
|
+
"primaryKey": true,
|
|
168
|
+
"referenceKind": "id",
|
|
169
|
+
"notNull": true
|
|
170
|
+
},
|
|
171
|
+
"slug": {
|
|
172
|
+
"type": "TEXT",
|
|
173
|
+
"notNull": true
|
|
174
|
+
},
|
|
175
|
+
"context": {
|
|
176
|
+
"type": "TEXT",
|
|
177
|
+
"notNull": true,
|
|
178
|
+
"default": ""
|
|
179
|
+
},
|
|
180
|
+
"created_at": {
|
|
181
|
+
"type": "TIMESTAMP",
|
|
182
|
+
"notNull": true,
|
|
183
|
+
"default": "current_timestamp"
|
|
184
|
+
},
|
|
185
|
+
"updated_at": {
|
|
186
|
+
"type": "TIMESTAMP",
|
|
187
|
+
"notNull": true,
|
|
188
|
+
"default": "current_timestamp"
|
|
189
|
+
},
|
|
190
|
+
"child_tenant_id": {
|
|
191
|
+
"type": "UUID",
|
|
192
|
+
"referenceKind": "crossPackageRef",
|
|
193
|
+
"notNull": true,
|
|
194
|
+
"unique": false
|
|
195
|
+
},
|
|
196
|
+
"reseller_tenant_id": {
|
|
197
|
+
"type": "UUID",
|
|
198
|
+
"referenceKind": "crossPackageRef",
|
|
199
|
+
"notNull": true,
|
|
200
|
+
"unique": false
|
|
201
|
+
},
|
|
202
|
+
"billing_owner_mode": {
|
|
203
|
+
"type": "TEXT",
|
|
204
|
+
"notNull": false,
|
|
205
|
+
"unique": false,
|
|
206
|
+
"default": "self"
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
"indexes": [
|
|
210
|
+
{
|
|
211
|
+
"name": "_smrt_billing_relationships_child_tenant_id_idx",
|
|
212
|
+
"columns": [
|
|
213
|
+
"child_tenant_id"
|
|
214
|
+
],
|
|
215
|
+
"unique": true
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"name": "_smrt_billing_relationships_slug_context_idx",
|
|
219
|
+
"columns": [
|
|
220
|
+
"slug",
|
|
221
|
+
"context"
|
|
222
|
+
]
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"name": "_smrt_billing_relationships_created_at_idx",
|
|
226
|
+
"columns": [
|
|
227
|
+
"created_at"
|
|
228
|
+
]
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"name": "_smrt_billing_relationships_reseller_tenant_id_idx",
|
|
232
|
+
"columns": [
|
|
233
|
+
"reseller_tenant_id"
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
],
|
|
237
|
+
"version": "4d7f410a"
|
|
238
|
+
},
|
|
239
|
+
"importPath": "@happyvertical/smrt-tenancy"
|
|
240
|
+
}
|
|
241
|
+
},
|
|
7
242
|
"moduleType": "smrt",
|
|
8
243
|
"smrtDependencies": [
|
|
9
244
|
"@happyvertical/smrt-core"
|
package/dist/smrt-knowledge.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"sensitiveFieldsExcluded": true,
|
|
4
4
|
"generatedAt": "1970-01-01T00:00:00.000Z",
|
|
5
5
|
"packageName": "@happyvertical/smrt-tenancy",
|
|
6
|
-
"packageVersion": "0.51.
|
|
6
|
+
"packageVersion": "0.51.31",
|
|
7
7
|
"sourceManifestPath": "dist/manifest.json",
|
|
8
8
|
"agentDocPath": "AGENTS.md",
|
|
9
9
|
"sourceHashes": {
|
|
10
|
-
"manifest": "
|
|
11
|
-
"packageJson": "
|
|
12
|
-
"agents": "
|
|
10
|
+
"manifest": "75157032de40738bcaf452e8a142cbda6bdd3fb006af04ab3ac0863dab1f7ab9",
|
|
11
|
+
"packageJson": "7730fb98467d397eb13aa8a14a42409f9ac7108a29d35da6719dbcfb51473bbd",
|
|
12
|
+
"agents": "7aaa00d7fb58fd1d31bafcbeeea9aace247a00c1332ad943e101023617b9aa93"
|
|
13
13
|
},
|
|
14
14
|
"exports": [
|
|
15
15
|
".",
|
|
@@ -59,18 +59,95 @@
|
|
|
59
59
|
"tenancy"
|
|
60
60
|
],
|
|
61
61
|
"risks": [
|
|
62
|
+
"cross-package-refs:2",
|
|
62
63
|
"sensitive-fields-excluded"
|
|
63
64
|
],
|
|
64
|
-
"objects": [
|
|
65
|
+
"objects": [
|
|
66
|
+
{
|
|
67
|
+
"name": "BillingRelationship",
|
|
68
|
+
"qualifiedName": "@happyvertical/smrt-tenancy:BillingRelationship",
|
|
69
|
+
"collection": "billingrelationships",
|
|
70
|
+
"tableName": "_smrt_billing_relationships",
|
|
71
|
+
"packageName": "@happyvertical/smrt-tenancy",
|
|
72
|
+
"extends": "SmrtObject",
|
|
73
|
+
"fields": [
|
|
74
|
+
{
|
|
75
|
+
"name": "childTenantId",
|
|
76
|
+
"type": "crossPackageRef",
|
|
77
|
+
"required": true,
|
|
78
|
+
"related": "@happyvertical/smrt-users:Tenant",
|
|
79
|
+
"columnType": "UUID"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"name": "resellerTenantId",
|
|
83
|
+
"type": "crossPackageRef",
|
|
84
|
+
"required": true,
|
|
85
|
+
"related": "@happyvertical/smrt-users:Tenant",
|
|
86
|
+
"columnType": "UUID"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "billingOwnerMode",
|
|
90
|
+
"type": "text",
|
|
91
|
+
"required": false,
|
|
92
|
+
"columnType": "TEXT",
|
|
93
|
+
"default": "self"
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"relationships": [
|
|
97
|
+
{
|
|
98
|
+
"name": "childTenantId",
|
|
99
|
+
"type": "crossPackageRef",
|
|
100
|
+
"required": true,
|
|
101
|
+
"related": "@happyvertical/smrt-users:Tenant",
|
|
102
|
+
"columnType": "UUID"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "resellerTenantId",
|
|
106
|
+
"type": "crossPackageRef",
|
|
107
|
+
"required": true,
|
|
108
|
+
"related": "@happyvertical/smrt-users:Tenant",
|
|
109
|
+
"columnType": "UUID"
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
"methods": [],
|
|
113
|
+
"conflictColumns": [
|
|
114
|
+
"child_tenant_id"
|
|
115
|
+
],
|
|
116
|
+
"surfaces": [],
|
|
117
|
+
"relationshipFeatures": [
|
|
118
|
+
"crossPackageRef",
|
|
119
|
+
"uuidColumns"
|
|
120
|
+
],
|
|
121
|
+
"tags": [],
|
|
122
|
+
"risks": []
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "BillingRelationshipCollection",
|
|
126
|
+
"qualifiedName": "@happyvertical/smrt-tenancy:BillingRelationshipCollection",
|
|
127
|
+
"collection": "billingrelationships",
|
|
128
|
+
"tableName": "_smrt_billing_relationships",
|
|
129
|
+
"packageName": "@happyvertical/smrt-tenancy",
|
|
130
|
+
"extends": "SmrtCollection",
|
|
131
|
+
"fields": [],
|
|
132
|
+
"relationships": [],
|
|
133
|
+
"methods": [],
|
|
134
|
+
"surfaces": [],
|
|
135
|
+
"relationshipFeatures": [
|
|
136
|
+
"uuidColumns"
|
|
137
|
+
],
|
|
138
|
+
"tags": [],
|
|
139
|
+
"risks": []
|
|
140
|
+
}
|
|
141
|
+
],
|
|
65
142
|
"surfaces": [],
|
|
66
143
|
"prompts": [],
|
|
67
144
|
"relationshipsV2": {
|
|
68
145
|
"foreignKeyFields": 0,
|
|
69
|
-
"crossPackageRefFields":
|
|
146
|
+
"crossPackageRefFields": 2,
|
|
70
147
|
"junctionCollections": 0,
|
|
71
148
|
"hierarchicalObjects": 0,
|
|
72
149
|
"polymorphicAssociations": 0,
|
|
73
|
-
"uuidColumns":
|
|
150
|
+
"uuidColumns": 6
|
|
74
151
|
},
|
|
75
|
-
"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 — resolves string lookups via core's `resolveGetStringFilter()` (UUID → `{ id }`, else `{ slug, context: '' }`) and adds the tenant predicate (#2365) |\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## Read-Path Coverage (#2365)\n\nTenant scoping is a whole-path property — every read path is interceptor-aware,\nnot only collection list/get:\n\n- **Get-by-slug**: `collection.get('<slug>')` works under a tenant context. The\n interceptor resolves string filters with core's `resolveGetStringFilter()`\n instead of assuming they are ids. Any custom `beforeGet` interceptor that\n rewrites a string filter must do the same.\n- **Hydration and identity**: `new Model({ id | slug }).initialize()`,\n `loadFromId()`, `loadFromSlug()`, `getSavedId()` and `getId()` run their\n filters through the `beforeGet` pipeline, so constructor hydration cannot\n read another tenant's row and `getId()` can never adopt another tenant's\n same-slug row id (which would steer a later `save()` onto the foreign row).\n Required-mode classes fail closed (`TenantContextError`) when hydrated\n outside a tenant context; `withSystemContext()` / super-admin bypass remain\n the explicit cross-tenant paths.\n- **Vector search**: `semanticSearch()` / `findSimilarToEmbedding()` restrict\n candidates to the tenant's rows BEFORE top-K ranking (the tenant predicate is\n resolved through the `beforeList` pipeline), so results are never starved by\n — and similarity ranks never leak — other tenants' content.\n- **Collection memory**: `remember()`/`recall()`/`recallAll()`/`forget()` on a\n tenant-scoped collection key `_smrt_contexts.owner_id` per tenant\n (`__collection__:<tenantId>`) under an active tenant context. Isolation is\n strict: tenant-keyed memory never falls back to the shared `__collection__`\n key, and memory learned outside a tenant context is invisible inside one.\n Two edge semantics to know: under `withSuperAdminBypass()` reads skip\n filtering but memory still keys to the active tenant (scoped tighter, not a\n leak), and an empty-string tenant id resolves to the shared key (an\n empty-string tenant is a misconfiguration — real tenant ids are uuids).\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\nThe exported `registerTenantScopedClass()` also accepts a simple or qualified\nstring selector for third-party classes and test doubles. A simple selector is\nbound to the one exact core constructor when that name is uniquely resolvable;\nit may be registered before core. Once bound, a later same-name package peer\ndoes not inherit its policy. If two core classes exist before the simple\nselector can bind, operations fail closed until the caller registers an exact\nqualified name (for example, `@package/name:Doc`). No lookup strips a namespace\nto guess an owner. If core clears a bound constructor and reuses its qualified\nname, unregister and register the selector again; it will not silently transfer\npolicy to the replacement constructor.\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- **Natural keys are per tenant (smrt#2360)**: a tenant-scoped class with no explicit `conflictColumns` upserts on, and indexes, `(tenant_id, slug, context[, _meta_type])` — `save()` from tenant B with tenant A's slug is a second row, never an overwrite; within a tenant the natural key still dedups; NULL-tenant (`optional` mode, no context) rows dedup among themselves through the SDK's null-aware upsert but not through the index (NULLs are distinct), so raw SQL `ON CONFLICT (slug, context…)` on such a table no longer binds — use `WHERE NOT EXISTS`, and on PostgreSQL an advisory lock, as `ProfileTypeCollection.getOrCreateGlobalBySlug()` does. Core resolves tenant schema policy in order: explicit `@smrt`, manifest (including an omitted `tenantScoped`), exact-constructor `@TenantScoped()` reconciliation, then marked-field fallback; it never reads the standalone tenancy registry. This keeps schema and upsert behavior aligned before `enableTenancy()` runs. Rollout: deploy the code and `smrt db:migrate` together (neither version's create works against the other's index), and backfill `tenant_id` on legacy NULL-tenant rows first — a tenant-context save no longer adopts a `(NULL, slug)` row, it inserts beside it and that tenant stops seeing the legacy one (details in `packages/core/agents/schema-paths.md`).\n- **Manifest/runtime mismatch fails closed (smrt#2763)**: if a cached manifest omits or sets `tenantScoped: false` while the exact runtime constructor carries `@TenantScoped()`, registration is rejected; regenerate the manifest. A rejected late manifest or conflicting promotion preserves a previously valid scoped registration. Applying `@TenantScoped()` to an already-global manifest registration that omits or disables tenancy instead marks that class unavailable to conflict-key, schema, and tenancy interceptor operations until a corrected manifest explicitly declares tenancy. This prevents a global unique key from conflicting with tenant-enforced reads or writes.\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"
|
|
152
|
+
"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## Billing relationships (#3058)\n\n`src/billing-relationship.ts` owns a separate reseller parent graph. It never\nupdates `smrt-users` `Tenant.parentTenantId` or tenant-scoped records. One row\nper child stores the reseller and `self`/`reseller` owner mode; owner queries\nderive one tenant ID. Mutations go through `BillingRelationshipService`, which\nserializes graph writes and rejects cycles. The host supplies tenant existence\nand optional authorization; no generated write surface is exposed. The model\nstays a root export because generated consumer registration imports every\npublic non-collection manifest object from the root; withholding it breaks\nevery dependent's `register.js` (publish dry run, smrt#3074). The collection\nstays unexported, as in smrt-chat. See the README for setup, access rules, and\nmigration guidance.\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 — resolves string lookups via core's `resolveGetStringFilter()` (UUID → `{ id }`, else `{ slug, context: '' }`) and adds the tenant predicate (#2365) |\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## Read-Path Coverage (#2365)\n\nTenant scoping is a whole-path property — every read path is interceptor-aware,\nnot only collection list/get:\n\n- **Get-by-slug**: `collection.get('<slug>')` works under a tenant context. The\n interceptor resolves string filters with core's `resolveGetStringFilter()`\n instead of assuming they are ids. Any custom `beforeGet` interceptor that\n rewrites a string filter must do the same.\n- **Hydration and identity**: `new Model({ id | slug }).initialize()`,\n `loadFromId()`, `loadFromSlug()`, `getSavedId()` and `getId()` run their\n filters through the `beforeGet` pipeline, so constructor hydration cannot\n read another tenant's row and `getId()` can never adopt another tenant's\n same-slug row id (which would steer a later `save()` onto the foreign row).\n Required-mode classes fail closed (`TenantContextError`) when hydrated\n outside a tenant context; `withSystemContext()` / super-admin bypass remain\n the explicit cross-tenant paths.\n- **Vector search**: `semanticSearch()` / `findSimilarToEmbedding()` restrict\n candidates to the tenant's rows BEFORE top-K ranking (the tenant predicate is\n resolved through the `beforeList` pipeline), so results are never starved by\n — and similarity ranks never leak — other tenants' content.\n- **Collection memory**: `remember()`/`recall()`/`recallAll()`/`forget()` on a\n tenant-scoped collection key `_smrt_contexts.owner_id` per tenant\n (`__collection__:<tenantId>`) under an active tenant context. Isolation is\n strict: tenant-keyed memory never falls back to the shared `__collection__`\n key, and memory learned outside a tenant context is invisible inside one.\n Two edge semantics to know: under `withSuperAdminBypass()` reads skip\n filtering but memory still keys to the active tenant (scoped tighter, not a\n leak), and an empty-string tenant id resolves to the shared key (an\n empty-string tenant is a misconfiguration — real tenant ids are uuids).\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\nThe exported `registerTenantScopedClass()` also accepts a simple or qualified\nstring selector for third-party classes and test doubles. A simple selector is\nbound to the one exact core constructor when that name is uniquely resolvable;\nit may be registered before core. Once bound, a later same-name package peer\ndoes not inherit its policy. If two core classes exist before the simple\nselector can bind, operations fail closed until the caller registers an exact\nqualified name (for example, `@package/name:Doc`). No lookup strips a namespace\nto guess an owner. If core clears a bound constructor and reuses its qualified\nname, unregister and register the selector again; it will not silently transfer\npolicy to the replacement constructor.\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- **Natural keys are per tenant (smrt#2360)**: a tenant-scoped class with no explicit `conflictColumns` upserts on, and indexes, `(tenant_id, slug, context[, _meta_type])` — `save()` from tenant B with tenant A's slug is a second row, never an overwrite; within a tenant the natural key still dedups; NULL-tenant (`optional` mode, no context) rows dedup among themselves through the SDK's null-aware upsert but not through the index (NULLs are distinct), so raw SQL `ON CONFLICT (slug, context…)` on such a table no longer binds — use `WHERE NOT EXISTS`, and on PostgreSQL an advisory lock, as `ProfileTypeCollection.getOrCreateGlobalBySlug()` does. Core resolves tenant schema policy in order: explicit `@smrt`, manifest (including an omitted `tenantScoped`), exact-constructor `@TenantScoped()` reconciliation, then marked-field fallback; it never reads the standalone tenancy registry. This keeps schema and upsert behavior aligned before `enableTenancy()` runs. Rollout: deploy the code and `smrt db:migrate` together (neither version's create works against the other's index), and backfill `tenant_id` on legacy NULL-tenant rows first — a tenant-context save no longer adopts a `(NULL, slug)` row, it inserts beside it and that tenant stops seeing the legacy one (details in `packages/core/agents/schema-paths.md`).\n- **Manifest/runtime mismatch fails closed (smrt#2763)**: if a cached manifest omits or sets `tenantScoped: false` while the exact runtime constructor carries `@TenantScoped()`, registration is rejected; regenerate the manifest. A rejected late manifest or conflicting promotion preserves a previously valid scoped registration. Applying `@TenantScoped()` to an already-global manifest registration that omits or disables tenancy instead marks that class unavailable to conflict-key, schema, and tenancy interceptor operations until a corrected manifest explicitly declares tenancy. This prevents a global unique key from conflicting with tenant-enforced reads or writes.\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"
|
|
76
153
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-tenancy",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.31",
|
|
4
4
|
"smrtJsdoc": "strict",
|
|
5
5
|
"description": "Production-ready multi-tenancy framework for SMRT with automatic tenant isolation and enforcement",
|
|
6
6
|
"type": "module",
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@happyvertical/logger": "^0.
|
|
46
|
-
"@happyvertical/smrt-core": "0.51.
|
|
47
|
-
"@happyvertical/smrt-types": "0.51.
|
|
48
|
-
"@happyvertical/smrt-ui": "0.51.
|
|
49
|
-
"@happyvertical/sql": "^0.
|
|
50
|
-
"@happyvertical/utils": "^0.
|
|
45
|
+
"@happyvertical/logger": "^0.94.1",
|
|
46
|
+
"@happyvertical/smrt-core": "0.51.31",
|
|
47
|
+
"@happyvertical/smrt-types": "0.51.31",
|
|
48
|
+
"@happyvertical/smrt-ui": "0.51.31",
|
|
49
|
+
"@happyvertical/sql": "^0.94.1",
|
|
50
|
+
"@happyvertical/utils": "^0.94.1"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"svelte": "^5.57.1"
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@happyvertical/smrt-vitest": "0.51.
|
|
61
|
+
"@happyvertical/smrt-vitest": "0.51.31",
|
|
62
62
|
"@sveltejs/package": "^2.5.8",
|
|
63
63
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
64
64
|
"@types/node": "24.13.6",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"dev": "npm run build:watch",
|
|
95
95
|
"test": "vitest run",
|
|
96
96
|
"test:watch": "vitest",
|
|
97
|
+
"test:postgres": "node ../../scripts/run-with-ci-postgres.mjs -- pnpm exec vitest run src/__tests__/billing-relationship-postgres.test.ts",
|
|
97
98
|
"check": "pnpm exec svelte-check --tsconfig ./tsconfig.svelte.json",
|
|
98
99
|
"typecheck": "tsc --noEmit && node ../../scripts/svelte-check-a11y.mjs --tsconfig ./tsconfig.svelte.json",
|
|
99
100
|
"verify:pack": "node ../../scripts/verify-package-types-exports.js ."
|