@memberjunction/server 5.8.0 → 5.9.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.
Files changed (69) hide show
  1. package/README.md +1 -0
  2. package/dist/apolloServer/index.d.ts +10 -2
  3. package/dist/apolloServer/index.d.ts.map +1 -1
  4. package/dist/apolloServer/index.js +22 -8
  5. package/dist/apolloServer/index.js.map +1 -1
  6. package/dist/config.d.ts +125 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +23 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/context.d.ts +17 -0
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +144 -62
  13. package/dist/context.js.map +1 -1
  14. package/dist/generated/generated.d.ts +207 -0
  15. package/dist/generated/generated.d.ts.map +1 -1
  16. package/dist/generated/generated.js +1112 -76
  17. package/dist/generated/generated.js.map +1 -1
  18. package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
  19. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
  20. package/dist/generic/CacheInvalidationResolver.js +80 -0
  21. package/dist/generic/CacheInvalidationResolver.js.map +1 -0
  22. package/dist/generic/PubSubManager.d.ts +27 -0
  23. package/dist/generic/PubSubManager.d.ts.map +1 -0
  24. package/dist/generic/PubSubManager.js +42 -0
  25. package/dist/generic/PubSubManager.js.map +1 -0
  26. package/dist/generic/ResolverBase.d.ts +14 -0
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +50 -0
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/hooks.d.ts +65 -0
  31. package/dist/hooks.d.ts.map +1 -0
  32. package/dist/hooks.js +14 -0
  33. package/dist/hooks.js.map +1 -0
  34. package/dist/index.d.ts +6 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +172 -45
  37. package/dist/index.js.map +1 -1
  38. package/dist/multiTenancy/index.d.ts +47 -0
  39. package/dist/multiTenancy/index.d.ts.map +1 -0
  40. package/dist/multiTenancy/index.js +152 -0
  41. package/dist/multiTenancy/index.js.map +1 -0
  42. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
  43. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
  44. package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
  45. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
  46. package/dist/rest/RESTEndpointHandler.d.ts +3 -1
  47. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  48. package/dist/rest/RESTEndpointHandler.js +14 -33
  49. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  50. package/dist/types.d.ts +9 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/package.json +61 -57
  54. package/src/__tests__/multiTenancy.security.test.ts +334 -0
  55. package/src/__tests__/multiTenancy.test.ts +225 -0
  56. package/src/__tests__/unifiedAuth.test.ts +416 -0
  57. package/src/apolloServer/index.ts +32 -16
  58. package/src/config.ts +25 -0
  59. package/src/context.ts +205 -98
  60. package/src/generated/generated.ts +736 -1
  61. package/src/generic/CacheInvalidationResolver.ts +66 -0
  62. package/src/generic/PubSubManager.ts +47 -0
  63. package/src/generic/ResolverBase.ts +53 -0
  64. package/src/hooks.ts +77 -0
  65. package/src/index.ts +198 -49
  66. package/src/multiTenancy/index.ts +183 -0
  67. package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
  68. package/src/rest/RESTEndpointHandler.ts +23 -42
  69. package/src/types.ts +10 -0
@@ -0,0 +1,416 @@
1
+ /**
2
+ * Tests for the unified auth middleware (createUnifiedAuthMiddleware).
3
+ *
4
+ * Verifies: OPTIONS passthrough, token handling, userPayload attachment,
5
+ * 401 responses with proper error codes, and backward-compat properties.
6
+ *
7
+ * Because `createUnifiedAuthMiddleware` and `getUserPayload` live in the same
8
+ * module (context.ts), vi.mock cannot intercept the intra-module call.
9
+ * Instead we mock the LOWER-LEVEL dependencies that `getUserPayload` calls.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+
13
+ // ─── Hoisted mocks (available inside vi.mock factories) ─────────────────────
14
+ const {
15
+ MockTokenExpiredError,
16
+ mockGetReadOnlyDS,
17
+ mockGetReadWriteDS,
18
+ mockJwtDecode,
19
+ mockJwtVerify,
20
+ mockVerifyUserRecord,
21
+ mockExtractUserInfo,
22
+ mockGetValidationOptions,
23
+ mockGetSigningKeys,
24
+ mockGetByIssuer,
25
+ } = vi.hoisted(() => {
26
+ class _MockTokenExpiredError extends Error {
27
+ constructor(public expiryDate: Date) {
28
+ super('Token expired');
29
+ this.name = 'TokenExpiredError';
30
+ }
31
+ }
32
+
33
+ return {
34
+ MockTokenExpiredError: _MockTokenExpiredError,
35
+ mockGetReadOnlyDS: vi.fn(),
36
+ mockGetReadWriteDS: vi.fn(),
37
+ mockJwtDecode: vi.fn(),
38
+ mockJwtVerify: vi.fn(),
39
+ mockVerifyUserRecord: vi.fn(),
40
+ mockExtractUserInfo: vi.fn(),
41
+ mockGetValidationOptions: vi.fn(),
42
+ mockGetSigningKeys: vi.fn(),
43
+ mockGetByIssuer: vi.fn(),
44
+ };
45
+ });
46
+
47
+ // ─── Module mocks ───────────────────────────────────────────────────────────
48
+
49
+ vi.mock('../util.js', () => ({
50
+ GetReadOnlyDataSource: mockGetReadOnlyDS,
51
+ GetReadWriteDataSource: mockGetReadWriteDS,
52
+ }));
53
+
54
+ vi.mock('jsonwebtoken', () => ({
55
+ default: {
56
+ decode: mockJwtDecode,
57
+ verify: mockJwtVerify,
58
+ },
59
+ }));
60
+
61
+ vi.mock('../auth/index.js', () => ({
62
+ getSigningKeys: mockGetSigningKeys,
63
+ getSystemUser: vi.fn(),
64
+ getValidationOptions: mockGetValidationOptions,
65
+ verifyUserRecord: mockVerifyUserRecord,
66
+ extractUserInfoFromPayload: mockExtractUserInfo,
67
+ TokenExpiredError: MockTokenExpiredError,
68
+ }));
69
+
70
+ vi.mock('../cache.js', () => {
71
+ const map = new Map<string, boolean>();
72
+ return { authCache: map };
73
+ });
74
+
75
+ vi.mock('../config.js', () => ({
76
+ configInfo: {
77
+ baseUrl: 'http://localhost',
78
+ graphqlPort: 4001,
79
+ graphqlRootPath: '/',
80
+ databaseSettings: { metadataCacheRefreshInterval: 0 },
81
+ },
82
+ userEmailMap: {},
83
+ apiKey: 'test-api-key',
84
+ mj_core_schema: '__mj',
85
+ }));
86
+
87
+ vi.mock('../auth/AuthProviderFactory.js', () => ({
88
+ AuthProviderFactory: {
89
+ getInstance: () => ({ getByIssuer: mockGetByIssuer }),
90
+ },
91
+ }));
92
+
93
+ vi.mock('@memberjunction/api-keys', () => ({
94
+ GetAPIKeyEngine: vi.fn(),
95
+ }));
96
+
97
+ import { createUnifiedAuthMiddleware } from '../context.js';
98
+ import type { Request, Response, NextFunction } from 'express';
99
+ import type { DataSourceInfo } from '../types.js';
100
+
101
+ // ─── Helpers ────────────────────────────────────────────────────────────────
102
+
103
+ function makeMockReq(overrides: Partial<Request> = {}): Request {
104
+ return {
105
+ method: 'POST',
106
+ headers: {},
107
+ path: '/',
108
+ url: '/',
109
+ ip: '127.0.0.1',
110
+ socket: { remoteAddress: '127.0.0.1' },
111
+ ...overrides,
112
+ } as unknown as Request;
113
+ }
114
+
115
+ function makeMockRes(): Response & { _status: number; _json: Record<string, unknown> | null } {
116
+ const res = {
117
+ _status: 0,
118
+ _json: null as Record<string, unknown> | null,
119
+ status(code: number) {
120
+ res._status = code;
121
+ return res;
122
+ },
123
+ json(data: Record<string, unknown>) {
124
+ res._json = data;
125
+ return res;
126
+ },
127
+ };
128
+ return res as unknown as Response & { _status: number; _json: Record<string, unknown> | null };
129
+ }
130
+
131
+ const mockDataSources: DataSourceInfo[] = [];
132
+ const MOCK_POOL = {} as unknown as import('mssql').ConnectionPool;
133
+
134
+ const mockUserRecord = {
135
+ ID: 'user-1',
136
+ Name: 'Test User',
137
+ Email: 'test@example.com',
138
+ IsActive: true,
139
+ };
140
+
141
+ /** Configure lower-level mocks so getUserPayload succeeds for a bearer token */
142
+ function setupSuccessfulAuth() {
143
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
144
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
145
+ // Decode returns a valid, non-expired JWT payload
146
+ mockJwtDecode.mockReturnValue({
147
+ iss: 'https://test-issuer.com',
148
+ sub: 'user-1',
149
+ exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
150
+ });
151
+ // AuthProviderFactory finds the issuer
152
+ mockGetByIssuer.mockReturnValue({ name: 'test' });
153
+ // Validation options needed by verifyAsync
154
+ mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
155
+ // Signing keys callback
156
+ mockGetSigningKeys.mockReturnValue((_header: unknown, cb: (err: Error | null, key: string) => void) => {
157
+ cb(null, 'mock-key');
158
+ });
159
+ // jwt.verify calls the callback successfully
160
+ mockJwtVerify.mockImplementation(
161
+ (_token: string, _keyFn: unknown, _options: unknown, callback: (err: Error | null, decoded: unknown) => void) => {
162
+ callback(null, { iss: 'https://test-issuer.com', sub: 'user-1' });
163
+ }
164
+ );
165
+ // User info extraction
166
+ mockExtractUserInfo.mockReturnValue({
167
+ email: 'test@example.com',
168
+ firstName: 'Test',
169
+ lastName: 'User',
170
+ fullName: 'Test User',
171
+ preferredUsername: 'test@example.com',
172
+ });
173
+ // User record verification
174
+ mockVerifyUserRecord.mockResolvedValue(mockUserRecord);
175
+ }
176
+
177
+ // ─── Tests ──────────────────────────────────────────────────────────────────
178
+
179
+ describe('createUnifiedAuthMiddleware', () => {
180
+ let middleware: ReturnType<typeof createUnifiedAuthMiddleware>;
181
+
182
+ beforeEach(() => {
183
+ vi.clearAllMocks();
184
+ middleware = createUnifiedAuthMiddleware(mockDataSources);
185
+ });
186
+
187
+ describe('OPTIONS passthrough', () => {
188
+ it('should call next() without auth for OPTIONS requests', async () => {
189
+ const req = makeMockReq({ method: 'OPTIONS' });
190
+ const res = makeMockRes();
191
+ const next = vi.fn();
192
+
193
+ await middleware(req, res, next);
194
+
195
+ expect(next).toHaveBeenCalledTimes(1);
196
+ expect(mockJwtDecode).not.toHaveBeenCalled();
197
+ expect(res._status).toBe(0); // no status set
198
+ });
199
+
200
+ it('should not attach userPayload for OPTIONS requests', async () => {
201
+ const req = makeMockReq({ method: 'OPTIONS' });
202
+ const res = makeMockRes();
203
+ const next = vi.fn();
204
+
205
+ await middleware(req, res, next);
206
+
207
+ expect(req.userPayload).toBeUndefined();
208
+ });
209
+ });
210
+
211
+ describe('Successful authentication', () => {
212
+ beforeEach(() => {
213
+ setupSuccessfulAuth();
214
+ });
215
+
216
+ it('should attach userPayload to req on successful auth', async () => {
217
+ const req = makeMockReq({
218
+ headers: { authorization: 'Bearer valid-token' },
219
+ });
220
+ const res = makeMockRes();
221
+ const next = vi.fn();
222
+
223
+ await middleware(req, res, next);
224
+
225
+ expect(next).toHaveBeenCalledTimes(1);
226
+ expect(req.userPayload).toBeDefined();
227
+ expect(req.userPayload!.email).toBe('test@example.com');
228
+ expect(req.userPayload!.userRecord).toBe(mockUserRecord);
229
+ });
230
+
231
+ it('should set req.user for backward compatibility', async () => {
232
+ const req = makeMockReq({
233
+ headers: { authorization: 'Bearer valid-token' },
234
+ });
235
+ const res = makeMockRes();
236
+ const next = vi.fn();
237
+
238
+ await middleware(req, res, next);
239
+
240
+ const reqRecord = req as unknown as Record<string, unknown>;
241
+ expect(reqRecord['user']).toBeDefined();
242
+ expect((reqRecord['user'] as { email: string }).email).toBe('test@example.com');
243
+ });
244
+
245
+ it('should set req.mjUser to the userRecord', async () => {
246
+ const req = makeMockReq({
247
+ headers: { authorization: 'Bearer valid-token' },
248
+ });
249
+ const res = makeMockRes();
250
+ const next = vi.fn();
251
+
252
+ await middleware(req, res, next);
253
+
254
+ const reqRecord = req as unknown as Record<string, unknown>;
255
+ expect(reqRecord['mjUser']).toBe(mockUserRecord);
256
+ });
257
+ });
258
+
259
+ describe('Authentication failures', () => {
260
+ it('should return 401 when no authorization header is provided', async () => {
261
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
262
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
263
+ // No auth header → empty bearer token → empty token after strip → "Missing token"
264
+ const req = makeMockReq({ headers: {} });
265
+ const res = makeMockRes();
266
+ const next = vi.fn();
267
+
268
+ await middleware(req, res, next);
269
+
270
+ expect(next).not.toHaveBeenCalled();
271
+ expect(res._status).toBe(401);
272
+ expect(res._json).toEqual({ error: 'Authentication failed' });
273
+ });
274
+
275
+ it('should return 401 with JWT_EXPIRED code for expired tokens', async () => {
276
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
277
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
278
+ // jwt.decode returns a payload with expired timestamp
279
+ mockJwtDecode.mockReturnValue({
280
+ iss: 'https://test-issuer.com',
281
+ sub: 'user-1',
282
+ exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
283
+ });
284
+
285
+ const req = makeMockReq({
286
+ headers: { authorization: 'Bearer expired-token' },
287
+ });
288
+ const res = makeMockRes();
289
+ const next = vi.fn();
290
+
291
+ await middleware(req, res, next);
292
+
293
+ expect(next).not.toHaveBeenCalled();
294
+ expect(res._status).toBe(401);
295
+ expect(res._json).toEqual({
296
+ errors: [{
297
+ message: 'Token expired',
298
+ extensions: { code: 'JWT_EXPIRED' }
299
+ }]
300
+ });
301
+ });
302
+
303
+ it('should return 401 when jwt.decode returns null (corrupt token)', async () => {
304
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
305
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
306
+ mockJwtDecode.mockReturnValue(null);
307
+
308
+ const req = makeMockReq({
309
+ headers: { authorization: 'Bearer corrupt-token' },
310
+ });
311
+ const res = makeMockRes();
312
+ const next = vi.fn();
313
+
314
+ await middleware(req, res, next);
315
+
316
+ expect(next).not.toHaveBeenCalled();
317
+ expect(res._status).toBe(401);
318
+ expect(res._json).toEqual({ error: 'Authentication failed' });
319
+ });
320
+
321
+ it('should return 401 when verifyUserRecord returns null', async () => {
322
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
323
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
324
+ mockJwtDecode.mockReturnValue({
325
+ iss: 'https://test-issuer.com',
326
+ sub: 'user-1',
327
+ exp: Math.floor(Date.now() / 1000) + 3600,
328
+ });
329
+ mockGetByIssuer.mockReturnValue({ name: 'test' });
330
+ mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
331
+ mockGetSigningKeys.mockReturnValue(
332
+ (_h: unknown, cb: (err: Error | null, key: string) => void) => cb(null, 'key')
333
+ );
334
+ mockJwtVerify.mockImplementation(
335
+ (_t: string, _k: unknown, _o: unknown, cb: (err: Error | null, d: unknown) => void) => {
336
+ cb(null, { iss: 'https://test-issuer.com' });
337
+ }
338
+ );
339
+ mockExtractUserInfo.mockReturnValue({ email: 'unknown@example.com' });
340
+ mockVerifyUserRecord.mockResolvedValue(null); // user not found
341
+
342
+ const req = makeMockReq({
343
+ headers: { authorization: 'Bearer valid-token' },
344
+ });
345
+ const res = makeMockRes();
346
+ const next = vi.fn();
347
+
348
+ await middleware(req, res, next);
349
+
350
+ expect(next).not.toHaveBeenCalled();
351
+ expect(res._status).toBe(401);
352
+ });
353
+
354
+ it('should not call next() on auth failure', async () => {
355
+ mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
356
+ mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
357
+ mockJwtDecode.mockReturnValue(null); // invalid token
358
+
359
+ const req = makeMockReq({
360
+ headers: { authorization: 'Bearer bad-token' },
361
+ });
362
+ const res = makeMockRes();
363
+ const next = vi.fn();
364
+
365
+ await middleware(req, res, next);
366
+
367
+ expect(next).not.toHaveBeenCalled();
368
+ });
369
+ });
370
+
371
+ describe('HTTP method handling', () => {
372
+ beforeEach(() => {
373
+ setupSuccessfulAuth();
374
+ });
375
+
376
+ it('should authenticate GET requests', async () => {
377
+ const req = makeMockReq({ method: 'GET', headers: { authorization: 'Bearer tok' } });
378
+ const res = makeMockRes();
379
+ const next = vi.fn();
380
+
381
+ await middleware(req, res, next);
382
+
383
+ expect(next).toHaveBeenCalled();
384
+ });
385
+
386
+ it('should authenticate POST requests', async () => {
387
+ const req = makeMockReq({ method: 'POST', headers: { authorization: 'Bearer tok' } });
388
+ const res = makeMockRes();
389
+ const next = vi.fn();
390
+
391
+ await middleware(req, res, next);
392
+
393
+ expect(next).toHaveBeenCalled();
394
+ });
395
+
396
+ it('should authenticate PUT requests', async () => {
397
+ const req = makeMockReq({ method: 'PUT', headers: { authorization: 'Bearer tok' } });
398
+ const res = makeMockRes();
399
+ const next = vi.fn();
400
+
401
+ await middleware(req, res, next);
402
+
403
+ expect(next).toHaveBeenCalled();
404
+ });
405
+
406
+ it('should authenticate DELETE requests', async () => {
407
+ const req = makeMockReq({ method: 'DELETE', headers: { authorization: 'Bearer tok' } });
408
+ const res = makeMockRes();
409
+ const next = vi.fn();
410
+
411
+ await middleware(req, res, next);
412
+
413
+ expect(next).toHaveBeenCalled();
414
+ });
415
+ });
416
+ });
@@ -1,4 +1,4 @@
1
- import { ApolloServer, ApolloServerOptions } from '@apollo/server';
1
+ import { ApolloServer, ApolloServerOptions, ApolloServerPlugin } from '@apollo/server';
2
2
  import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
3
3
  import { Disposable } from 'graphql-ws';
4
4
  import { Server } from 'http';
@@ -7,27 +7,43 @@ import { AppContext } from '../types.js';
7
7
  import { Metadata } from '@memberjunction/core';
8
8
  import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
9
9
 
10
+ /**
11
+ * Creates and configures an Apollo Server instance with built-in plugins
12
+ * for HTTP drain and WebSocket cleanup.
13
+ *
14
+ * @param configOverride - Apollo Server options (typically contains the schema)
15
+ * @param servers - HTTP server and WebSocket cleanup disposable
16
+ * @param additionalPlugins - Optional additional plugins to merge with built-in plugins
17
+ */
10
18
  const buildApolloServer = (
11
19
  configOverride: ApolloServerOptions<AppContext>,
12
- { httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable }
13
- ) =>
14
- new ApolloServer({
20
+ { httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable },
21
+ additionalPlugins?: ApolloServerPlugin[]
22
+ ) => {
23
+ const builtInPlugins: ApolloServerPlugin[] = [
24
+ ApolloServerPluginDrainHttpServer({ httpServer }),
25
+ {
26
+ async serverWillStart() {
27
+ return {
28
+ async drainServer() {
29
+ await serverCleanup.dispose();
30
+ },
31
+ };
32
+ },
33
+ }
34
+ ];
35
+
36
+ const allPlugins = additionalPlugins
37
+ ? [...builtInPlugins, ...additionalPlugins]
38
+ : builtInPlugins;
39
+
40
+ return new ApolloServer({
15
41
  csrfPrevention: true,
16
42
  cache: 'bounded',
17
- plugins: [
18
- ApolloServerPluginDrainHttpServer({ httpServer }),
19
- {
20
- async serverWillStart() {
21
- return {
22
- async drainServer() {
23
- await serverCleanup.dispose();
24
- },
25
- };
26
- },
27
- }
28
- ],
43
+ plugins: allPlugins,
29
44
  introspection: enableIntrospection,
30
45
  ...configOverride,
31
46
  });
47
+ };
32
48
 
33
49
  export default buildApolloServer;
package/src/config.ts CHANGED
@@ -147,6 +147,29 @@ const queryDialectSchema = z.object({
147
147
  targetPlatforms: z.array(z.string()).optional().default([]),
148
148
  });
149
149
 
150
+ const multiTenancySchema = z.object({
151
+ /** Master switch — when false (default), no tenant isolation is applied */
152
+ enabled: zodBooleanWithTransforms().default(false),
153
+ /** How the tenant ID is determined for each request */
154
+ contextSource: z.enum(['header', 'linkedEntity', 'custom']).default('header'),
155
+ /** HTTP header name used when contextSource is 'header' */
156
+ tenantHeader: z.string().default('X-Tenant-ID'),
157
+ /** Whether scopedEntities is an allowlist or denylist of entities to filter */
158
+ scopingStrategy: z.enum(['allowlist', 'denylist']).default('denylist'),
159
+ /** Entities included/excluded from tenant filtering based on scopingStrategy */
160
+ scopedEntities: z.array(z.string()).default([]),
161
+ /** When true, entities in the __mj core schema are never tenant-filtered */
162
+ autoExcludeCoreEntities: zodBooleanWithTransforms().default(true),
163
+ /** Default column name containing the tenant identifier */
164
+ defaultTenantColumn: z.string().default('OrganizationID'),
165
+ /** Per-entity overrides for the tenant column name: { "EntityName": "ColumnName" } */
166
+ entityColumnMappings: z.record(z.string()).default({}),
167
+ /** Roles that bypass tenant filtering entirely */
168
+ adminRoles: z.array(z.string()).default(['Admin', 'System']),
169
+ /** Write protection mode: 'strict' rejects, 'log' warns, 'off' skips validation */
170
+ writeProtection: z.enum(['strict', 'log', 'off']).default('strict'),
171
+ });
172
+
150
173
  const telemetrySchema = z.object({
151
174
  enabled: zodBooleanWithTransforms().default(
152
175
  process.env.MJ_TELEMETRY_ENABLED !== 'false' // Enabled by default unless explicitly disabled
@@ -166,6 +189,7 @@ const configInfoSchema = z.object({
166
189
  scheduledJobs: scheduledJobsSchema.optional().default({}),
167
190
  telemetry: telemetrySchema.optional().default({}),
168
191
  queryDialects: queryDialectSchema.optional().default({}),
192
+ multiTenancy: multiTenancySchema.optional().default({}),
169
193
 
170
194
  apiKey: z.string().optional(),
171
195
  baseUrl: z.string().default('http://localhost'),
@@ -210,6 +234,7 @@ export type ComponentRegistryConfig = z.infer<typeof componentRegistrySchema>;
210
234
  export type ScheduledJobsConfig = z.infer<typeof scheduledJobsSchema>;
211
235
  export type TelemetryConfig = z.infer<typeof telemetrySchema>;
212
236
  export type QueryDialectConfig = z.infer<typeof queryDialectSchema>;
237
+ export type MultiTenancyConfig = z.infer<typeof multiTenancySchema>;
213
238
  export type ConfigInfo = z.infer<typeof configInfoSchema>;
214
239
 
215
240
  /**