@memberjunction/server 5.8.0 → 5.10.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 (91) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/skip-agent.d.ts.map +1 -1
  3. package/dist/agents/skip-agent.js +1 -0
  4. package/dist/agents/skip-agent.js.map +1 -1
  5. package/dist/agents/skip-sdk.d.ts +6 -0
  6. package/dist/agents/skip-sdk.d.ts.map +1 -1
  7. package/dist/agents/skip-sdk.js +5 -3
  8. package/dist/agents/skip-sdk.js.map +1 -1
  9. package/dist/apolloServer/index.d.ts +10 -2
  10. package/dist/apolloServer/index.d.ts.map +1 -1
  11. package/dist/apolloServer/index.js +22 -8
  12. package/dist/apolloServer/index.js.map +1 -1
  13. package/dist/config.d.ts +125 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +23 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts +17 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +144 -62
  20. package/dist/context.js.map +1 -1
  21. package/dist/generated/generated.d.ts +210 -0
  22. package/dist/generated/generated.d.ts.map +1 -1
  23. package/dist/generated/generated.js +1049 -0
  24. package/dist/generated/generated.js.map +1 -1
  25. package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
  26. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
  27. package/dist/generic/CacheInvalidationResolver.js +80 -0
  28. package/dist/generic/CacheInvalidationResolver.js.map +1 -0
  29. package/dist/generic/PubSubManager.d.ts +27 -0
  30. package/dist/generic/PubSubManager.d.ts.map +1 -0
  31. package/dist/generic/PubSubManager.js +41 -0
  32. package/dist/generic/PubSubManager.js.map +1 -0
  33. package/dist/generic/ResolverBase.d.ts +14 -0
  34. package/dist/generic/ResolverBase.d.ts.map +1 -1
  35. package/dist/generic/ResolverBase.js +66 -4
  36. package/dist/generic/ResolverBase.js.map +1 -1
  37. package/dist/hooks.d.ts +65 -0
  38. package/dist/hooks.d.ts.map +1 -0
  39. package/dist/hooks.js +14 -0
  40. package/dist/hooks.js.map +1 -0
  41. package/dist/index.d.ts +7 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +173 -45
  44. package/dist/index.js.map +1 -1
  45. package/dist/multiTenancy/index.d.ts +47 -0
  46. package/dist/multiTenancy/index.d.ts.map +1 -0
  47. package/dist/multiTenancy/index.js +152 -0
  48. package/dist/multiTenancy/index.js.map +1 -0
  49. package/dist/resolvers/CurrentUserContextResolver.d.ts +18 -0
  50. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
  51. package/dist/resolvers/CurrentUserContextResolver.js +54 -0
  52. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -0
  53. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
  55. package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
  57. package/dist/rest/RESTEndpointHandler.d.ts +3 -1
  58. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  59. package/dist/rest/RESTEndpointHandler.js +14 -33
  60. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  61. package/dist/test-dynamic-plugin.d.ts +6 -0
  62. package/dist/test-dynamic-plugin.d.ts.map +1 -0
  63. package/dist/test-dynamic-plugin.js +18 -0
  64. package/dist/test-dynamic-plugin.js.map +1 -0
  65. package/dist/types.d.ts +9 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/package.json +61 -57
  69. package/src/__tests__/bcsaas-integration.test.ts +455 -0
  70. package/src/__tests__/middleware-integration.test.ts +877 -0
  71. package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
  72. package/src/__tests__/multiTenancy.security.test.ts +334 -0
  73. package/src/__tests__/multiTenancy.test.ts +225 -0
  74. package/src/__tests__/unifiedAuth.test.ts +416 -0
  75. package/src/agents/skip-agent.ts +1 -0
  76. package/src/agents/skip-sdk.ts +13 -3
  77. package/src/apolloServer/index.ts +32 -16
  78. package/src/config.ts +25 -0
  79. package/src/context.ts +205 -98
  80. package/src/generated/generated.ts +746 -1
  81. package/src/generic/CacheInvalidationResolver.ts +66 -0
  82. package/src/generic/PubSubManager.ts +46 -0
  83. package/src/generic/ResolverBase.ts +70 -4
  84. package/src/hooks.ts +77 -0
  85. package/src/index.ts +199 -49
  86. package/src/multiTenancy/index.ts +183 -0
  87. package/src/resolvers/CurrentUserContextResolver.ts +39 -0
  88. package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
  89. package/src/rest/RESTEndpointHandler.ts +23 -42
  90. package/src/test-dynamic-plugin.ts +36 -0
  91. 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';
@@ -115,12 +122,36 @@ export * from './resolvers/UserFavoriteResolver.js';
115
122
  export * from './resolvers/UserResolver.js';
116
123
  export * from './resolvers/UserViewResolver.js';
117
124
  export * from './resolvers/VersionHistoryResolver.js';
125
+ export * from './resolvers/CurrentUserContextResolver.js';
118
126
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
119
127
 
120
128
  export * from './generated/generated.js';
129
+ export * from './hooks.js';
130
+ export * from './multiTenancy/index.js';
121
131
 
132
+ import type { ServerExtensibilityOptions, HookWithOptions } from './hooks.js';
133
+ import { HookRegistry } from '@memberjunction/core';
134
+ import type { HookRegistrationOptions } from '@memberjunction/core';
135
+ import type { ApolloServerPlugin } from '@apollo/server';
136
+ import { createTenantMiddleware, createTenantPreRunViewHook, createTenantPreSaveHook } from './multiTenancy/index.js';
122
137
 
123
- export type MJServerOptions = {
138
+ /**
139
+ * Register a hook that may be a plain function or a `{ hook, Priority, Namespace }` object.
140
+ * Dynamic packages (e.g., BCSaaS) return hooks in object form to declare registration metadata.
141
+ */
142
+ function registerHookEntry<T>(hookName: string, entry: T | HookWithOptions<T>): void {
143
+ if (typeof entry === 'function') {
144
+ HookRegistry.Register(hookName, entry);
145
+ } else if (entry && typeof entry === 'object' && 'hook' in entry) {
146
+ const { hook, Priority, Namespace } = entry as HookWithOptions<T>;
147
+ const options: HookRegistrationOptions = {};
148
+ if (Priority != null) options.Priority = Priority;
149
+ if (Namespace != null) options.Namespace = Namespace;
150
+ HookRegistry.Register(hookName, hook, options);
151
+ }
152
+ }
153
+
154
+ export type MJServerOptions = ServerExtensibilityOptions & {
124
155
  onBeforeServe?: () => void | Promise<void>;
125
156
  restApiOptions?: Partial<RESTApiOptions>; // Options for REST API configuration
126
157
  };
@@ -301,9 +332,53 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
301
332
  console.log('Server telemetry disabled');
302
333
  }
303
334
 
304
- // Initialize LocalCacheManager with the server-side storage provider (in-memory)
305
- await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider);
306
- console.log('LocalCacheManager initialized');
335
+ // Optionally inject Redis as the shared storage provider for cross-server cache invalidation
336
+ if (process.env.REDIS_URL) {
337
+ const redisProvider = new RedisLocalStorageProvider({
338
+ url: process.env.REDIS_URL,
339
+ keyPrefix: process.env.REDIS_KEY_PREFIX || 'mj',
340
+ enablePubSub: true,
341
+ enableLogging: true,
342
+ });
343
+ (Metadata.Provider as GenericDatabaseProvider).SetLocalStorageProvider(redisProvider);
344
+ await redisProvider.StartListening();
345
+
346
+ // Connect Redis pub/sub events to LocalCacheManager callback dispatch
347
+ // so cross-server cache invalidation messages are routed to registered callbacks
348
+ redisProvider.OnCacheChanged((event) => {
349
+ const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
350
+ console.log(`[MJAPI] Redis pub/sub → DispatchCacheChange: ${event.Action} for "${event.CacheKey}" from server ${sourceShort}`);
351
+ LocalCacheManager.Instance.DispatchCacheChange(event);
352
+
353
+ // Also broadcast to connected browser clients via GraphQL subscription
354
+ // Extract entity name from the cache key (format: EntityName|Filter|OrderBy|...)
355
+ const entityName = event.CacheKey ? event.CacheKey.split('|')[0] : '';
356
+ if (entityName) {
357
+ PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
358
+ entityName,
359
+ primaryKeyValues: null, // entity-level invalidation
360
+ action: event.Action || 'save',
361
+ sourceServerId: event.SourceServerId || 'unknown',
362
+ timestamp: new Date(),
363
+ });
364
+ }
365
+ });
366
+
367
+ console.log(`Redis cache provider connected: ${process.env.REDIS_URL}`);
368
+ }
369
+
370
+ // If Redis is available, swap LocalCacheManager's storage provider to Redis.
371
+ // LocalCacheManager may have already been initialized (with in-memory provider)
372
+ // during engine loading. SetStorageProvider migrates cached data to Redis.
373
+ if (process.env.REDIS_URL) {
374
+ await LocalCacheManager.Instance.SetStorageProvider(Metadata.Provider.LocalStorageProvider);
375
+ console.log('LocalCacheManager: storage provider swapped to Redis');
376
+ }
377
+ // Ensure LocalCacheManager is initialized (no-op if already done during engine loading)
378
+ if (!LocalCacheManager.Instance.IsInitialized) {
379
+ await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider);
380
+ console.log('LocalCacheManager initialized');
381
+ }
307
382
 
308
383
  // Initialize APIKeyEngine singleton — reads apiKeyGeneration from mj.config.cjs automatically
309
384
  // This must happen before any request handler calls GetAPIKeyEngine()
@@ -342,6 +417,15 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
342
417
  Object.values(module).filter((value) => typeof value === 'function')
343
418
  ) as BuildSchemaOptions['resolvers'];
344
419
 
420
+ // Create an explicit PubSub instance so we can reference it outside of resolvers
421
+ // graphql-subscriptions v3 renamed asyncIterator→asyncIterableIterator, but
422
+ // type-graphql still calls asyncIterator. Shim for compatibility.
423
+ const pubSub = new PubSub() as unknown as Record<string, unknown>;
424
+ if (!pubSub.asyncIterator && typeof pubSub.asyncIterableIterator === 'function') {
425
+ pubSub.asyncIterator = pubSub.asyncIterableIterator;
426
+ }
427
+ PubSubManager.Instance.SetPubSubEngine(pubSub as unknown as PubSubEngine);
428
+
345
429
  let schema = mergeSchemas({
346
430
  schemas: [
347
431
  buildSchemaSync({
@@ -349,6 +433,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
349
433
  validate: false,
350
434
  scalarsMap: [{ type: Date, scalar: GraphQLTimestamp }],
351
435
  emitSchemaFile: websiteRunFromPackage !== 1,
436
+ pubSub,
352
437
  }),
353
438
  ],
354
439
  typeDefs: [requireSystemUserDirective.typeDefs, publicDirective.typeDefs],
@@ -356,6 +441,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
356
441
  schema = requireSystemUserDirective.transformer(schema);
357
442
  schema = publicDirective.transformer(schema);
358
443
 
444
+ // Apply user-provided schema transformers (after built-in directive transformers)
445
+ if (options?.SchemaTransformers) {
446
+ for (const transformer of options.SchemaTransformers) {
447
+ schema = transformer(schema);
448
+ }
449
+ }
450
+
359
451
  const httpServer = createServer(app);
360
452
 
361
453
  const webSocketServer = new WebSocketServer({ server: httpServer, path: graphqlRootPath });
@@ -385,7 +477,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
385
477
  webSocketServer
386
478
  );
387
479
 
388
- const apolloServer = buildApolloServer({ schema }, { httpServer, serverCleanup });
480
+ const apolloServer = buildApolloServer(
481
+ { schema },
482
+ { httpServer, serverCleanup },
483
+ options?.ApolloPlugins
484
+ );
389
485
  await apolloServer.start();
390
486
 
391
487
  // Fix #8: Add compression for better throughput performance
@@ -409,72 +505,81 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
409
505
  level: 6
410
506
  }));
411
507
 
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' });
508
+ // Apply user-provided Express middleware (after compression, before routes)
509
+ if (options?.ExpressMiddlewareBefore) {
510
+ for (const mw of options.ExpressMiddlewareBefore) {
511
+ app.use(mw);
435
512
  }
436
- };
513
+ }
514
+
515
+ // Escape hatch for advanced Express app configuration
516
+ if (options?.ConfigureExpressApp) {
517
+ await Promise.resolve(options.ConfigureExpressApp(app));
518
+ }
437
519
 
438
- // Build public URL for OAuth callbacks
520
+ // ─── OAuth callback routes (unauthenticated, registered BEFORE auth) ─────
439
521
  const oauthPublicUrl = configInfo.publicUrl || `${configInfo.baseUrl}:${configInfo.graphqlPort}${configInfo.graphqlRootPath || ''}`;
440
522
  console.log(`[OAuth] publicUrl: ${oauthPublicUrl}`);
441
523
 
442
- // Set up OAuth callback routes at /oauth (independent of REST API)
443
- // These must be registered BEFORE GraphQL middleware since graphqlRootPath may be '/'
524
+ let oauthAuthenticatedRouter: ReturnType<typeof createOAuthCallbackHandler>['authenticatedRouter'] | undefined;
444
525
  if (oauthPublicUrl) {
445
526
  const { callbackRouter, authenticatedRouter } = createOAuthCallbackHandler({
446
527
  publicUrl: oauthPublicUrl,
447
- // TODO: These should be configurable to point to the MJ Explorer UI
448
528
  successRedirectUrl: `${oauthPublicUrl}/oauth/success`,
449
529
  errorRedirectUrl: `${oauthPublicUrl}/oauth/error`
450
530
  });
531
+ oauthAuthenticatedRouter = authenticatedRouter;
451
532
 
452
- // Create CORS middleware for OAuth routes (needed for cross-origin requests from frontend)
453
533
  const oauthCors = cors<cors.CorsRequest>();
454
534
 
455
535
  // OAuth callback is unauthenticated (called by external auth server)
456
536
  app.use('/oauth', oauthCors, callbackRouter);
457
537
  console.log('[OAuth] Callback route registered at /oauth/callback');
538
+ }
539
+
540
+ // ─── Global CORS (before auth so 401 responses include CORS headers) ─────
541
+ // Without this, the browser blocks 401 responses from the auth middleware
542
+ // because they lack Access-Control-Allow-Origin headers, preventing the
543
+ // client from reading the error code and triggering token refresh.
544
+ app.use(cors<cors.CorsRequest>());
458
545
 
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);
546
+ // ─── Unified auth middleware (replaces both REST authMiddleware and contextFunction auth) ─────
547
+ app.use(createUnifiedAuthMiddleware(dataSources));
548
+
549
+ // ─── Built-in post-auth middleware (multi-tenancy) ─────
550
+ // Config-driven multi-tenancy middleware runs after auth so it can read req.userPayload.
551
+ if (configInfo.multiTenancy?.enabled) {
552
+ const tenantMiddleware = createTenantMiddleware(configInfo.multiTenancy);
553
+ app.use(tenantMiddleware);
554
+ }
555
+
556
+ // ─── Post-auth middleware from plugins ─────
557
+ // Middleware here has access to the authenticated user via req.userPayload.
558
+ // Use this for tenant context resolution, org membership loading, etc.
559
+ if (options?.ExpressMiddlewarePostAuth) {
560
+ for (const mw of options.ExpressMiddlewarePostAuth) {
561
+ app.use(mw);
562
+ }
563
+ }
564
+
565
+ // ─── OAuth authenticated routes (auth already handled by unified middleware) ─────
566
+ if (oauthAuthenticatedRouter) {
567
+ const oauthCors = cors<cors.CorsRequest>();
568
+ app.use('/oauth', oauthCors, BodyParser.json(), oauthAuthenticatedRouter);
462
569
  console.log('[OAuth] Authenticated routes registered at /oauth/status, /oauth/initiate, and /oauth/exchange');
463
570
  }
464
571
 
465
- // Get REST API configuration
572
+ // ─── REST API endpoints (auth already handled by unified middleware) ─────
466
573
  const restApiConfig = {
467
574
  enabled: configInfo.restApiOptions?.enabled ?? false,
468
575
  includeEntities: configInfo.restApiOptions?.includeEntities,
469
576
  excludeEntities: configInfo.restApiOptions?.excludeEntities,
470
577
  };
471
578
 
472
- // Apply options from server options if provided (these override the config file)
473
579
  if (options?.restApiOptions) {
474
580
  Object.assign(restApiConfig, options.restApiOptions);
475
581
  }
476
582
 
477
- // Get REST API configuration from environment variables if present (env vars override everything)
478
583
  if (process.env.MJ_REST_API_ENABLED !== undefined) {
479
584
  restApiConfig.enabled = process.env.MJ_REST_API_ENABLED === 'true';
480
585
  if (restApiConfig.enabled) {
@@ -490,12 +595,10 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
490
595
  restApiConfig.excludeEntities = process.env.MJ_REST_API_EXCLUDE_ENTITIES.split(',').map(e => e.trim());
491
596
  }
492
597
 
493
- // Set up REST endpoints with the configured options and auth middleware
494
- setupRESTEndpoints(app, restApiConfig, authMiddleware);
598
+ // No per-route authMiddleware needed unified auth middleware already ran
599
+ setupRESTEndpoints(app, restApiConfig);
495
600
 
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)
601
+ // ─── GraphQL middleware (contextFunction reads req.userPayload, no re-auth) ─────
499
602
  app.use(
500
603
  graphqlRootPath,
501
604
  cors<cors.CorsRequest>(),
@@ -509,9 +612,16 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
509
612
  dataSource: extendConnectionPoolWithQuery(dataSources[0].dataSource), // default read-write data source
510
613
  dataSources // all data source
511
614
  }),
512
- }) as unknown as express.RequestHandler
615
+ })
513
616
  );
514
617
 
618
+ // ─── Post-route middleware (error handlers, catch-alls) ─────
619
+ if (options?.ExpressMiddlewareAfter) {
620
+ for (const mw of options.ExpressMiddlewareAfter) {
621
+ app.use(mw);
622
+ }
623
+ }
624
+
515
625
  // Initialize and start scheduled jobs service if enabled
516
626
  let scheduledJobsService: ScheduledJobsService | null = null;
517
627
  if (configInfo.scheduledJobs?.enabled) {
@@ -525,6 +635,46 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
525
635
  }
526
636
  }
527
637
 
638
+ // Register provider-level hooks with the global HookRegistry.
639
+ // Each entry can be a plain function or a { hook, Priority, Namespace } object.
640
+ if (options?.PreRunViewHooks) {
641
+ for (const entry of options.PreRunViewHooks) {
642
+ registerHookEntry('PreRunView', entry);
643
+ }
644
+ }
645
+ if (options?.PostRunViewHooks) {
646
+ for (const entry of options.PostRunViewHooks) {
647
+ registerHookEntry('PostRunView', entry);
648
+ }
649
+ }
650
+ if (options?.PreSaveHooks) {
651
+ for (const entry of options.PreSaveHooks) {
652
+ registerHookEntry('PreSave', entry);
653
+ }
654
+ }
655
+
656
+ // Auto-register multi-tenancy hooks when enabled in config
657
+ // (The tenant Express middleware was already registered above in the post-auth slot)
658
+ if (configInfo.multiTenancy?.enabled) {
659
+ console.log('[MultiTenancy] Enabled — registering tenant isolation hooks');
660
+ const tenantConfig = configInfo.multiTenancy;
661
+
662
+ // Register tenant PreRunView hook (injects WHERE clauses)
663
+ // Priority 50 + namespace allows middle layers to replace with their own implementation
664
+ HookRegistry.Register('PreRunView', createTenantPreRunViewHook(tenantConfig), {
665
+ Priority: 50,
666
+ Namespace: 'mj:tenantFilter',
667
+ });
668
+
669
+ // Register tenant PreSave hook (validates tenant column on writes)
670
+ HookRegistry.Register('PreSave', createTenantPreSaveHook(tenantConfig), {
671
+ Priority: 50,
672
+ Namespace: 'mj:tenantSave',
673
+ });
674
+
675
+ console.log(`[MultiTenancy] Context source: ${tenantConfig.contextSource}, scoping: ${tenantConfig.scopingStrategy}, write protection: ${tenantConfig.writeProtection}`);
676
+ }
677
+
528
678
  if (options?.onBeforeServe) {
529
679
  await Promise.resolve(options.onBeforeServe());
530
680
  }
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Resolver for the `CurrentUserTenantContext` GraphQL query.
3
+ *
4
+ * Returns the server-set `TenantContext` from the authenticated user's `UserInfo`.
5
+ * This is populated by post-auth middleware (e.g., BCSaaS's `bcTenantContextMiddleware`)
6
+ * and serialized as JSON so the client can auto-stamp `UserInfo.TenantContext`.
7
+ *
8
+ * On the client, `GraphQLDataProvider.GetCurrentUser()` batches this query alongside
9
+ * `CurrentUser` — making plugins stack-layer agnostic without any client-side code.
10
+ *
11
+ * Returns `null` when no middleware has set TenantContext (vanilla MJ deployment).
12
+ */
13
+
14
+ import { Query, Resolver, Ctx } from 'type-graphql';
15
+ import { GraphQLJSONObject } from 'graphql-type-json';
16
+ import { AppContext } from '../types.js';
17
+ import { ResolverBase } from '../generic/ResolverBase.js';
18
+
19
+ @Resolver()
20
+ export class CurrentUserContextResolver extends ResolverBase {
21
+ @Query(() => GraphQLJSONObject, {
22
+ nullable: true,
23
+ description: 'Returns the server-set TenantContext for the authenticated user. Null when no tenant middleware is active.',
24
+ })
25
+ async CurrentUserTenantContext(
26
+ @Ctx() context: AppContext
27
+ ): Promise<Record<string, unknown> | null> {
28
+ await this.CheckAPIKeyScopeAuthorization('user:read', '*', context.userPayload);
29
+
30
+ const userRecord = context.userPayload.userRecord;
31
+ if (!userRecord?.TenantContext) {
32
+ return null;
33
+ }
34
+
35
+ // Serialize the full TenantContext (which may be an extended type like BCTenantContext).
36
+ // JSON serialization captures all enumerable properties including those from subtypes.
37
+ return { ...userRecord.TenantContext } as Record<string, unknown>;
38
+ }
39
+ }