@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.
- package/README.md +1 -0
- package/dist/agents/skip-agent.d.ts.map +1 -1
- package/dist/agents/skip-agent.js +1 -0
- package/dist/agents/skip-agent.js.map +1 -1
- package/dist/agents/skip-sdk.d.ts +6 -0
- package/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +5 -3
- package/dist/agents/skip-sdk.js.map +1 -1
- 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 +210 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1049 -0
- 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 +41 -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 +66 -4
- 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 +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +173 -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/CurrentUserContextResolver.d.ts +18 -0
- package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
- package/dist/resolvers/CurrentUserContextResolver.js +54 -0
- package/dist/resolvers/CurrentUserContextResolver.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/test-dynamic-plugin.d.ts +6 -0
- package/dist/test-dynamic-plugin.d.ts.map +1 -0
- package/dist/test-dynamic-plugin.js +18 -0
- package/dist/test-dynamic-plugin.js.map +1 -0
- 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__/bcsaas-integration.test.ts +455 -0
- package/src/__tests__/middleware-integration.test.ts +877 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
- 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/agents/skip-agent.ts +1 -0
- package/src/agents/skip-sdk.ts +13 -3
- package/src/apolloServer/index.ts +32 -16
- package/src/config.ts +25 -0
- package/src/context.ts +205 -98
- package/src/generated/generated.ts +746 -1
- package/src/generic/CacheInvalidationResolver.ts +66 -0
- package/src/generic/PubSubManager.ts +46 -0
- package/src/generic/ResolverBase.ts +70 -4
- package/src/hooks.ts +77 -0
- package/src/index.ts +199 -49
- package/src/multiTenancy/index.ts +183 -0
- package/src/resolvers/CurrentUserContextResolver.ts +39 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
- package/src/rest/RESTEndpointHandler.ts +23 -42
- package/src/test-dynamic-plugin.ts +36 -0
- 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';
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
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(
|
|
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
|
-
//
|
|
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' });
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
494
|
-
setupRESTEndpoints(app, restApiConfig
|
|
598
|
+
// No per-route authMiddleware needed — unified auth middleware already ran
|
|
599
|
+
setupRESTEndpoints(app, restApiConfig);
|
|
495
600
|
|
|
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)
|
|
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
|
-
})
|
|
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
|
+
}
|