@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.
- package/README.md +1 -0
- package/dist/apolloServer/index.d.ts +10 -2
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +22 -8
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +125 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +144 -62
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +207 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1112 -76
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
- package/dist/generic/CacheInvalidationResolver.js +80 -0
- package/dist/generic/CacheInvalidationResolver.js.map +1 -0
- package/dist/generic/PubSubManager.d.ts +27 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -0
- package/dist/generic/PubSubManager.js +42 -0
- package/dist/generic/PubSubManager.js.map +1 -0
- package/dist/generic/ResolverBase.d.ts +14 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +50 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/hooks.d.ts +65 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -45
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts +47 -0
- package/dist/multiTenancy/index.d.ts.map +1 -0
- package/dist/multiTenancy/index.js +152 -0
- package/dist/multiTenancy/index.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
- package/dist/rest/RESTEndpointHandler.d.ts +3 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +14 -33
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +61 -57
- package/src/__tests__/multiTenancy.security.test.ts +334 -0
- package/src/__tests__/multiTenancy.test.ts +225 -0
- package/src/__tests__/unifiedAuth.test.ts +416 -0
- package/src/apolloServer/index.ts +32 -16
- package/src/config.ts +25 -0
- package/src/context.ts +205 -98
- package/src/generated/generated.ts +736 -1
- package/src/generic/CacheInvalidationResolver.ts +66 -0
- package/src/generic/PubSubManager.ts +47 -0
- package/src/generic/ResolverBase.ts +53 -0
- package/src/hooks.ts +77 -0
- package/src/index.ts +198 -49
- package/src/multiTenancy/index.ts +183 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
- package/src/rest/RESTEndpointHandler.ts +23 -42
- package/src/types.ts +10 -0
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import dotenv from 'dotenv';
|
|
|
2
2
|
|
|
3
3
|
dotenv.config({ quiet: true });
|
|
4
4
|
|
|
5
|
-
import { expressMiddleware } from '@
|
|
5
|
+
import { expressMiddleware } from '@as-integrations/express5';
|
|
6
6
|
import { mergeSchemas } from '@graphql-tools/schema';
|
|
7
7
|
import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport } from '@memberjunction/core';
|
|
8
8
|
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
@@ -19,12 +19,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
19
19
|
import { sep } from 'node:path';
|
|
20
20
|
import 'reflect-metadata';
|
|
21
21
|
import { ReplaySubject } from 'rxjs';
|
|
22
|
-
import { BuildSchemaOptions, buildSchemaSync, GraphQLTimestamp } from 'type-graphql';
|
|
22
|
+
import { BuildSchemaOptions, buildSchemaSync, GraphQLTimestamp, PubSubEngine } from 'type-graphql';
|
|
23
|
+
import { PubSub } from 'graphql-subscriptions';
|
|
23
24
|
import sql from 'mssql';
|
|
24
25
|
import { WebSocketServer } from 'ws';
|
|
25
26
|
import buildApolloServer from './apolloServer/index.js';
|
|
26
27
|
import { configInfo, dbDatabase, dbHost, dbPort, dbUsername, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage, RESTApiOptions } from './config.js';
|
|
27
|
-
import { contextFunction, getUserPayload } from './context.js';
|
|
28
|
+
import { contextFunction, createUnifiedAuthMiddleware, getUserPayload } from './context.js';
|
|
28
29
|
import { requireSystemUserDirective, publicDirective } from './directives/index.js';
|
|
29
30
|
import createMSSQLConfig from './orm.js';
|
|
30
31
|
import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
|
|
@@ -38,6 +39,10 @@ import { ScheduledJobsService } from './services/ScheduledJobsService.js';
|
|
|
38
39
|
import { LocalCacheManager, StartupManager, TelemetryManager, TelemetryLevel } from '@memberjunction/core';
|
|
39
40
|
import { getSystemUser } from './auth/index.js';
|
|
40
41
|
import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
42
|
+
import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
|
|
43
|
+
import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
|
|
44
|
+
import { PubSubManager } from './generic/PubSubManager.js';
|
|
45
|
+
import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
|
|
41
46
|
|
|
42
47
|
const cacheRefreshInterval = configInfo.databaseSettings.metadataCacheRefreshInterval;
|
|
43
48
|
|
|
@@ -72,6 +77,8 @@ export {
|
|
|
72
77
|
export * from './auth/APIKeyScopeAuth.js';
|
|
73
78
|
|
|
74
79
|
export * from './generic/PushStatusResolver.js';
|
|
80
|
+
export * from './generic/PubSubManager.js';
|
|
81
|
+
export * from './generic/CacheInvalidationResolver.js';
|
|
75
82
|
export * from './generic/ResolverBase.js';
|
|
76
83
|
export * from './generic/RunViewResolver.js';
|
|
77
84
|
export * from './resolvers/RunTemplateResolver.js';
|
|
@@ -118,9 +125,32 @@ export * from './resolvers/VersionHistoryResolver.js';
|
|
|
118
125
|
export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
|
|
119
126
|
|
|
120
127
|
export * from './generated/generated.js';
|
|
128
|
+
export * from './hooks.js';
|
|
129
|
+
export * from './multiTenancy/index.js';
|
|
121
130
|
|
|
131
|
+
import type { ServerExtensibilityOptions, HookWithOptions } from './hooks.js';
|
|
132
|
+
import { HookRegistry } from '@memberjunction/core';
|
|
133
|
+
import type { HookRegistrationOptions } from '@memberjunction/core';
|
|
134
|
+
import type { ApolloServerPlugin } from '@apollo/server';
|
|
135
|
+
import { createTenantMiddleware, createTenantPreRunViewHook, createTenantPreSaveHook } from './multiTenancy/index.js';
|
|
122
136
|
|
|
123
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Register a hook that may be a plain function or a `{ hook, Priority, Namespace }` object.
|
|
139
|
+
* Dynamic packages (e.g., BCSaaS) return hooks in object form to declare registration metadata.
|
|
140
|
+
*/
|
|
141
|
+
function registerHookEntry<T>(hookName: string, entry: T | HookWithOptions<T>): void {
|
|
142
|
+
if (typeof entry === 'function') {
|
|
143
|
+
HookRegistry.Register(hookName, entry);
|
|
144
|
+
} else if (entry && typeof entry === 'object' && 'hook' in entry) {
|
|
145
|
+
const { hook, Priority, Namespace } = entry as HookWithOptions<T>;
|
|
146
|
+
const options: HookRegistrationOptions = {};
|
|
147
|
+
if (Priority != null) options.Priority = Priority;
|
|
148
|
+
if (Namespace != null) options.Namespace = Namespace;
|
|
149
|
+
HookRegistry.Register(hookName, hook, options);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type MJServerOptions = ServerExtensibilityOptions & {
|
|
124
154
|
onBeforeServe?: () => void | Promise<void>;
|
|
125
155
|
restApiOptions?: Partial<RESTApiOptions>; // Options for REST API configuration
|
|
126
156
|
};
|
|
@@ -301,9 +331,53 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
301
331
|
console.log('Server telemetry disabled');
|
|
302
332
|
}
|
|
303
333
|
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
334
|
+
// Optionally inject Redis as the shared storage provider for cross-server cache invalidation
|
|
335
|
+
if (process.env.REDIS_URL) {
|
|
336
|
+
const redisProvider = new RedisLocalStorageProvider({
|
|
337
|
+
url: process.env.REDIS_URL,
|
|
338
|
+
keyPrefix: process.env.REDIS_KEY_PREFIX || 'mj',
|
|
339
|
+
enablePubSub: true,
|
|
340
|
+
enableLogging: true,
|
|
341
|
+
});
|
|
342
|
+
(Metadata.Provider as GenericDatabaseProvider).SetLocalStorageProvider(redisProvider);
|
|
343
|
+
await redisProvider.StartListening();
|
|
344
|
+
|
|
345
|
+
// Connect Redis pub/sub events to LocalCacheManager callback dispatch
|
|
346
|
+
// so cross-server cache invalidation messages are routed to registered callbacks
|
|
347
|
+
redisProvider.OnCacheChanged((event) => {
|
|
348
|
+
const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
|
|
349
|
+
console.log(`[MJAPI] Redis pub/sub → DispatchCacheChange: ${event.Action} for "${event.CacheKey}" from server ${sourceShort}`);
|
|
350
|
+
LocalCacheManager.Instance.DispatchCacheChange(event);
|
|
351
|
+
|
|
352
|
+
// Also broadcast to connected browser clients via GraphQL subscription
|
|
353
|
+
// Extract entity name from the cache key (format: EntityName|Filter|OrderBy|...)
|
|
354
|
+
const entityName = event.CacheKey ? event.CacheKey.split('|')[0] : '';
|
|
355
|
+
if (entityName) {
|
|
356
|
+
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
|
|
357
|
+
entityName,
|
|
358
|
+
primaryKeyValues: null, // entity-level invalidation
|
|
359
|
+
action: event.Action || 'save',
|
|
360
|
+
sourceServerId: event.SourceServerId || 'unknown',
|
|
361
|
+
timestamp: new Date(),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
console.log(`Redis cache provider connected: ${process.env.REDIS_URL}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// If Redis is available, swap LocalCacheManager's storage provider to Redis.
|
|
370
|
+
// LocalCacheManager may have already been initialized (with in-memory provider)
|
|
371
|
+
// during engine loading. SetStorageProvider migrates cached data to Redis.
|
|
372
|
+
if (process.env.REDIS_URL) {
|
|
373
|
+
await LocalCacheManager.Instance.SetStorageProvider(Metadata.Provider.LocalStorageProvider);
|
|
374
|
+
console.log('LocalCacheManager: storage provider swapped to Redis');
|
|
375
|
+
}
|
|
376
|
+
// Ensure LocalCacheManager is initialized (no-op if already done during engine loading)
|
|
377
|
+
if (!LocalCacheManager.Instance.IsInitialized) {
|
|
378
|
+
await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider);
|
|
379
|
+
console.log('LocalCacheManager initialized');
|
|
380
|
+
}
|
|
307
381
|
|
|
308
382
|
// Initialize APIKeyEngine singleton — reads apiKeyGeneration from mj.config.cjs automatically
|
|
309
383
|
// This must happen before any request handler calls GetAPIKeyEngine()
|
|
@@ -342,6 +416,15 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
342
416
|
Object.values(module).filter((value) => typeof value === 'function')
|
|
343
417
|
) as BuildSchemaOptions['resolvers'];
|
|
344
418
|
|
|
419
|
+
// Create an explicit PubSub instance so we can reference it outside of resolvers
|
|
420
|
+
// graphql-subscriptions v3 renamed asyncIterator→asyncIterableIterator, but
|
|
421
|
+
// type-graphql still calls asyncIterator. Shim for compatibility.
|
|
422
|
+
const pubSub = new PubSub() as unknown as Record<string, unknown>;
|
|
423
|
+
if (!pubSub.asyncIterator && typeof pubSub.asyncIterableIterator === 'function') {
|
|
424
|
+
pubSub.asyncIterator = pubSub.asyncIterableIterator;
|
|
425
|
+
}
|
|
426
|
+
PubSubManager.Instance.SetPubSubEngine(pubSub as unknown as PubSubEngine);
|
|
427
|
+
|
|
345
428
|
let schema = mergeSchemas({
|
|
346
429
|
schemas: [
|
|
347
430
|
buildSchemaSync({
|
|
@@ -349,6 +432,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
349
432
|
validate: false,
|
|
350
433
|
scalarsMap: [{ type: Date, scalar: GraphQLTimestamp }],
|
|
351
434
|
emitSchemaFile: websiteRunFromPackage !== 1,
|
|
435
|
+
pubSub,
|
|
352
436
|
}),
|
|
353
437
|
],
|
|
354
438
|
typeDefs: [requireSystemUserDirective.typeDefs, publicDirective.typeDefs],
|
|
@@ -356,6 +440,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
356
440
|
schema = requireSystemUserDirective.transformer(schema);
|
|
357
441
|
schema = publicDirective.transformer(schema);
|
|
358
442
|
|
|
443
|
+
// Apply user-provided schema transformers (after built-in directive transformers)
|
|
444
|
+
if (options?.SchemaTransformers) {
|
|
445
|
+
for (const transformer of options.SchemaTransformers) {
|
|
446
|
+
schema = transformer(schema);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
359
450
|
const httpServer = createServer(app);
|
|
360
451
|
|
|
361
452
|
const webSocketServer = new WebSocketServer({ server: httpServer, path: graphqlRootPath });
|
|
@@ -385,7 +476,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
385
476
|
webSocketServer
|
|
386
477
|
);
|
|
387
478
|
|
|
388
|
-
const apolloServer = buildApolloServer(
|
|
479
|
+
const apolloServer = buildApolloServer(
|
|
480
|
+
{ schema },
|
|
481
|
+
{ httpServer, serverCleanup },
|
|
482
|
+
options?.ApolloPlugins
|
|
483
|
+
);
|
|
389
484
|
await apolloServer.start();
|
|
390
485
|
|
|
391
486
|
// Fix #8: Add compression for better throughput performance
|
|
@@ -409,72 +504,81 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
409
504
|
level: 6
|
|
410
505
|
}));
|
|
411
506
|
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const requestDomain = new URL(req.headers.origin || '').hostname;
|
|
417
|
-
const sessionId = sessionIdRaw ? sessionIdRaw.toString() : '';
|
|
418
|
-
const bearerToken = req.headers.authorization ?? '';
|
|
419
|
-
const apiKey = String(req.headers['x-mj-api-key']);
|
|
420
|
-
|
|
421
|
-
const userPayload = await getUserPayload(bearerToken, sessionId, dataSources, requestDomain, apiKey);
|
|
422
|
-
if (!userPayload) {
|
|
423
|
-
return res.status(401).json({ error: 'Invalid token' });
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Set both req.user (standard Express convention) and req['mjUser'] (MJ REST convention)
|
|
427
|
-
// Note: userPayload contains { userRecord: UserInfo, email, sessionId }
|
|
428
|
-
// The mjUser property expects the UserInfo directly (userRecord)
|
|
429
|
-
req.user = userPayload;
|
|
430
|
-
req['mjUser'] = userPayload.userRecord;
|
|
431
|
-
next();
|
|
432
|
-
} catch (error) {
|
|
433
|
-
console.error('Auth error:', error);
|
|
434
|
-
return res.status(401).json({ error: 'Authentication failed' });
|
|
507
|
+
// Apply user-provided Express middleware (after compression, before routes)
|
|
508
|
+
if (options?.ExpressMiddlewareBefore) {
|
|
509
|
+
for (const mw of options.ExpressMiddlewareBefore) {
|
|
510
|
+
app.use(mw);
|
|
435
511
|
}
|
|
436
|
-
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Escape hatch for advanced Express app configuration
|
|
515
|
+
if (options?.ConfigureExpressApp) {
|
|
516
|
+
await Promise.resolve(options.ConfigureExpressApp(app));
|
|
517
|
+
}
|
|
437
518
|
|
|
438
|
-
//
|
|
519
|
+
// ─── OAuth callback routes (unauthenticated, registered BEFORE auth) ─────
|
|
439
520
|
const oauthPublicUrl = configInfo.publicUrl || `${configInfo.baseUrl}:${configInfo.graphqlPort}${configInfo.graphqlRootPath || ''}`;
|
|
440
521
|
console.log(`[OAuth] publicUrl: ${oauthPublicUrl}`);
|
|
441
522
|
|
|
442
|
-
|
|
443
|
-
// These must be registered BEFORE GraphQL middleware since graphqlRootPath may be '/'
|
|
523
|
+
let oauthAuthenticatedRouter: ReturnType<typeof createOAuthCallbackHandler>['authenticatedRouter'] | undefined;
|
|
444
524
|
if (oauthPublicUrl) {
|
|
445
525
|
const { callbackRouter, authenticatedRouter } = createOAuthCallbackHandler({
|
|
446
526
|
publicUrl: oauthPublicUrl,
|
|
447
|
-
// TODO: These should be configurable to point to the MJ Explorer UI
|
|
448
527
|
successRedirectUrl: `${oauthPublicUrl}/oauth/success`,
|
|
449
528
|
errorRedirectUrl: `${oauthPublicUrl}/oauth/error`
|
|
450
529
|
});
|
|
530
|
+
oauthAuthenticatedRouter = authenticatedRouter;
|
|
451
531
|
|
|
452
|
-
// Create CORS middleware for OAuth routes (needed for cross-origin requests from frontend)
|
|
453
532
|
const oauthCors = cors<cors.CorsRequest>();
|
|
454
533
|
|
|
455
534
|
// OAuth callback is unauthenticated (called by external auth server)
|
|
456
535
|
app.use('/oauth', oauthCors, callbackRouter);
|
|
457
536
|
console.log('[OAuth] Callback route registered at /oauth/callback');
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ─── Global CORS (before auth so 401 responses include CORS headers) ─────
|
|
540
|
+
// Without this, the browser blocks 401 responses from the auth middleware
|
|
541
|
+
// because they lack Access-Control-Allow-Origin headers, preventing the
|
|
542
|
+
// client from reading the error code and triggering token refresh.
|
|
543
|
+
app.use(cors<cors.CorsRequest>());
|
|
458
544
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
545
|
+
// ─── Unified auth middleware (replaces both REST authMiddleware and contextFunction auth) ─────
|
|
546
|
+
app.use(createUnifiedAuthMiddleware(dataSources));
|
|
547
|
+
|
|
548
|
+
// ─── Built-in post-auth middleware (multi-tenancy) ─────
|
|
549
|
+
// Config-driven multi-tenancy middleware runs after auth so it can read req.userPayload.
|
|
550
|
+
if (configInfo.multiTenancy?.enabled) {
|
|
551
|
+
const tenantMiddleware = createTenantMiddleware(configInfo.multiTenancy);
|
|
552
|
+
app.use(tenantMiddleware);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── Post-auth middleware from plugins ─────
|
|
556
|
+
// Middleware here has access to the authenticated user via req.userPayload.
|
|
557
|
+
// Use this for tenant context resolution, org membership loading, etc.
|
|
558
|
+
if (options?.ExpressMiddlewarePostAuth) {
|
|
559
|
+
for (const mw of options.ExpressMiddlewarePostAuth) {
|
|
560
|
+
app.use(mw);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ─── OAuth authenticated routes (auth already handled by unified middleware) ─────
|
|
565
|
+
if (oauthAuthenticatedRouter) {
|
|
566
|
+
const oauthCors = cors<cors.CorsRequest>();
|
|
567
|
+
app.use('/oauth', oauthCors, BodyParser.json(), oauthAuthenticatedRouter);
|
|
462
568
|
console.log('[OAuth] Authenticated routes registered at /oauth/status, /oauth/initiate, and /oauth/exchange');
|
|
463
569
|
}
|
|
464
570
|
|
|
465
|
-
//
|
|
571
|
+
// ─── REST API endpoints (auth already handled by unified middleware) ─────
|
|
466
572
|
const restApiConfig = {
|
|
467
573
|
enabled: configInfo.restApiOptions?.enabled ?? false,
|
|
468
574
|
includeEntities: configInfo.restApiOptions?.includeEntities,
|
|
469
575
|
excludeEntities: configInfo.restApiOptions?.excludeEntities,
|
|
470
576
|
};
|
|
471
577
|
|
|
472
|
-
// Apply options from server options if provided (these override the config file)
|
|
473
578
|
if (options?.restApiOptions) {
|
|
474
579
|
Object.assign(restApiConfig, options.restApiOptions);
|
|
475
580
|
}
|
|
476
581
|
|
|
477
|
-
// Get REST API configuration from environment variables if present (env vars override everything)
|
|
478
582
|
if (process.env.MJ_REST_API_ENABLED !== undefined) {
|
|
479
583
|
restApiConfig.enabled = process.env.MJ_REST_API_ENABLED === 'true';
|
|
480
584
|
if (restApiConfig.enabled) {
|
|
@@ -490,12 +594,10 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
490
594
|
restApiConfig.excludeEntities = process.env.MJ_REST_API_EXCLUDE_ENTITIES.split(',').map(e => e.trim());
|
|
491
595
|
}
|
|
492
596
|
|
|
493
|
-
//
|
|
494
|
-
setupRESTEndpoints(app, restApiConfig
|
|
597
|
+
// No per-route authMiddleware needed — unified auth middleware already ran
|
|
598
|
+
setupRESTEndpoints(app, restApiConfig);
|
|
495
599
|
|
|
496
|
-
// GraphQL middleware (
|
|
497
|
-
// Note: Type assertion needed due to @apollo/server bundling older @types/express types
|
|
498
|
-
// that are incompatible with Express 5.x types (missing 'param' property)
|
|
600
|
+
// ─── GraphQL middleware (contextFunction reads req.userPayload, no re-auth) ─────
|
|
499
601
|
app.use(
|
|
500
602
|
graphqlRootPath,
|
|
501
603
|
cors<cors.CorsRequest>(),
|
|
@@ -509,9 +611,16 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
509
611
|
dataSource: extendConnectionPoolWithQuery(dataSources[0].dataSource), // default read-write data source
|
|
510
612
|
dataSources // all data source
|
|
511
613
|
}),
|
|
512
|
-
})
|
|
614
|
+
})
|
|
513
615
|
);
|
|
514
616
|
|
|
617
|
+
// ─── Post-route middleware (error handlers, catch-alls) ─────
|
|
618
|
+
if (options?.ExpressMiddlewareAfter) {
|
|
619
|
+
for (const mw of options.ExpressMiddlewareAfter) {
|
|
620
|
+
app.use(mw);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
515
624
|
// Initialize and start scheduled jobs service if enabled
|
|
516
625
|
let scheduledJobsService: ScheduledJobsService | null = null;
|
|
517
626
|
if (configInfo.scheduledJobs?.enabled) {
|
|
@@ -525,6 +634,46 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
525
634
|
}
|
|
526
635
|
}
|
|
527
636
|
|
|
637
|
+
// Register provider-level hooks with the global HookRegistry.
|
|
638
|
+
// Each entry can be a plain function or a { hook, Priority, Namespace } object.
|
|
639
|
+
if (options?.PreRunViewHooks) {
|
|
640
|
+
for (const entry of options.PreRunViewHooks) {
|
|
641
|
+
registerHookEntry('PreRunView', entry);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (options?.PostRunViewHooks) {
|
|
645
|
+
for (const entry of options.PostRunViewHooks) {
|
|
646
|
+
registerHookEntry('PostRunView', entry);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (options?.PreSaveHooks) {
|
|
650
|
+
for (const entry of options.PreSaveHooks) {
|
|
651
|
+
registerHookEntry('PreSave', entry);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Auto-register multi-tenancy hooks when enabled in config
|
|
656
|
+
// (The tenant Express middleware was already registered above in the post-auth slot)
|
|
657
|
+
if (configInfo.multiTenancy?.enabled) {
|
|
658
|
+
console.log('[MultiTenancy] Enabled — registering tenant isolation hooks');
|
|
659
|
+
const tenantConfig = configInfo.multiTenancy;
|
|
660
|
+
|
|
661
|
+
// Register tenant PreRunView hook (injects WHERE clauses)
|
|
662
|
+
// Priority 50 + namespace allows middle layers to replace with their own implementation
|
|
663
|
+
HookRegistry.Register('PreRunView', createTenantPreRunViewHook(tenantConfig), {
|
|
664
|
+
Priority: 50,
|
|
665
|
+
Namespace: 'mj:tenantFilter',
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// Register tenant PreSave hook (validates tenant column on writes)
|
|
669
|
+
HookRegistry.Register('PreSave', createTenantPreSaveHook(tenantConfig), {
|
|
670
|
+
Priority: 50,
|
|
671
|
+
Namespace: 'mj:tenantSave',
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
console.log(`[MultiTenancy] Context source: ${tenantConfig.contextSource}, scoping: ${tenantConfig.scopingStrategy}, write protection: ${tenantConfig.writeProtection}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
528
677
|
if (options?.onBeforeServe) {
|
|
529
678
|
await Promise.resolve(options.onBeforeServe());
|
|
530
679
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-tenant data separation framework.
|
|
3
|
+
*
|
|
4
|
+
* Provides factory functions that create Express middleware and provider hooks
|
|
5
|
+
* based on the `multiTenancy` section of mj.config.cjs. All functions return
|
|
6
|
+
* standard hook/middleware types from WS2 so they integrate seamlessly with
|
|
7
|
+
* the MJServer extensibility system.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RequestHandler } from 'express';
|
|
11
|
+
import type { PreRunViewHook, PreSaveHook } from '@memberjunction/core';
|
|
12
|
+
import { Metadata, type UserInfo, type TenantContext } from '@memberjunction/core';
|
|
13
|
+
import type { MultiTenancyConfig } from '../config.js';
|
|
14
|
+
import type { UserPayload } from '../types.js';
|
|
15
|
+
|
|
16
|
+
/** Custom tenant context extractor signature */
|
|
17
|
+
export type TenantContextExtractor = (
|
|
18
|
+
user: UserInfo,
|
|
19
|
+
req: Express.Request
|
|
20
|
+
) => Promise<{ TenantID: string } | null>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates Express middleware that resolves and attaches TenantContext
|
|
24
|
+
* to the authenticated user's UserInfo for each request.
|
|
25
|
+
*
|
|
26
|
+
* This middleware runs in the post-auth slot (after `createUnifiedAuthMiddleware`)
|
|
27
|
+
* so `req.userPayload` is available. It reads the tenant ID from the configured
|
|
28
|
+
* source and attaches it directly to `userPayload.userRecord.TenantContext`.
|
|
29
|
+
*
|
|
30
|
+
* By the time GraphQL resolvers or REST handlers run, the contextUser already
|
|
31
|
+
* has TenantContext set — no deferred pickup via `req['__mj_tenantId']` needed.
|
|
32
|
+
*/
|
|
33
|
+
export function createTenantMiddleware(config: MultiTenancyConfig): RequestHandler {
|
|
34
|
+
return (req, _res, next) => {
|
|
35
|
+
const userPayload = (req as { userPayload?: UserPayload }).userPayload;
|
|
36
|
+
if (!userPayload?.userRecord) {
|
|
37
|
+
// No authenticated user — skip tenant resolution
|
|
38
|
+
next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (config.contextSource === 'header') {
|
|
43
|
+
const tenantId = req.headers[config.tenantHeader.toLowerCase()] as string | undefined;
|
|
44
|
+
if (tenantId) {
|
|
45
|
+
attachTenantContext(userPayload.userRecord as UserInfo, tenantId, 'header');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
next();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Attaches TenantContext to a UserInfo object.
|
|
54
|
+
* Called from the GraphQL context function after authentication.
|
|
55
|
+
*/
|
|
56
|
+
export function attachTenantContext(
|
|
57
|
+
user: UserInfo,
|
|
58
|
+
tenantId: string,
|
|
59
|
+
source: TenantContext['Source']
|
|
60
|
+
): void {
|
|
61
|
+
user.TenantContext = { TenantID: tenantId, Source: source };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Determines whether a given entity name should have tenant filtering applied.
|
|
66
|
+
*/
|
|
67
|
+
function isEntityScoped(entityName: string, config: MultiTenancyConfig): boolean {
|
|
68
|
+
// Auto-exclude core MJ entities (entities in the __mj schema)
|
|
69
|
+
if (config.autoExcludeCoreEntities) {
|
|
70
|
+
const md = new Metadata();
|
|
71
|
+
const entity = md.Entities.find(
|
|
72
|
+
e => e.Name.trim().toLowerCase() === entityName.trim().toLowerCase()
|
|
73
|
+
);
|
|
74
|
+
if (entity && entity.SchemaName === '__mj') {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const normalizedName = entityName.trim().toLowerCase();
|
|
80
|
+
const normalizedScoped = config.scopedEntities.map(e => e.trim().toLowerCase());
|
|
81
|
+
|
|
82
|
+
if (config.scopingStrategy === 'allowlist') {
|
|
83
|
+
// Only entities explicitly listed are scoped
|
|
84
|
+
return normalizedScoped.includes(normalizedName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Denylist: all entities are scoped EXCEPT those listed
|
|
88
|
+
return !normalizedScoped.includes(normalizedName);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Checks if a user has an admin role that bypasses tenant filtering.
|
|
93
|
+
*/
|
|
94
|
+
function isAdminUser(user: UserInfo, adminRoles: string[]): boolean {
|
|
95
|
+
if (!user.UserRoles || user.UserRoles.length === 0) return false;
|
|
96
|
+
const normalizedAdmin = adminRoles.map(r => r.trim().toLowerCase());
|
|
97
|
+
return user.UserRoles.some(
|
|
98
|
+
ur => normalizedAdmin.includes(ur.Role?.trim().toLowerCase() ?? '')
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creates a PreRunViewHook that auto-injects tenant WHERE clauses
|
|
104
|
+
* into RunView queries for scoped entities.
|
|
105
|
+
*/
|
|
106
|
+
export function createTenantPreRunViewHook(config: MultiTenancyConfig): PreRunViewHook {
|
|
107
|
+
return (params, contextUser) => {
|
|
108
|
+
// No tenant context → no filtering
|
|
109
|
+
if (!contextUser?.TenantContext) return params;
|
|
110
|
+
|
|
111
|
+
// Admin users bypass tenant filtering
|
|
112
|
+
if (isAdminUser(contextUser, config.adminRoles)) return params;
|
|
113
|
+
|
|
114
|
+
// Resolve entity name from params
|
|
115
|
+
const entityName = params.EntityName;
|
|
116
|
+
if (!entityName) return params; // Can't filter without knowing the entity
|
|
117
|
+
|
|
118
|
+
// Check if this entity should be scoped
|
|
119
|
+
if (!isEntityScoped(entityName, config)) return params;
|
|
120
|
+
|
|
121
|
+
// Determine which column holds the tenant ID
|
|
122
|
+
const tenantColumn = config.entityColumnMappings[entityName] ?? config.defaultTenantColumn;
|
|
123
|
+
const tenantFilter = `[${tenantColumn}] = '${contextUser.TenantContext.TenantID}'`;
|
|
124
|
+
|
|
125
|
+
// Inject the tenant filter
|
|
126
|
+
if (params.ExtraFilter && typeof params.ExtraFilter === 'string' && params.ExtraFilter.trim().length > 0) {
|
|
127
|
+
params.ExtraFilter = `(${params.ExtraFilter}) AND ${tenantFilter}`;
|
|
128
|
+
} else {
|
|
129
|
+
params.ExtraFilter = tenantFilter;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return params;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Creates a PreSaveHook that validates the tenant column on writes.
|
|
138
|
+
*
|
|
139
|
+
* In 'strict' mode, rejects saves where the tenant column value doesn't
|
|
140
|
+
* match the user's TenantContext. In 'log' mode, warns but allows. In
|
|
141
|
+
* 'off' mode, this hook is a no-op.
|
|
142
|
+
*/
|
|
143
|
+
export function createTenantPreSaveHook(config: MultiTenancyConfig): PreSaveHook {
|
|
144
|
+
return (entity, contextUser) => {
|
|
145
|
+
// No validation needed if write protection is off
|
|
146
|
+
if (config.writeProtection === 'off') return true;
|
|
147
|
+
|
|
148
|
+
// No tenant context → no validation
|
|
149
|
+
if (!contextUser?.TenantContext) return true;
|
|
150
|
+
|
|
151
|
+
// Admin users bypass write validation
|
|
152
|
+
if (isAdminUser(contextUser, config.adminRoles)) return true;
|
|
153
|
+
|
|
154
|
+
// Check if this entity should be scoped
|
|
155
|
+
const entityName = entity.EntityInfo?.Name;
|
|
156
|
+
if (!entityName) return true;
|
|
157
|
+
if (!isEntityScoped(entityName, config)) return true;
|
|
158
|
+
|
|
159
|
+
// Determine which column holds the tenant ID
|
|
160
|
+
const tenantColumn = config.entityColumnMappings[entityName] ?? config.defaultTenantColumn;
|
|
161
|
+
|
|
162
|
+
// Get the value of the tenant column from the entity
|
|
163
|
+
const tenantFieldValue = entity.Get(tenantColumn);
|
|
164
|
+
|
|
165
|
+
// For new records without the tenant column set, auto-assign the tenant ID
|
|
166
|
+
if (!entity.IsSaved && (tenantFieldValue === null || tenantFieldValue === undefined)) {
|
|
167
|
+
entity.Set(tenantColumn, contextUser.TenantContext.TenantID);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Validate the tenant column matches
|
|
172
|
+
if (tenantFieldValue && String(tenantFieldValue) !== contextUser.TenantContext.TenantID) {
|
|
173
|
+
const message = `Save rejected: ${entityName} record belongs to tenant '${tenantFieldValue}' but user is in tenant '${contextUser.TenantContext.TenantID}'`;
|
|
174
|
+
if (config.writeProtection === 'strict') {
|
|
175
|
+
return message; // Reject with error message
|
|
176
|
+
}
|
|
177
|
+
// 'log' mode — warn but allow
|
|
178
|
+
console.warn(`[MultiTenancy] ${message}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return true;
|
|
182
|
+
};
|
|
183
|
+
}
|