@happyvertical/smrt-tenancy 0.30.0
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 +71 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +122 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/adapters/cli.d.ts +178 -0
- package/dist/adapters/cli.d.ts.map +1 -0
- package/dist/adapters/express.d.ts +115 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/index.d.ts +22 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +7 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/sveltekit.d.ts +123 -0
- package/dist/adapters/sveltekit.d.ts.map +1 -0
- package/dist/chunks/context-B5CKsmMi.js +190 -0
- package/dist/chunks/context-B5CKsmMi.js.map +1 -0
- package/dist/chunks/sveltekit-9eRH1RLw.js +153 -0
- package/dist/chunks/sveltekit-9eRH1RLw.js.map +1 -0
- package/dist/chunks/testing-C_tV23JW.js +487 -0
- package/dist/chunks/testing-C_tV23JW.js.map +1 -0
- package/dist/context.d.ts +435 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/decorators.d.ts +126 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/enabled-state.d.ts +25 -0
- package/dist/enabled-state.d.ts.map +1 -0
- package/dist/entry-point.d.ts +83 -0
- package/dist/entry-point.d.ts.map +1 -0
- package/dist/fields.d.ts +104 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptor.d.ts +156 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/manifest.json +11 -0
- package/dist/playground.d.ts +2 -0
- package/dist/playground.d.ts.map +1 -0
- package/dist/playground.js +80 -0
- package/dist/playground.js.map +1 -0
- package/dist/registry.d.ts +145 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +65 -0
- package/dist/svelte/components/TenantCard.svelte +272 -0
- package/dist/svelte/components/TenantCard.svelte.d.ts +18 -0
- package/dist/svelte/components/TenantCard.svelte.d.ts.map +1 -0
- package/dist/svelte/components/TenantSwitcher.svelte +68 -0
- package/dist/svelte/components/TenantSwitcher.svelte.d.ts +11 -0
- package/dist/svelte/components/TenantSwitcher.svelte.d.ts.map +1 -0
- package/dist/svelte/i18n.d.ts +5 -0
- package/dist/svelte/i18n.d.ts.map +1 -0
- package/dist/svelte/i18n.js +9 -0
- package/dist/svelte/index.d.ts +15 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +19 -0
- package/dist/svelte/playground.d.ts +70 -0
- package/dist/svelte/playground.d.ts.map +1 -0
- package/dist/svelte/playground.js +75 -0
- package/dist/testing.d.ts +145 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +11 -0
- package/dist/testing.js.map +1 -0
- package/dist/ui.d.ts +21 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +33 -0
- package/dist/ui.js.map +1 -0
- package/package.json +99 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { h as hasTenantContext, d as isSystemContext, j as withSystemContext, k as withTenant, a as TenantContextError, i as isSuperAdminBypass, g as getCurrentTenant, b as TenantIsolationError, c as getTenantId } from "./context-B5CKsmMi.js";
|
|
2
|
+
import { createLogger } from "@happyvertical/logger";
|
|
3
|
+
import { ObjectRegistry, GlobalInterceptors, setDispatchTenantResolver, setTenantEntryPointRunner } from "@happyvertical/smrt-core";
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
mode: "required",
|
|
6
|
+
field: "tenantId",
|
|
7
|
+
autoFilter: true,
|
|
8
|
+
autoPopulate: true,
|
|
9
|
+
allowSuperAdminBypass: false
|
|
10
|
+
};
|
|
11
|
+
const tenantScopedClasses = /* @__PURE__ */ new Map();
|
|
12
|
+
function registerTenantScopedClass(className, config = {}) {
|
|
13
|
+
tenantScopedClasses.set(className, {
|
|
14
|
+
...DEFAULT_CONFIG,
|
|
15
|
+
...config
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function unregisterTenantScopedClass(className) {
|
|
19
|
+
tenantScopedClasses.delete(className);
|
|
20
|
+
}
|
|
21
|
+
function isTenantScopedClass(className) {
|
|
22
|
+
if (tenantScopedClasses.has(className)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return ObjectRegistry.isTenantScoped(className);
|
|
26
|
+
}
|
|
27
|
+
function getTenantScopedConfig(className) {
|
|
28
|
+
const localConfig = tenantScopedClasses.get(className);
|
|
29
|
+
if (localConfig) {
|
|
30
|
+
return localConfig;
|
|
31
|
+
}
|
|
32
|
+
const coreConfig = ObjectRegistry.getTenantScopedConfig(className);
|
|
33
|
+
if (coreConfig) {
|
|
34
|
+
return {
|
|
35
|
+
mode: coreConfig.mode,
|
|
36
|
+
field: coreConfig.field,
|
|
37
|
+
autoFilter: coreConfig.autoFilter,
|
|
38
|
+
autoPopulate: coreConfig.autoPopulate,
|
|
39
|
+
allowSuperAdminBypass: coreConfig.allowSuperAdminBypass
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return void 0;
|
|
43
|
+
}
|
|
44
|
+
function getAllTenantScopedClasses() {
|
|
45
|
+
return new Map(tenantScopedClasses);
|
|
46
|
+
}
|
|
47
|
+
function clearTenantScopedRegistry() {
|
|
48
|
+
tenantScopedClasses.clear();
|
|
49
|
+
}
|
|
50
|
+
let enabled = false;
|
|
51
|
+
function setTenancyEnabled(value) {
|
|
52
|
+
enabled = value;
|
|
53
|
+
}
|
|
54
|
+
function isTenancyEnabled() {
|
|
55
|
+
return enabled;
|
|
56
|
+
}
|
|
57
|
+
async function runTenantScopedEntryPoint(options, fn) {
|
|
58
|
+
const {
|
|
59
|
+
className,
|
|
60
|
+
tenantScoped,
|
|
61
|
+
tenantId,
|
|
62
|
+
allowCrossTenant = false,
|
|
63
|
+
surface = "entry point"
|
|
64
|
+
} = options;
|
|
65
|
+
const scoped = typeof tenantScoped === "boolean" ? tenantScoped : className ? isTenantScopedClass(className) : false;
|
|
66
|
+
if (!scoped) return fn();
|
|
67
|
+
if (hasTenantContext() || isSystemContext()) return fn();
|
|
68
|
+
if (allowCrossTenant) {
|
|
69
|
+
return withSystemContext(fn);
|
|
70
|
+
}
|
|
71
|
+
if (typeof tenantId === "string" && tenantId) {
|
|
72
|
+
return withTenant({ tenantId }, fn);
|
|
73
|
+
}
|
|
74
|
+
if (isTenancyEnabled()) {
|
|
75
|
+
throw new TenantContextError(
|
|
76
|
+
`Tenant context required for tenant-scoped access via ${surface}. Pass an explicit tenant (e.g. --tenant <id> / a tenantId) or opt into cross-tenant access (e.g. --all-tenants) to read across all tenants.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return fn();
|
|
80
|
+
}
|
|
81
|
+
const logger = createLogger({ level: "info" });
|
|
82
|
+
const DEFAULT_OPTIONS = {
|
|
83
|
+
rawQueryPolicy: "throw"
|
|
84
|
+
};
|
|
85
|
+
function serializeInstance(instance, className) {
|
|
86
|
+
if (typeof instance.toJSON === "function") {
|
|
87
|
+
return { className, ...instance.toJSON() };
|
|
88
|
+
}
|
|
89
|
+
const result = { className };
|
|
90
|
+
for (const key of Object.keys(instance)) {
|
|
91
|
+
const value = instance[key];
|
|
92
|
+
if (typeof value !== "function") {
|
|
93
|
+
result[key] = value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
function createTenantInterceptor(options = {}) {
|
|
99
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
100
|
+
return {
|
|
101
|
+
name: "smrt-tenancy",
|
|
102
|
+
priority: 100,
|
|
103
|
+
// High priority - should run first
|
|
104
|
+
/**
|
|
105
|
+
* Before list: Add tenant filter to queries
|
|
106
|
+
*/
|
|
107
|
+
beforeList(className, listOptions, context) {
|
|
108
|
+
if (!isTenantScopedClass(className)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (isSuperAdminBypass()) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (isSystemContext()) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const config = getTenantScopedConfig(className);
|
|
118
|
+
const tenantContext = getCurrentTenant();
|
|
119
|
+
if (!tenantContext) {
|
|
120
|
+
if (config?.mode === "required") {
|
|
121
|
+
opts.onMissingContext?.(className, "list", context);
|
|
122
|
+
throw new TenantContextError(
|
|
123
|
+
`Tenant context required for listing ${className}. Use withTenant() or configure TenantContext middleware.`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const tenantField = config?.field || "tenantId";
|
|
129
|
+
const where = listOptions.where || {};
|
|
130
|
+
if (tenantField in where) {
|
|
131
|
+
const existingFilter = where[tenantField];
|
|
132
|
+
const filterValues = Array.isArray(existingFilter) ? existingFilter : [existingFilter];
|
|
133
|
+
const offendingIndex = filterValues.findIndex(
|
|
134
|
+
(value) => value !== tenantContext.tenantId
|
|
135
|
+
);
|
|
136
|
+
if (offendingIndex !== -1) {
|
|
137
|
+
const offending = filterValues[offendingIndex];
|
|
138
|
+
opts.onIsolationViolation?.(
|
|
139
|
+
className,
|
|
140
|
+
tenantContext.tenantId,
|
|
141
|
+
String(offending),
|
|
142
|
+
context
|
|
143
|
+
);
|
|
144
|
+
throw new TenantIsolationError(
|
|
145
|
+
`Tenant isolation violation in ${className} query: context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,
|
|
146
|
+
{
|
|
147
|
+
tenantId: tenantContext.tenantId,
|
|
148
|
+
attemptedTenantId: String(offending)
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
...listOptions,
|
|
156
|
+
where: {
|
|
157
|
+
...where,
|
|
158
|
+
[tenantField]: tenantContext.tenantId
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
/**
|
|
163
|
+
* Before get: Add tenant filter to single record fetches
|
|
164
|
+
*/
|
|
165
|
+
beforeGet(className, filter, context) {
|
|
166
|
+
if (!isTenantScopedClass(className)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (isSuperAdminBypass()) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (isSystemContext()) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const config = getTenantScopedConfig(className);
|
|
176
|
+
const tenantContext = getCurrentTenant();
|
|
177
|
+
if (!tenantContext) {
|
|
178
|
+
if (config?.mode === "required") {
|
|
179
|
+
opts.onMissingContext?.(className, "get", context);
|
|
180
|
+
throw new TenantContextError(
|
|
181
|
+
`Tenant context required for getting ${className}. Use withTenant() or configure TenantContext middleware.`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const tenantField = config?.field || "tenantId";
|
|
187
|
+
if (typeof filter === "string") {
|
|
188
|
+
return {
|
|
189
|
+
id: filter,
|
|
190
|
+
[tenantField]: tenantContext.tenantId
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (!(tenantField in filter)) {
|
|
194
|
+
return {
|
|
195
|
+
...filter,
|
|
196
|
+
[tenantField]: tenantContext.tenantId
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const existingFilter = filter[tenantField];
|
|
200
|
+
const filterValues = Array.isArray(existingFilter) ? existingFilter : [existingFilter];
|
|
201
|
+
const offendingIndex = filterValues.findIndex(
|
|
202
|
+
(value) => value !== tenantContext.tenantId
|
|
203
|
+
);
|
|
204
|
+
if (offendingIndex !== -1) {
|
|
205
|
+
const offending = filterValues[offendingIndex];
|
|
206
|
+
opts.onIsolationViolation?.(
|
|
207
|
+
className,
|
|
208
|
+
tenantContext.tenantId,
|
|
209
|
+
String(offending),
|
|
210
|
+
context
|
|
211
|
+
);
|
|
212
|
+
throw new TenantIsolationError(
|
|
213
|
+
`Tenant isolation violation in ${className} get: context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,
|
|
214
|
+
{
|
|
215
|
+
tenantId: tenantContext.tenantId,
|
|
216
|
+
attemptedTenantId: String(offending)
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
},
|
|
222
|
+
/**
|
|
223
|
+
* Before query: Handle raw SQL on tenant-scoped classes
|
|
224
|
+
*/
|
|
225
|
+
beforeQuery(className, queryOptions, context) {
|
|
226
|
+
if (!isTenantScopedClass(className)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (queryOptions.allowRawOnTenantScoped) {
|
|
230
|
+
opts.onRawQuery?.(className, queryOptions.sql, context);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (isSuperAdminBypass()) {
|
|
234
|
+
opts.onRawQuery?.(className, queryOptions.sql, context);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (isSystemContext()) {
|
|
238
|
+
opts.onRawQuery?.(className, queryOptions.sql, context);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const message = `Raw SQL query attempted on tenant-scoped class ${className}. Use list()/get() for automatic tenant filtering, or call query() with { allowRawOnTenantScoped: true } if you're handling tenant filtering manually.`;
|
|
242
|
+
opts.onRawQuery?.(className, queryOptions.sql, context);
|
|
243
|
+
switch (opts.rawQueryPolicy) {
|
|
244
|
+
case "throw":
|
|
245
|
+
throw new TenantIsolationError(message);
|
|
246
|
+
case "warn":
|
|
247
|
+
logger.warn(`[smrt-tenancy] WARNING: ${message}`);
|
|
248
|
+
return;
|
|
249
|
+
default:
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
/**
|
|
254
|
+
* Before save: Validate tenant ID is set and matches context
|
|
255
|
+
*/
|
|
256
|
+
beforeSave(instance, context) {
|
|
257
|
+
const className = context.className;
|
|
258
|
+
if (opts.directoryClasses?.includes(className)) {
|
|
259
|
+
const id = instance.id;
|
|
260
|
+
context.metadata = {
|
|
261
|
+
...context.metadata,
|
|
262
|
+
_directoryIsNew: id === void 0 || id === null
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (!isTenantScopedClass(className)) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (isSuperAdminBypass()) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (isSystemContext()) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const config = getTenantScopedConfig(className);
|
|
275
|
+
const tenantField = config?.field || "tenantId";
|
|
276
|
+
const instanceTenantId = instance[tenantField];
|
|
277
|
+
const tenantContext = getCurrentTenant();
|
|
278
|
+
if (!tenantContext) {
|
|
279
|
+
if (config?.mode === "required") {
|
|
280
|
+
opts.onMissingContext?.(className, "save", context);
|
|
281
|
+
throw new TenantContextError(
|
|
282
|
+
`Tenant context required for saving ${className}. Use withTenant() or configure TenantContext middleware.`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (!instanceTenantId && config?.autoPopulate !== false) {
|
|
288
|
+
instance[tenantField] = tenantContext.tenantId;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {
|
|
292
|
+
opts.onIsolationViolation?.(
|
|
293
|
+
className,
|
|
294
|
+
tenantContext.tenantId,
|
|
295
|
+
instanceTenantId,
|
|
296
|
+
context
|
|
297
|
+
);
|
|
298
|
+
throw new TenantIsolationError(
|
|
299
|
+
`Tenant isolation violation: cannot save ${className} with tenantId '${instanceTenantId}' in context of tenant '${tenantContext.tenantId}'`,
|
|
300
|
+
{
|
|
301
|
+
tenantId: tenantContext.tenantId,
|
|
302
|
+
attemptedTenantId: instanceTenantId
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
/**
|
|
308
|
+
* Before delete: Validate instance belongs to current tenant
|
|
309
|
+
*/
|
|
310
|
+
beforeDelete(instance, context) {
|
|
311
|
+
const className = context.className;
|
|
312
|
+
if (!isTenantScopedClass(className)) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (isSuperAdminBypass()) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (isSystemContext()) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const config = getTenantScopedConfig(className);
|
|
322
|
+
const tenantField = config?.field || "tenantId";
|
|
323
|
+
const instanceTenantId = instance[tenantField];
|
|
324
|
+
const tenantContext = getCurrentTenant();
|
|
325
|
+
if (!tenantContext) {
|
|
326
|
+
if (config?.mode === "required") {
|
|
327
|
+
opts.onMissingContext?.(className, "delete", context);
|
|
328
|
+
throw new TenantContextError(
|
|
329
|
+
`Tenant context required for deleting ${className}. Use withTenant() or configure TenantContext middleware.`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {
|
|
335
|
+
opts.onIsolationViolation?.(
|
|
336
|
+
className,
|
|
337
|
+
tenantContext.tenantId,
|
|
338
|
+
instanceTenantId,
|
|
339
|
+
context
|
|
340
|
+
);
|
|
341
|
+
throw new TenantIsolationError(
|
|
342
|
+
`Tenant isolation violation: cannot delete ${className} with tenantId '${instanceTenantId}' in context of tenant '${tenantContext.tenantId}'`,
|
|
343
|
+
{
|
|
344
|
+
tenantId: tenantContext.tenantId,
|
|
345
|
+
attemptedTenantId: instanceTenantId
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
/**
|
|
351
|
+
* After save: Emit directory dispatch for configured classes
|
|
352
|
+
*/
|
|
353
|
+
async afterSave(instance, context) {
|
|
354
|
+
if (!opts.dispatchBus || !opts.directoryClasses?.includes(context.className))
|
|
355
|
+
return;
|
|
356
|
+
const rawIsNew = context.metadata?._directoryIsNew;
|
|
357
|
+
const isNew = typeof rawIsNew === "boolean" ? rawIsNew : instance.id == null;
|
|
358
|
+
const event = isNew ? `directory.${context.className.toLowerCase()}.created` : `directory.${context.className.toLowerCase()}.updated`;
|
|
359
|
+
await opts.dispatchBus.emit(
|
|
360
|
+
event,
|
|
361
|
+
serializeInstance(instance, context.className),
|
|
362
|
+
{
|
|
363
|
+
source: "smrt-tenancy",
|
|
364
|
+
sourceId: instance.id
|
|
365
|
+
}
|
|
366
|
+
);
|
|
367
|
+
},
|
|
368
|
+
/**
|
|
369
|
+
* After delete: Emit directory dispatch for configured classes
|
|
370
|
+
*/
|
|
371
|
+
async afterDelete(instance, context) {
|
|
372
|
+
if (!opts.dispatchBus || !opts.directoryClasses?.includes(context.className))
|
|
373
|
+
return;
|
|
374
|
+
await opts.dispatchBus.emit(
|
|
375
|
+
`directory.${context.className.toLowerCase()}.deleted`,
|
|
376
|
+
serializeInstance(instance, context.className),
|
|
377
|
+
{
|
|
378
|
+
source: "smrt-tenancy",
|
|
379
|
+
sourceId: instance.id
|
|
380
|
+
}
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
let registeredInterceptor = null;
|
|
386
|
+
function enableTenancy(options = {}) {
|
|
387
|
+
if (isTenancyEnabled()) {
|
|
388
|
+
logger.warn(
|
|
389
|
+
"[smrt-tenancy] Tenancy is already enabled. Call disableTenancy() first to reconfigure."
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
registeredInterceptor = createTenantInterceptor(options);
|
|
394
|
+
GlobalInterceptors.register(registeredInterceptor);
|
|
395
|
+
setDispatchTenantResolver(() => getTenantId());
|
|
396
|
+
setTenantEntryPointRunner(runTenantScopedEntryPoint);
|
|
397
|
+
setTenancyEnabled(true);
|
|
398
|
+
}
|
|
399
|
+
function disableTenancy() {
|
|
400
|
+
if (!isTenancyEnabled() || !registeredInterceptor) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
GlobalInterceptors.unregister(registeredInterceptor);
|
|
404
|
+
setDispatchTenantResolver(void 0);
|
|
405
|
+
setTenantEntryPointRunner(void 0);
|
|
406
|
+
registeredInterceptor = null;
|
|
407
|
+
setTenancyEnabled(false);
|
|
408
|
+
}
|
|
409
|
+
function resetTenancy() {
|
|
410
|
+
disableTenancy();
|
|
411
|
+
clearTenantScopedRegistry();
|
|
412
|
+
}
|
|
413
|
+
async function createTestTenantContext(context, fn) {
|
|
414
|
+
return withTenant(context, fn);
|
|
415
|
+
}
|
|
416
|
+
async function testTenantIsolation(tenantIds, fn) {
|
|
417
|
+
const tenants = {};
|
|
418
|
+
for (const tenantId of tenantIds) {
|
|
419
|
+
tenants[tenantId] = async (runner) => {
|
|
420
|
+
return withTenant({ tenantId }, runner);
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return fn(tenants);
|
|
424
|
+
}
|
|
425
|
+
function setupTestTenancy(options = {}) {
|
|
426
|
+
const { enableInterceptors = true, rawQueryPolicy = "throw" } = options;
|
|
427
|
+
resetTenancy();
|
|
428
|
+
if (enableInterceptors) {
|
|
429
|
+
enableTenancy({ rawQueryPolicy });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function assertTenantContextRequired(fn, messageContains) {
|
|
433
|
+
try {
|
|
434
|
+
await fn();
|
|
435
|
+
throw new Error("Expected TenantContextError but no error was thrown");
|
|
436
|
+
} catch (error) {
|
|
437
|
+
const err = error;
|
|
438
|
+
if (err.code !== "TENANT_CONTEXT_REQUIRED") {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Expected TenantContextError but got ${err.constructor.name}: ${err.message}`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
if (messageContains && !err.message.includes(messageContains)) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
`Expected error message to contain '${messageContains}' but got: ${err.message}`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function assertTenantIsolationViolation(fn, messageContains) {
|
|
451
|
+
try {
|
|
452
|
+
await fn();
|
|
453
|
+
throw new Error("Expected TenantIsolationError but no error was thrown");
|
|
454
|
+
} catch (error) {
|
|
455
|
+
const err = error;
|
|
456
|
+
if (err.code !== "TENANT_ISOLATION_VIOLATION") {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Expected TenantIsolationError but got ${err.constructor.name}: ${err.message}`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (messageContains && !err.message.includes(messageContains)) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`Expected error message to contain '${messageContains}' but got: ${err.message}`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
export {
|
|
469
|
+
assertTenantContextRequired as a,
|
|
470
|
+
assertTenantIsolationViolation as b,
|
|
471
|
+
clearTenantScopedRegistry as c,
|
|
472
|
+
createTenantInterceptor as d,
|
|
473
|
+
createTestTenantContext as e,
|
|
474
|
+
disableTenancy as f,
|
|
475
|
+
enableTenancy as g,
|
|
476
|
+
getAllTenantScopedClasses as h,
|
|
477
|
+
getTenantScopedConfig as i,
|
|
478
|
+
isTenancyEnabled as j,
|
|
479
|
+
isTenantScopedClass as k,
|
|
480
|
+
resetTenancy as l,
|
|
481
|
+
runTenantScopedEntryPoint as m,
|
|
482
|
+
registerTenantScopedClass as r,
|
|
483
|
+
setupTestTenancy as s,
|
|
484
|
+
testTenantIsolation as t,
|
|
485
|
+
unregisterTenantScopedClass as u
|
|
486
|
+
};
|
|
487
|
+
//# sourceMappingURL=testing-C_tV23JW.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing-C_tV23JW.js","sources":["../../src/registry.ts","../../src/enabled-state.ts","../../src/entry-point.ts","../../src/interceptor.ts","../../src/testing.ts"],"sourcesContent":["/**\n * Tenant-Scoped Class Registry\n *\n * Tracks which classes are tenant-scoped and their configuration.\n * Used by the interceptor to determine how to handle operations.\n *\n * This registry supports two patterns:\n * 1. @TenantScoped() decorator + tenantId field (original pattern)\n * 2. @smrt({ tenantScoped: true }) in smrt-core (Issue #688 pattern)\n *\n * Both patterns are automatically recognized by the interceptor.\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n * @see https://github.com/happyvertical/smrt/issues/688\n */\n\nimport { ObjectRegistry } from '@happyvertical/smrt-core';\n\n/**\n * Resolved tenancy configuration for a single class, as stored in the registry.\n *\n * Every field has a concrete (non-optional) value — defaults are applied by\n * `registerTenantScopedClass()` when the class is registered via `@TenantScoped()`.\n *\n * @see TenantScopedOptions\n * @see registerTenantScopedClass\n */\nexport interface TenantScopedConfig {\n /**\n * Tenancy mode for this class\n * - 'required': Must have tenant context for all operations\n * - 'optional': Works with or without tenant context\n * @default 'required'\n */\n mode: 'required' | 'optional';\n\n /**\n * Field name containing tenant ID\n * @default 'tenantId'\n */\n field: string;\n\n /**\n * Auto-filter all queries by tenant\n * @default true\n */\n autoFilter: boolean;\n\n /**\n * Auto-populate tenant ID from context on create\n * @default true\n */\n autoPopulate: boolean;\n\n /**\n * Allow super admin bypass for this class\n * @default false\n */\n allowSuperAdminBypass: boolean;\n}\n\nconst DEFAULT_CONFIG: TenantScopedConfig = {\n mode: 'required',\n field: 'tenantId',\n autoFilter: true,\n autoPopulate: true,\n allowSuperAdminBypass: false,\n};\n\n// Registry storing tenant-scoped class configurations\nconst tenantScopedClasses = new Map<string, TenantScopedConfig>();\n\n/**\n * Register a class as tenant-scoped with the given configuration.\n *\n * Called automatically by the `@TenantScoped()` decorator. You can also call\n * this directly when you cannot use decorators (e.g., third-party classes or\n * plain objects in tests). Defaults from `DEFAULT_CONFIG` are merged over any\n * omitted options.\n *\n * Calling this again for the same `className` overwrites the previous entry.\n *\n * @param className - The class's `name` property (e.g., `'Document'`).\n * @param config - Partial tenancy configuration; omitted fields receive defaults.\n *\n * @example\n * ```typescript\n * // Manually register a class (e.g., for testing)\n * registerTenantScopedClass('Document', { mode: 'optional' });\n * ```\n *\n * @see TenantScoped\n * @see unregisterTenantScopedClass\n */\nexport function registerTenantScopedClass(\n className: string,\n config: Partial<TenantScopedConfig> = {},\n): void {\n tenantScopedClasses.set(className, {\n ...DEFAULT_CONFIG,\n ...config,\n });\n}\n\n/**\n * Remove a class from the tenant-scoped registry.\n *\n * Primarily intended for test teardown — use `clearTenantScopedRegistry()` to\n * reset the entire registry at once.\n *\n * @param className - The class name to remove (e.g., `'Document'`).\n *\n * @see clearTenantScopedRegistry\n * @see registerTenantScopedClass\n */\nexport function unregisterTenantScopedClass(className: string): void {\n tenantScopedClasses.delete(className);\n}\n\n/**\n * Return `true` if the named class is registered as tenant-scoped.\n *\n * Checks two sources in order:\n * 1. The local registry populated by `@TenantScoped()`.\n * 2. The core `ObjectRegistry` populated by `@smrt({ tenantScoped: true })`.\n *\n * @param className - The class name to look up (e.g., `'Document'`).\n * @returns `true` if the class is tenant-scoped by either mechanism.\n *\n * @see getTenantScopedConfig\n * @see registerTenantScopedClass\n */\nexport function isTenantScopedClass(className: string): boolean {\n // Check local registry first (explicit @TenantScoped decorator)\n if (tenantScopedClasses.has(className)) {\n return true;\n }\n // Check core registry (@smrt({ tenantScoped: true }) pattern - Issue #688)\n return ObjectRegistry.isTenantScoped(className);\n}\n\n/**\n * Retrieve the resolved tenancy configuration for a class.\n *\n * Checks two sources in order, with the local registry taking precedence:\n * 1. The local registry populated by `@TenantScoped()`.\n * 2. The core `ObjectRegistry` populated by `@smrt({ tenantScoped: true })`.\n *\n * When found in the core registry, the raw config is normalised into a\n * `TenantScopedConfig` with the same shape as locally registered classes.\n *\n * @param className - The class name to look up.\n * @returns The `TenantScopedConfig` if the class is tenant-scoped, or\n * `undefined` if it is not registered in either source.\n *\n * @see isTenantScopedClass\n * @see getAllTenantScopedClasses\n */\nexport function getTenantScopedConfig(\n className: string,\n): TenantScopedConfig | undefined {\n // Check local registry first (explicit @TenantScoped decorator)\n const localConfig = tenantScopedClasses.get(className);\n if (localConfig) {\n return localConfig;\n }\n\n // Check core registry (@smrt({ tenantScoped: true }) pattern - Issue #688)\n const coreConfig = ObjectRegistry.getTenantScopedConfig(className);\n if (coreConfig) {\n // Convert core config to TenantScopedConfig format\n return {\n mode: coreConfig.mode,\n field: coreConfig.field,\n autoFilter: coreConfig.autoFilter,\n autoPopulate: coreConfig.autoPopulate,\n allowSuperAdminBypass: coreConfig.allowSuperAdminBypass,\n };\n }\n\n return undefined;\n}\n\n/**\n * Return a snapshot of all classes registered via `@TenantScoped()`.\n *\n * Returns a new `Map` so mutations to the returned value do not affect the\n * internal registry. Note that classes registered only through the core\n * `ObjectRegistry` (`@smrt({ tenantScoped: true })`) are **not** included in\n * this map.\n *\n * @returns A copy of the local tenant-scoped class registry, keyed by class name.\n *\n * @see isTenantScopedClass\n * @see getTenantScopedConfig\n */\nexport function getAllTenantScopedClasses(): Map<string, TenantScopedConfig> {\n return new Map(tenantScopedClasses);\n}\n\n/**\n * Remove all entries from the local tenant-scoped class registry.\n *\n * Intended for test teardown via `resetTenancy()`. Does not affect\n * registrations held by the core `ObjectRegistry`.\n *\n * @see resetTenancy\n * @see unregisterTenantScopedClass\n */\nexport function clearTenantScopedRegistry(): void {\n tenantScopedClasses.clear();\n}\n","/**\n * Shared tenancy-enabled flag.\n *\n * Holds the single boolean toggled by `enableTenancy()` / `disableTenancy()`.\n * It lives in its own leaf module (importing nothing from the package) so that\n * both `interceptor.ts` and `entry-point.ts` can read it without forming a\n * circular import: `interceptor.ts` imports `runTenantScopedEntryPoint` from\n * `entry-point.ts`, and `entry-point.ts` needs the enabled flag — routing the\n * flag through here keeps that dependency one-directional.\n */\n\nlet enabled = false;\n\n/**\n * Set the global tenancy-enabled flag. Internal — called by `enableTenancy()` /\n * `disableTenancy()` in `interceptor.ts`.\n *\n * @param value - `true` to mark tenancy enabled, `false` to clear it.\n */\nexport function setTenancyEnabled(value: boolean): void {\n enabled = value;\n}\n\n/**\n * Return `true` if tenant enforcement is currently active.\n *\n * @returns Whether `enableTenancy()` has been called without a later\n * `disableTenancy()`.\n */\nexport function isTenancyEnabled(): boolean {\n return enabled;\n}\n","/**\n * Fail-closed tenant-context establishment for non-web entry points (#1554).\n *\n * The SvelteKit/Express adapters establish tenant context from the authenticated\n * request principal, so the web surface of a `@TenantScoped({ mode: 'optional' })`\n * model never reads across tenants without an active context. The generated\n * **CLI** and **MCP** entry points have no request principal, so an invocation\n * with no active context would fall through the interceptor's optional-mode\n * pass-through and return rows across **all** tenants.\n *\n * `runTenantScopedEntryPoint()` closes that gap. It is the single fail-closed\n * gate both generated surfaces wrap their per-command/per-tool execution in.\n *\n * @see createCliContext for the richer CLI runner (resolveTenantId, super-admin).\n */\n\nimport {\n hasTenantContext,\n isSystemContext,\n TenantContextError,\n withSystemContext,\n withTenant,\n} from './context.js';\nimport { isTenancyEnabled } from './enabled-state.js';\nimport { isTenantScopedClass } from './registry.js';\n\n/**\n * Inputs for {@link runTenantScopedEntryPoint}.\n *\n * Provide **either** `className` (the gate resolves tenant-scoping from the\n * authoritative tenancy registry — the same source the interceptor uses, so it\n * covers both `@TenantScoped` and `@smrt({ tenantScoped })` registrations) or an\n * explicit `tenantScoped` boolean (when the caller already resolved it, e.g. a\n * build-time generated surface). An explicit boolean wins when both are given.\n */\nexport interface TenantEntryPointOptions {\n /**\n * Class name of the target model. When provided, tenant-scoping is resolved\n * via `isTenantScopedClass(className)`.\n */\n className?: string;\n\n /**\n * Explicit tenant-scoping decision. Overrides `className` resolution when set.\n * Non-scoped models always pass through unchanged — the gate is a no-op.\n */\n tenantScoped?: boolean;\n\n /**\n * Explicit operator-provided tenant selector (CLI `--tenant <id>`, MCP\n * `context.tenantId`). When present (and no context is already active) the\n * function runs inside this tenant's context.\n */\n tenantId?: string | null;\n\n /**\n * Explicit operator opt-in to cross-tenant / system access (CLI\n * `--all-tenants`, an MCP host that trusts the caller as an operator). When\n * set the function runs in system context, bypassing tenant filtering.\n *\n * @default false\n */\n allowCrossTenant?: boolean;\n\n /**\n * Human-facing surface name used in the fail-closed error message, e.g.\n * `'CLI'` or `'MCP'`.\n *\n * @default 'entry point'\n */\n surface?: string;\n}\n\n/**\n * Run `fn` inside an appropriate tenant context for a generated CLI/MCP entry\n * point, failing closed for tenant-scoped models when no authorized context can\n * be established.\n *\n * Resolution order (tenant-scoped models only):\n * 1. A tenant context is already active, or an explicit `withSystemContext()`\n * bypass is in effect (e.g. `runAsSystem()`, migrations) → run as-is.\n * 2. `allowCrossTenant` was explicitly set → run in system context. Checked\n * before `tenantId` so an explicit cross-tenant opt-in wins over a default\n * principal/host tenant rather than being silently scoped.\n * 3. An explicit `tenantId` was provided → run inside that tenant.\n * 4. Tenancy is enabled but none of the above → **throw** `TenantContextError`\n * (the fail-closed branch — never silently read across tenants).\n * 5. Tenancy is disabled (single-/no-tenant deployment) → pass through.\n *\n * Non-tenant-scoped models always pass straight through.\n *\n * @param options - {@link TenantEntryPointOptions}.\n * @param fn - The command/tool body to execute.\n * @returns The resolved value of `fn`.\n * @throws {TenantContextError} When a tenant-scoped model is reached with\n * tenancy enabled and no tenant/cross-tenant selector.\n */\nexport async function runTenantScopedEntryPoint<T>(\n options: TenantEntryPointOptions,\n fn: () => Promise<T>,\n): Promise<T> {\n const {\n className,\n tenantScoped,\n tenantId,\n allowCrossTenant = false,\n surface = 'entry point',\n } = options;\n\n // Resolve tenant-scoping: an explicit boolean wins; otherwise consult the\n // authoritative tenancy registry by class name (matches the interceptor).\n const scoped =\n typeof tenantScoped === 'boolean'\n ? tenantScoped\n : className\n ? isTenantScopedClass(className)\n : false;\n\n // Non-scoped models run as-is. So do calls already inside a tenant context\n // (an upstream handle) or an explicit system-context bypass — the interceptor\n // honors `withSystemContext()` (migrations, `runAsSystem()`), so the gate must\n // not fail-close over it (hasTenantContext() is false for the system marker).\n if (!scoped) return fn();\n if (hasTenantContext() || isSystemContext()) return fn();\n\n // Explicit operator opt-in to cross-tenant access. Checked before the tenant\n // selector so a deliberate `--all-tenants` / `allowCrossTenant` overrides a\n // default host/principal tenant instead of being silently scoped to it.\n if (allowCrossTenant) {\n return withSystemContext(fn);\n }\n\n // Explicit tenant selector.\n if (typeof tenantId === 'string' && tenantId) {\n return withTenant({ tenantId }, fn);\n }\n\n // Fail closed: tenancy is on but the caller gave us nothing to scope by.\n if (isTenancyEnabled()) {\n throw new TenantContextError(\n `Tenant context required for tenant-scoped access via ${surface}. ` +\n 'Pass an explicit tenant (e.g. --tenant <id> / a tenantId) or opt into ' +\n 'cross-tenant access (e.g. --all-tenants) to read across all tenants.',\n );\n }\n\n // Tenancy disabled → single-tenant deployment, pass through.\n return fn();\n}\n","/**\n * Tenant Interceptor - Core enforcement mechanism\n *\n * Registers with GlobalInterceptors in smrt-core to automatically:\n * - Filter queries by tenant ID\n * - Validate tenant context on save/delete\n * - Block or audit raw SQL on tenant-scoped classes\n *\n * @see https://github.com/happyvertical/smrt/issues/675\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { SmrtObject } from '@happyvertical/smrt-core';\nimport {\n type CollectionInterceptor,\n type DispatchBus,\n GlobalInterceptors,\n type InterceptorContext,\n type ListOptions,\n type QueryInterceptResult,\n type QueryOptions,\n setDispatchTenantResolver,\n setTenantEntryPointRunner,\n} from '@happyvertical/smrt-core';\nimport {\n getCurrentTenant,\n getTenantId,\n isSuperAdminBypass,\n isSystemContext,\n TenantContextError,\n TenantIsolationError,\n} from './context.js';\nimport { isTenancyEnabled, setTenancyEnabled } from './enabled-state.js';\nimport { runTenantScopedEntryPoint } from './entry-point.js';\nimport { getTenantScopedConfig, isTenantScopedClass } from './registry.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Policy controlling what happens when raw SQL is executed against a\n * tenant-scoped class without an explicit bypass.\n *\n * - `'throw'` — Raises a `TenantIsolationError` (most secure; default).\n * - `'warn'` — Logs a `console.warn` but allows the query to proceed (useful\n * during migration periods).\n * - `'allow'` — Silently allows the query; not recommended for production.\n *\n * @see TenantInterceptorOptions.rawQueryPolicy\n * @see enableTenancy\n */\nexport type RawQueryPolicy = 'throw' | 'warn' | 'allow';\n\n/**\n * Configuration options accepted by `createTenantInterceptor()` and\n * `enableTenancy()`.\n *\n * All options are optional; reasonable defaults are applied. The callback\n * hooks (`onRawQuery`, `onMissingContext`, `onIsolationViolation`) are useful\n * for logging and alerting without altering the enforcement behaviour.\n *\n * @see createTenantInterceptor\n * @see enableTenancy\n */\nexport interface TenantInterceptorOptions {\n /**\n * Policy for raw SQL queries on tenant-scoped classes\n * - 'throw': Throw error (most secure, default)\n * - 'warn': Log warning but allow (for migration)\n * - 'allow': Silently allow (not recommended for production)\n * @default 'throw'\n */\n rawQueryPolicy?: RawQueryPolicy;\n\n /**\n * Called when a raw query is attempted on a tenant-scoped class\n * Useful for logging/auditing\n */\n onRawQuery?: (\n className: string,\n sql: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * Called when tenant context is missing for a tenant-scoped operation\n */\n onMissingContext?: (\n className: string,\n operation: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * Called when an isolation violation is detected\n */\n onIsolationViolation?: (\n className: string,\n expectedTenantId: string,\n actualTenantId: string,\n context: InterceptorContext,\n ) => void;\n\n /**\n * DispatchBus instance for emitting provisioning events on lifecycle changes.\n * When provided along with directoryClasses, afterSave/afterDelete hooks\n * emit dispatches like `directory.membership.created`.\n */\n dispatchBus?: DispatchBus;\n\n /**\n * Class names to emit directory dispatches for on save/delete lifecycle events.\n * Only classes listed here will trigger dispatch emissions.\n * @example ['Tenant', 'Membership', 'User']\n */\n directoryClasses?: string[];\n}\n\nconst DEFAULT_OPTIONS: TenantInterceptorOptions = {\n rawQueryPolicy: 'throw',\n};\n\n/**\n * Extract a plain-object snapshot of an instance for dispatch payloads.\n *\n * Prefers `toJSON()` when available (all real SmrtObject instances) because\n * it returns only data fields and excludes internal handles like `_db`, `_ai`,\n * and `_fs` which may contain circular references (e.g. connection pools with\n * Timeout objects).\n *\n * @see https://github.com/happyvertical/smrt/issues/946\n */\nfunction serializeInstance(\n instance: SmrtObject,\n className: string,\n): Record<string, unknown> {\n // Documented exception to the \"never call toJSON() directly\" convention\n // (docs/content/standards.md §7): the interceptor must serialize whatever\n // instance is handed to it, including workspace stubs and plain-object\n // doubles used in unit tests whose classes may not extend SmrtObject and\n // therefore have no `transformJSON()` hook. Using `toJSON()` here is a\n // duck-typed fallback — when present, it strips framework-internal handles\n // for us; when absent, we fall through to manual key iteration below.\n if (typeof (instance as any).toJSON === 'function') {\n return { className, ...(instance as any).toJSON() };\n }\n\n // Fallback for plain-object stubs (e.g. in unit tests):\n // skip functions and framework-internal properties\n const result: Record<string, unknown> = { className };\n for (const key of Object.keys(instance)) {\n const value = (instance as any)[key];\n if (typeof value !== 'function') {\n result[key] = value;\n }\n }\n return result;\n}\n\n/**\n * Create a `CollectionInterceptor` that enforces tenant isolation on all\n * `SmrtCollection` operations.\n *\n * The returned interceptor hooks into the smrt-core `GlobalInterceptors`\n * pipeline at priority 100 (runs before all other interceptors) and\n * handles the following lifecycle hooks:\n *\n * | Hook | Behaviour |\n * |---------------|-----------|\n * | `beforeList` | Injects tenant filter into `WHERE`; validates explicit filters. |\n * | `beforeGet` | Converts ID lookups to `{ id, tenantId }` filter objects. |\n * | `beforeSave` | Auto-populates `tenantId`; validates existing values. |\n * | `beforeDelete`| Validates the instance's `tenantId` matches context. |\n * | `beforeQuery` | Enforces `rawQueryPolicy` on raw SQL calls. |\n * | `afterSave` | Emits `directory.<class>.created/updated` via `dispatchBus`. |\n * | `afterDelete` | Emits `directory.<class>.deleted` via `dispatchBus`. |\n *\n * Use `enableTenancy()` to register the interceptor globally. Call this\n * directly only when you need multiple interceptor instances (e.g., for\n * isolated tests or feature flags).\n *\n * @param options - Configuration for the interceptor.\n * @returns A `CollectionInterceptor` ready to be registered with\n * `GlobalInterceptors.register()`.\n *\n * @example\n * ```typescript\n * import { createTenantInterceptor } from '@happyvertical/smrt-tenancy';\n * import { GlobalInterceptors } from '@happyvertical/smrt-core';\n *\n * const interceptor = createTenantInterceptor({ rawQueryPolicy: 'warn' });\n * GlobalInterceptors.register(interceptor);\n * ```\n *\n * @see enableTenancy\n * @see TenantInterceptorOptions\n */\nexport function createTenantInterceptor(\n options: TenantInterceptorOptions = {},\n): CollectionInterceptor {\n const opts = { ...DEFAULT_OPTIONS, ...options };\n\n return {\n name: 'smrt-tenancy',\n priority: 100, // High priority - should run first\n\n /**\n * Before list: Add tenant filter to queries\n */\n beforeList(\n className: string,\n listOptions: ListOptions,\n context: InterceptorContext,\n ): ListOptions | undefined {\n // Check if this class is tenant-scoped\n if (!isTenantScopedClass(className)) {\n return; // Not tenant-scoped, pass through\n }\n\n // Check for super admin bypass\n if (isSuperAdminBypass()) {\n return; // Bypass enabled, pass through\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantContext = getCurrentTenant();\n\n // If no tenant context and mode is 'required', throw\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'list', context);\n throw new TenantContextError(\n `Tenant context required for listing ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return; // Mode is 'optional', allow without filtering\n }\n\n // Add tenant filter to where clause\n const tenantField = config?.field || 'tenantId';\n const where = listOptions.where || {};\n\n // Check if tenant filter is already present\n if (tenantField in where) {\n // Validate it matches context. The filter may be a scalar\n // (`tenantId: 'x'`) or an IN-style array (`tenantId: ['x']`) —\n // smrt-core auto-converts array values to SQL IN clauses, so an\n // array containing only the context tenant is a valid filter.\n // See https://github.com/happyvertical/smrt/issues/1495\n const existingFilter = where[tenantField];\n const filterValues = Array.isArray(existingFilter)\n ? existingFilter\n : [existingFilter];\n // findIndex (not find) so a literal null/undefined filter value is\n // still flagged as a violation rather than mistaken for \"not found\"\n const offendingIndex = filterValues.findIndex(\n (value) => value !== tenantContext.tenantId,\n );\n if (offendingIndex !== -1) {\n const offending = filterValues[offendingIndex];\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n String(offending),\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation in ${className} query: ` +\n `context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: String(offending),\n },\n );\n }\n return; // Filter already correct\n }\n\n // Inject tenant filter\n return {\n ...listOptions,\n where: {\n ...where,\n [tenantField]: tenantContext.tenantId,\n },\n };\n },\n\n /**\n * Before get: Add tenant filter to single record fetches\n */\n beforeGet(\n className: string,\n filter: string | Record<string, unknown>,\n context: InterceptorContext,\n ): string | Record<string, unknown> | undefined {\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantContext = getCurrentTenant();\n\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'get', context);\n throw new TenantContextError(\n `Tenant context required for getting ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return;\n }\n\n const tenantField = config?.field || 'tenantId';\n\n // If filter is a string (ID), convert to object filter with tenant\n if (typeof filter === 'string') {\n return {\n id: filter,\n [tenantField]: tenantContext.tenantId,\n };\n }\n\n // Add tenant filter to object\n if (!(tenantField in filter)) {\n return {\n ...filter,\n [tenantField]: tenantContext.tenantId,\n };\n }\n\n // Validate existing filter. Like beforeList, accept scalar or\n // IN-style array filters (smrt-core auto-converts arrays to SQL IN).\n // See https://github.com/happyvertical/smrt/issues/1495\n const existingFilter = filter[tenantField];\n const filterValues = Array.isArray(existingFilter)\n ? existingFilter\n : [existingFilter];\n // findIndex (not find) so a literal null/undefined filter value is\n // still flagged as a violation rather than mistaken for \"not found\"\n const offendingIndex = filterValues.findIndex(\n (value) => value !== tenantContext.tenantId,\n );\n if (offendingIndex !== -1) {\n const offending = filterValues[offendingIndex];\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n String(offending),\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation in ${className} get: ` +\n `context tenant is '${tenantContext.tenantId}' but query filters by '${String(offending)}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: String(offending),\n },\n );\n }\n\n return;\n },\n\n /**\n * Before query: Handle raw SQL on tenant-scoped classes\n */\n beforeQuery(\n className: string,\n queryOptions: QueryOptions,\n context: InterceptorContext,\n ): QueryInterceptResult | undefined {\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n // Check for explicit bypass flag\n if (queryOptions.allowRawOnTenantScoped) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return; // Explicitly allowed\n }\n\n if (isSuperAdminBypass()) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n opts.onRawQuery?.(className, queryOptions.sql, context);\n return;\n }\n\n // Handle based on policy\n const message =\n `Raw SQL query attempted on tenant-scoped class ${className}. ` +\n `Use list()/get() for automatic tenant filtering, or call ` +\n `query() with { allowRawOnTenantScoped: true } if you're handling ` +\n `tenant filtering manually.`;\n\n opts.onRawQuery?.(className, queryOptions.sql, context);\n\n switch (opts.rawQueryPolicy) {\n case 'throw':\n throw new TenantIsolationError(message);\n\n case 'warn':\n logger.warn(`[smrt-tenancy] WARNING: ${message}`);\n return;\n default:\n return;\n }\n },\n\n /**\n * Before save: Validate tenant ID is set and matches context\n */\n beforeSave(instance: SmrtObject, context: InterceptorContext): void {\n // Use context.className which is always correct\n // (instance.constructor.name may not match for proxies or plain objects in tests)\n const className = context.className;\n\n // Stash isNew flag for afterSave dispatch detection\n if (opts.directoryClasses?.includes(className)) {\n const id = (instance as any).id;\n context.metadata = {\n ...context.metadata,\n _directoryIsNew: id === undefined || id === null,\n };\n }\n\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantField = config?.field || 'tenantId';\n const instanceTenantId = (instance as any)[tenantField];\n\n const tenantContext = getCurrentTenant();\n\n // Check if tenant context is required\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'save', context);\n throw new TenantContextError(\n `Tenant context required for saving ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return; // Mode is 'optional'\n }\n\n // Auto-populate tenant ID if not set\n if (!instanceTenantId && config?.autoPopulate !== false) {\n (instance as any)[tenantField] = tenantContext.tenantId;\n return;\n }\n\n // Validate tenant ID matches context\n if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n instanceTenantId,\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation: cannot save ${className} with ` +\n `tenantId '${instanceTenantId}' in context of tenant '${tenantContext.tenantId}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: instanceTenantId,\n },\n );\n }\n },\n\n /**\n * Before delete: Validate instance belongs to current tenant\n */\n beforeDelete(instance: SmrtObject, context: InterceptorContext): void {\n // Use context.className which is always correct\n const className = context.className;\n\n if (!isTenantScopedClass(className)) {\n return;\n }\n\n if (isSuperAdminBypass()) {\n return;\n }\n\n // Check for system context (explicit bypass via withSystemContext)\n if (isSystemContext()) {\n return; // System context bypasses tenant checks\n }\n\n const config = getTenantScopedConfig(className);\n const tenantField = config?.field || 'tenantId';\n const instanceTenantId = (instance as any)[tenantField];\n\n const tenantContext = getCurrentTenant();\n\n if (!tenantContext) {\n if (config?.mode === 'required') {\n opts.onMissingContext?.(className, 'delete', context);\n throw new TenantContextError(\n `Tenant context required for deleting ${className}. ` +\n `Use withTenant() or configure TenantContext middleware.`,\n );\n }\n return;\n }\n\n // Validate tenant ID matches\n if (instanceTenantId && instanceTenantId !== tenantContext.tenantId) {\n opts.onIsolationViolation?.(\n className,\n tenantContext.tenantId,\n instanceTenantId,\n context,\n );\n throw new TenantIsolationError(\n `Tenant isolation violation: cannot delete ${className} with ` +\n `tenantId '${instanceTenantId}' in context of tenant '${tenantContext.tenantId}'`,\n {\n tenantId: tenantContext.tenantId,\n attemptedTenantId: instanceTenantId,\n },\n );\n }\n },\n\n /**\n * After save: Emit directory dispatch for configured classes\n */\n async afterSave(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n if (\n !opts.dispatchBus ||\n !opts.directoryClasses?.includes(context.className)\n )\n return;\n\n const rawIsNew = context.metadata?._directoryIsNew;\n const isNew =\n typeof rawIsNew === 'boolean' ? rawIsNew : (instance as any).id == null;\n const event = isNew\n ? `directory.${context.className.toLowerCase()}.created`\n : `directory.${context.className.toLowerCase()}.updated`;\n\n await opts.dispatchBus.emit(\n event,\n serializeInstance(instance, context.className),\n {\n source: 'smrt-tenancy',\n sourceId: (instance as any).id,\n },\n );\n },\n\n /**\n * After delete: Emit directory dispatch for configured classes\n */\n async afterDelete(\n instance: SmrtObject,\n context: InterceptorContext,\n ): Promise<void> {\n if (\n !opts.dispatchBus ||\n !opts.directoryClasses?.includes(context.className)\n )\n return;\n\n await opts.dispatchBus.emit(\n `directory.${context.className.toLowerCase()}.deleted`,\n serializeInstance(instance, context.className),\n {\n source: 'smrt-tenancy',\n sourceId: (instance as any).id,\n },\n );\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Registration Functions\n// ─────────────────────────────────────────────────────────────────────────────\n\n// The enabled flag lives in `enabled-state.ts` (a leaf module) so `entry-point.ts`\n// can read it without importing this module — breaking the otherwise-circular\n// interceptor ↔ entry-point dependency.\nlet registeredInterceptor: CollectionInterceptor | null = null;\n\n/**\n * Enable tenant enforcement globally\n *\n * Call this once at application startup to enable automatic tenant isolation.\n *\n * @param options - Configuration options\n *\n * @example\n * ```typescript\n * // In your app initialization\n * import { enableTenancy } from '@happyvertical/smrt-tenancy';\n *\n * enableTenancy({\n * rawQueryPolicy: 'throw',\n * onMissingContext: (className, operation) => {\n * console.error(`Missing tenant context for ${operation} on ${className}`);\n * }\n * });\n * ```\n */\nexport function enableTenancy(options: TenantInterceptorOptions = {}): void {\n if (isTenancyEnabled()) {\n logger.warn(\n '[smrt-tenancy] Tenancy is already enabled. Call disableTenancy() first to reconfigure.',\n );\n return;\n }\n\n registeredInterceptor = createTenantInterceptor(options);\n GlobalInterceptors.register(registeredInterceptor);\n\n // Wire the DispatchBus tenant-scope resolver (S5 #1398). Core cannot depend\n // on tenancy, so it reads the active tenant through this injected hook; the\n // bus stamps/filters dispatches by the active tenant only while tenancy is\n // enabled. Mirrors the GlobalInterceptors inversion above.\n setDispatchTenantResolver(() => getTenantId());\n\n // Wire the fail-closed tenant gate for generated CLI/MCP entry points (#1554).\n // Core invokes this runner around tenant-scoped CLI/MCP execution; without it\n // (tenancy disabled) those surfaces pass through unchanged.\n setTenantEntryPointRunner(runTenantScopedEntryPoint);\n\n setTenancyEnabled(true);\n}\n\n/**\n * Disable global tenant enforcement.\n *\n * Unregisters the interceptor previously installed by `enableTenancy()` and\n * resets the internal enabled flag so `enableTenancy()` can be called again.\n * Idempotent — safe to call even when tenancy was never enabled.\n *\n * Common use-cases:\n * - Test teardown (via `resetTenancy()`).\n * - Temporarily disabling tenancy before reconfiguring with new options.\n *\n * @example\n * ```typescript\n * afterAll(() => {\n * disableTenancy();\n * });\n * ```\n *\n * @see enableTenancy\n * @see isTenancyEnabled\n * @see resetTenancy\n */\nexport function disableTenancy(): void {\n if (!isTenancyEnabled() || !registeredInterceptor) {\n return;\n }\n\n GlobalInterceptors.unregister(registeredInterceptor);\n // Clear the DispatchBus tenant resolver so the bus reverts to its no-op\n // (pre-tenancy) behavior when tenancy is disabled.\n setDispatchTenantResolver(undefined);\n // Clear the CLI/MCP tenant gate so those surfaces pass through (#1554).\n setTenantEntryPointRunner(undefined);\n registeredInterceptor = null;\n setTenancyEnabled(false);\n}\n\n/**\n * Return `true` if tenant enforcement is currently active.\n *\n * Reflects whether `enableTenancy()` has been called and the interceptor has not\n * yet been removed by `disableTenancy()`. Re-exported from `enabled-state.ts`\n * (the shared leaf module) so the public API surface is unchanged.\n *\n * @see enableTenancy\n * @see disableTenancy\n */\nexport { isTenancyEnabled };\n","/**\n * Testing Utilities for smrt-tenancy\n *\n * Helpers for testing tenant-scoped applications.\n *\n * @example\n * ```typescript\n * import { createTestTenantContext, resetTenancy } from '@happyvertical/smrt-tenancy/testing';\n *\n * beforeEach(() => {\n * resetTenancy(); // Clear all state\n * });\n *\n * it('should filter by tenant', async () => {\n * await createTestTenantContext({ tenantId: 'tenant-1' }, async () => {\n * const docs = await collection.list({});\n * // Only tenant-1 documents\n * });\n * });\n * ```\n */\n\nimport {\n type MinimalTenantContext,\n type TenantContextData,\n withTenant,\n} from './context.js';\nimport { disableTenancy, enableTenancy } from './interceptor.js';\nimport { clearTenantScopedRegistry } from './registry.js';\n\n/**\n * Reset all tenancy state (for use in beforeEach/afterEach)\n *\n * This clears:\n * - Registered interceptors\n * - Tenant-scoped class registry\n *\n * @example\n * ```typescript\n * afterEach(() => {\n * resetTenancy();\n * });\n * ```\n */\nexport function resetTenancy(): void {\n disableTenancy();\n clearTenantScopedRegistry();\n}\n\n/**\n * Create a test tenant context and run code within it\n *\n * Convenience wrapper around withTenant() with sensible defaults for testing.\n *\n * @param context - Tenant context (can be minimal, just tenantId)\n * @param fn - Async function to run in the context\n *\n * @example\n * ```typescript\n * await createTestTenantContext({ tenantId: 'test-tenant' }, async () => {\n * const product = await collection.create({ name: 'Test' });\n * expect(product.tenantId).toBe('test-tenant');\n * });\n * ```\n */\nexport async function createTestTenantContext<T>(\n context: MinimalTenantContext | TenantContextData,\n fn: () => Promise<T>,\n): Promise<T> {\n return withTenant(context, fn);\n}\n\n/**\n * Create multiple tenant contexts for isolation testing\n *\n * @param tenantIds - Array of tenant IDs to create contexts for\n * @param fn - Function that receives an object mapping tenant IDs to context runners\n *\n * @example\n * ```typescript\n * await testTenantIsolation(['tenant-a', 'tenant-b'], async (tenants) => {\n * // Create in tenant A\n * const docA = await tenants['tenant-a'](async () => {\n * return collection.create({ title: 'A doc' });\n * });\n *\n * // Verify not visible in tenant B\n * await tenants['tenant-b'](async () => {\n * const found = await collection.get(docA.id);\n * expect(found).toBeNull();\n * });\n * });\n * ```\n */\nexport async function testTenantIsolation<T>(\n tenantIds: string[],\n fn: (\n tenants: Record<string, <R>(runner: () => Promise<R>) => Promise<R>>,\n ) => Promise<T>,\n): Promise<T> {\n const tenants: Record<string, <R>(runner: () => Promise<R>) => Promise<R>> =\n {};\n\n for (const tenantId of tenantIds) {\n tenants[tenantId] = async <R>(runner: () => Promise<R>) => {\n return withTenant({ tenantId }, runner);\n };\n }\n\n return fn(tenants);\n}\n\n/**\n * Options for `setupTestTenancy()`.\n *\n * @see setupTestTenancy\n */\nexport interface SetupTestTenancyOptions {\n /**\n * Enable tenancy interceptors\n * @default true\n */\n enableInterceptors?: boolean;\n\n /**\n * Raw query policy for tests\n * @default 'throw'\n */\n rawQueryPolicy?: 'throw' | 'warn' | 'allow';\n}\n\n/**\n * Set up tenancy for a test suite\n *\n * Call in beforeAll or at the start of tests to configure tenancy.\n *\n * @param options - Setup options\n *\n * @example\n * ```typescript\n * beforeAll(() => {\n * setupTestTenancy({ enableInterceptors: true });\n * });\n *\n * afterAll(() => {\n * resetTenancy();\n * });\n * ```\n */\nexport function setupTestTenancy(options: SetupTestTenancyOptions = {}): void {\n const { enableInterceptors = true, rawQueryPolicy = 'throw' } = options;\n\n // Clear any existing state\n resetTenancy();\n\n // Enable interceptors if requested\n if (enableInterceptors) {\n enableTenancy({ rawQueryPolicy });\n }\n}\n\n/**\n * Assert that executing `fn` throws a `TenantContextError`.\n *\n * Fails with a descriptive message if `fn` completes without throwing, or if\n * it throws a different error type. Optionally verifies that the error message\n * contains a specific substring.\n *\n * Useful for testing that business-logic code correctly rejects calls that are\n * made outside a tenant context.\n *\n * @param fn - Async function that should throw `TenantContextError`.\n * @param messageContains - Optional substring the error message must include.\n *\n * @example\n * ```typescript\n * await assertTenantContextRequired(async () => {\n * // No withTenant() in scope\n * await documentCollection.list({});\n * });\n * ```\n *\n * @see assertTenantIsolationViolation\n * @see TenantContextError\n */\nexport async function assertTenantContextRequired(\n fn: () => Promise<unknown>,\n messageContains?: string,\n): Promise<void> {\n try {\n await fn();\n throw new Error('Expected TenantContextError but no error was thrown');\n } catch (error: unknown) {\n const err = error as Error & { code?: string };\n if (err.code !== 'TENANT_CONTEXT_REQUIRED') {\n throw new Error(\n `Expected TenantContextError but got ${err.constructor.name}: ${err.message}`,\n );\n }\n if (messageContains && !err.message.includes(messageContains)) {\n throw new Error(\n `Expected error message to contain '${messageContains}' but got: ${err.message}`,\n );\n }\n }\n}\n\n/**\n * Assert that executing `fn` throws a `TenantIsolationError`.\n *\n * Fails with a descriptive message if `fn` completes without throwing, or if\n * it throws a different error type. Optionally verifies that the error message\n * contains a specific substring.\n *\n * Use this to verify that cross-tenant data access attempts are correctly\n * blocked by the interceptor.\n *\n * @param fn - Async function that should throw `TenantIsolationError`.\n * @param messageContains - Optional substring the error message must include.\n *\n * @example\n * ```typescript\n * await withTenant({ tenantId: 'tenant-a' }, async () => {\n * await assertTenantIsolationViolation(async () => {\n * // Attempt to filter by a different tenant\n * await collection.list({ where: { tenantId: 'tenant-b' } });\n * });\n * });\n * ```\n *\n * @see assertTenantContextRequired\n * @see TenantIsolationError\n */\nexport async function assertTenantIsolationViolation(\n fn: () => Promise<unknown>,\n messageContains?: string,\n): Promise<void> {\n try {\n await fn();\n throw new Error('Expected TenantIsolationError but no error was thrown');\n } catch (error: unknown) {\n const err = error as Error & { code?: string };\n if (err.code !== 'TENANT_ISOLATION_VIOLATION') {\n throw new Error(\n `Expected TenantIsolationError but got ${err.constructor.name}: ${err.message}`,\n );\n }\n if (messageContains && !err.message.includes(messageContains)) {\n throw new Error(\n `Expected error message to contain '${messageContains}' but got: ${err.message}`,\n );\n }\n }\n}\n"],"names":[],"mappings":"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"}
|