@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
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 '@apollo/server/express4';
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
- export type MJServerOptions = {
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
- // Initialize LocalCacheManager with the server-side storage provider (in-memory)
305
- await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider);
306
- console.log('LocalCacheManager initialized');
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({ schema }, { httpServer, serverCleanup });
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
- // Setup REST API endpoints BEFORE GraphQL (since graphqlRootPath may be '/' which catches all routes)
413
- const authMiddleware = async (req, res, next) => {
414
- try {
415
- const sessionIdRaw = req.headers['x-session-id'];
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
- // Build public URL for OAuth callbacks
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
- // Set up OAuth callback routes at /oauth (independent of REST API)
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
- // OAuth status, initiate, and exchange endpoints require authentication
460
- // Must also have CORS for frontend requests and JSON body parsing
461
- app.use('/oauth', oauthCors, BodyParser.json(), authMiddleware, authenticatedRouter);
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
- // Get REST API configuration
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
- // Set up REST endpoints with the configured options and auth middleware
494
- setupRESTEndpoints(app, restApiConfig, authMiddleware);
597
+ // No per-route authMiddleware needed unified auth middleware already ran
598
+ setupRESTEndpoints(app, restApiConfig);
495
599
 
496
- // GraphQL middleware (after REST so /api/v1/* routes are handled first)
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
- }) as unknown as express.RequestHandler
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
+ }