@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/context.ts CHANGED
@@ -12,6 +12,7 @@ import { DataSourceInfo, UserPayload } from './types.js';
12
12
  import { GetReadOnlyDataSource, GetReadWriteDataSource } from './util.js';
13
13
  import { v4 as uuidv4 } from 'uuid';
14
14
  import e from 'express';
15
+ import type { RequestHandler, Request, Response, NextFunction } from 'express';
15
16
  import { DatabaseProviderBase } from '@memberjunction/core';
16
17
  import { SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
17
18
  import { AuthProviderFactory } from './auth/AuthProviderFactory.js';
@@ -206,23 +207,118 @@ export const getUserPayload = async (
206
207
  }
207
208
  };
208
209
 
210
+ /**
211
+ * Extracts auth headers and builds a RequestContext from an Express request.
212
+ * Shared by both the unified auth middleware and the WebSocket context.
213
+ */
214
+ function extractAuthInputs(req: IncomingMessage): {
215
+ bearerToken: string;
216
+ sessionId: string;
217
+ requestDomain: string | undefined;
218
+ systemApiKey: string;
219
+ userApiKey: string;
220
+ requestContext: RequestContext;
221
+ } {
222
+ const sessionIdRaw = req.headers['x-session-id'];
223
+ const requestDomain = url.parse(req.headers.origin || '');
224
+ const sessionId = sessionIdRaw ? sessionIdRaw.toString() : '';
225
+ const bearerToken = req.headers.authorization ?? '';
226
+ const systemApiKey = String(req.headers['x-mj-api-key']);
227
+ const userApiKey = String(req.headers['x-api-key']);
228
+ const expressReq = req as e.Request;
229
+
230
+ const requestContext: RequestContext = {
231
+ endpoint: expressReq.path || expressReq.url || '/api',
232
+ method: expressReq.method || 'POST',
233
+ operationName: undefined,
234
+ ipAddress: expressReq.ip || expressReq.socket?.remoteAddress || undefined,
235
+ userAgent: req.headers['user-agent'] as string | undefined,
236
+ };
237
+
238
+ return {
239
+ bearerToken,
240
+ sessionId,
241
+ requestDomain: requestDomain?.hostname ?? undefined,
242
+ systemApiKey,
243
+ userApiKey,
244
+ requestContext,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Creates a single Express middleware that authenticates ALL routes.
250
+ *
251
+ * On success, attaches `req.userPayload` (UserPayload) and `req['mjUser']` (UserInfo)
252
+ * so downstream middleware and route handlers can read the authenticated user without
253
+ * re-authenticating. On failure, returns 401.
254
+ *
255
+ * Register OAuth callback routes BEFORE this middleware so they remain unauthenticated.
256
+ */
257
+ export function createUnifiedAuthMiddleware(
258
+ dataSources: DataSourceInfo[]
259
+ ): RequestHandler {
260
+ return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
261
+ // Skip CORS preflight requests — OPTIONS requests never carry credentials
262
+ // and must pass through to the downstream cors() middleware to get proper headers.
263
+ if (req.method === 'OPTIONS') {
264
+ next();
265
+ return;
266
+ }
267
+
268
+ try {
269
+ const { bearerToken, sessionId, requestDomain, systemApiKey, userApiKey, requestContext } =
270
+ extractAuthInputs(req);
271
+
272
+ const userPayload = await getUserPayload(
273
+ bearerToken,
274
+ sessionId,
275
+ dataSources,
276
+ requestDomain,
277
+ systemApiKey,
278
+ userApiKey,
279
+ requestContext
280
+ );
281
+
282
+ if (!userPayload) {
283
+ res.status(401).json({ error: 'Invalid token' });
284
+ return;
285
+ }
286
+
287
+ // Attach to request for downstream consumers
288
+ req.userPayload = userPayload;
289
+ // Standard Express convention and MJ REST convention
290
+ (req as unknown as Record<string, unknown>)['user'] = userPayload;
291
+ (req as unknown as Record<string, unknown>)['mjUser'] = userPayload.userRecord;
292
+
293
+ next();
294
+ } catch (error) {
295
+ if (error instanceof TokenExpiredError) {
296
+ res.status(401).json({
297
+ errors: [{
298
+ message: 'Token expired',
299
+ extensions: { code: 'JWT_EXPIRED' }
300
+ }]
301
+ });
302
+ return;
303
+ }
304
+ console.error('Auth error:', error);
305
+ res.status(401).json({ error: 'Authentication failed' });
306
+ }
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Creates the GraphQL context from an already-authenticated request.
312
+ *
313
+ * The unified auth middleware has already resolved `req.userPayload` before this runs.
314
+ * This function reads the payload and creates per-request database providers.
315
+ */
209
316
  export const contextFunction =
210
317
  ({ setupComplete$, dataSource, dataSources }: { setupComplete$: Subject<unknown>; dataSource: sql.ConnectionPool, dataSources: DataSourceInfo[] }) =>
211
318
  async ({ req }: { req: IncomingMessage }) => {
212
319
  await firstValueFrom(setupComplete$); // wait for setup to complete before processing the request
213
320
 
214
- // Extract request data first (synchronous operations)
215
- const sessionIdRaw = req.headers['x-session-id'];
216
- const requestDomain = url.parse(req.headers.origin || '');
217
- const sessionId = sessionIdRaw ? sessionIdRaw.toString() : '';
218
- const bearerToken = req.headers.authorization ?? '';
219
-
220
- // Two types of API keys:
221
- // - x-mj-api-key: System API key for system-level operations (authenticates as system user)
222
- // - X-API-Key: User API key (mj_sk_*) for user-authenticated operations
223
- const systemApiKey = String(req.headers['x-mj-api-key']);
224
- const userApiKey = String(req.headers['x-api-key']);
225
-
321
+ // Log the GraphQL operation for debugging (skip introspection noise)
226
322
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
227
323
  const reqAny = req as any;
228
324
  const operationName: string | undefined = reqAny.body?.operationName;
@@ -230,103 +326,114 @@ export const contextFunction =
230
326
  console.log({ operationName, variables: reqAny.body?.variables || undefined });
231
327
  }
232
328
 
233
- // Build request context for API key logging
234
- // Note: responseTimeMs is not available at auth time, only endpoint/method/ip/ua
329
+ // Auth already happened in the unified auth middleware — just read the result
235
330
  const expressReq = req as e.Request;
236
- const requestContext: RequestContext = {
237
- endpoint: expressReq.path || expressReq.url || '/api',
238
- method: expressReq.method || 'POST',
239
- operationName: operationName,
240
- ipAddress: expressReq.ip || expressReq.socket?.remoteAddress || undefined,
241
- userAgent: req.headers['user-agent'] as string | undefined,
242
- };
243
-
244
- const userPayload = await getUserPayload(
245
- bearerToken,
246
- sessionId,
247
- dataSources,
248
- requestDomain?.hostname ? requestDomain.hostname : undefined,
249
- systemApiKey,
250
- userApiKey,
251
- requestContext
252
- );
331
+ const userPayload = expressReq.userPayload;
332
+ if (!userPayload) {
333
+ throw new AuthenticationError('No user payload — auth middleware may not have run');
334
+ }
253
335
 
254
336
  if (Metadata.Provider.Entities.length === 0 ) {
255
337
  console.warn('WARNING: No entities found in global/shared metadata, this can often be due to the use of **global** Metadata/RunView/DB Providers in a multi-user environment. Check your code to make sure you are using the providers passed to you in AppContext by MJServer and not calling new Metadata() new RunView() new RunQuery() and similar patterns as those are unstable at times in multi-user server environments!!!');
256
338
  }
257
339
 
258
340
  // Create per-request provider instance based on database type
259
- const dbType = process.env.DB_TYPE?.toLowerCase();
260
- const isPostgres = dbType === 'postgresql' || dbType === 'postgres' || dbType === 'pg';
261
-
262
- let p: DatabaseProviderBase;
263
- if (isPostgres) {
264
- const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
265
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
266
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
267
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
268
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
269
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
270
-
271
- const pgProvider = new PostgreSQLDataProvider();
272
- const pgConfig = new PostgreSQLProviderConfigData(
273
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgUser, Password: pgPass },
274
- mj_core_schema,
275
- 0,
276
- undefined,
277
- undefined,
278
- false, // use existing metadata from global provider
279
- );
341
+ const providers = await createPerRequestProviders(dataSource, dataSources);
280
342
 
281
- // Share the connection pool from the primary provider to avoid pool exhaustion.
282
- // Each per-request provider was creating its own pool, leading to "too many clients" errors.
283
- const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool };
284
- if (primaryProvider?.DatabaseConnection) {
285
- await pgProvider.ConfigWithSharedPool(pgConfig, primaryProvider.DatabaseConnection);
286
- } else {
287
- await pgProvider.Config(pgConfig);
288
- }
289
- p = pgProvider;
290
- } else {
291
- const config = new SQLServerProviderConfigData(dataSource, mj_core_schema, 0, undefined, undefined, false);
292
- const sqlProvider = new SQLServerDataProvider();
293
- await sqlProvider.Config(config);
294
- p = sqlProvider as unknown as DatabaseProviderBase;
295
- }
343
+ return {
344
+ dataSource,
345
+ dataSources,
346
+ userPayload,
347
+ providers,
348
+ };
349
+ };
296
350
 
297
- let rp: DatabaseProviderBase | null = null;
298
- if (!isPostgres) {
299
- try {
300
- const readOnlyDataSource = GetReadOnlyDataSource(dataSources, { allowFallbackToReadWrite: false });
301
- if (readOnlyDataSource) {
302
- const roProvider = new SQLServerDataProvider();
303
- const rConfig = new SQLServerProviderConfigData(readOnlyDataSource, mj_core_schema, 0, undefined, undefined, false);
304
- await roProvider.Config(rConfig);
305
- rp = roProvider as unknown as DatabaseProviderBase;
306
- }
307
- }
308
- catch (_err) {
309
- // no read only data source available, so rp will remain null, this is OK!
310
- }
311
- }
351
+ /**
352
+ * Creates per-request DatabaseProviderBase instances for the GraphQL context.
353
+ * Handles both SQL Server and PostgreSQL paths.
354
+ */
355
+ async function createPerRequestProviders(
356
+ dataSource: sql.ConnectionPool,
357
+ dataSources: DataSourceInfo[]
358
+ ): Promise<Array<{ provider: DatabaseProviderBase; type: 'Read-Write' | 'Read-Only' }>> {
359
+ const dbType = process.env.DB_TYPE?.toLowerCase();
360
+ const isPostgres = dbType === 'postgresql' || dbType === 'postgres' || dbType === 'pg';
361
+
362
+ let p: DatabaseProviderBase;
363
+ if (isPostgres) {
364
+ p = await createPostgresProvider();
365
+ } else {
366
+ const config = new SQLServerProviderConfigData(dataSource, mj_core_schema, 0, undefined, undefined, false);
367
+ const sqlProvider = new SQLServerDataProvider();
368
+ await sqlProvider.Config(config);
369
+ p = sqlProvider as unknown as DatabaseProviderBase;
370
+ }
371
+
372
+ const providers: Array<{ provider: DatabaseProviderBase; type: 'Read-Write' | 'Read-Only' }> = [
373
+ { provider: p, type: 'Read-Write' }
374
+ ];
312
375
 
313
- const providers = [{
314
- provider: p,
315
- type: 'Read-Write' as 'Read-Write' | 'Read-Only'
316
- }];
376
+ if (!isPostgres) {
377
+ const rp = await tryCreateReadOnlyProvider(dataSources);
317
378
  if (rp) {
318
- providers.push({
319
- provider: rp,
320
- type: 'Read-Only' as 'Read-Write' | 'Read-Only'
321
- });
379
+ providers.push({ provider: rp, type: 'Read-Only' });
322
380
  }
381
+ }
323
382
 
324
- const contextResult = {
325
- dataSource,
326
- dataSources,
327
- userPayload: userPayload,
328
- providers,
329
- };
383
+ return providers;
384
+ }
385
+
386
+ /**
387
+ * Creates a PostgreSQL per-request provider, sharing the connection pool
388
+ * from the primary provider to avoid pool exhaustion.
389
+ */
390
+ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
391
+ const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
392
+ const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
393
+ const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
394
+ const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
395
+ const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
396
+ const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
397
+
398
+ const pgProvider = new PostgreSQLDataProvider();
399
+ const pgConfig = new PostgreSQLProviderConfigData(
400
+ { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgUser, Password: pgPass },
401
+ mj_core_schema,
402
+ 0,
403
+ undefined,
404
+ undefined,
405
+ false, // use existing metadata from global provider
406
+ );
407
+
408
+ // Share the connection pool from the primary provider to avoid pool exhaustion
409
+ const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool };
410
+ if (primaryProvider?.DatabaseConnection) {
411
+ await pgProvider.ConfigWithSharedPool(pgConfig, primaryProvider.DatabaseConnection);
412
+ } else {
413
+ await pgProvider.Config(pgConfig);
414
+ }
330
415
 
331
- return contextResult;
332
- };
416
+ return pgProvider;
417
+ }
418
+
419
+ /**
420
+ * Attempts to create a read-only SQL Server provider.
421
+ * Returns null if no read-only data source is available.
422
+ */
423
+ async function tryCreateReadOnlyProvider(
424
+ dataSources: DataSourceInfo[]
425
+ ): Promise<DatabaseProviderBase | null> {
426
+ try {
427
+ const readOnlyDataSource = GetReadOnlyDataSource(dataSources, { allowFallbackToReadWrite: false });
428
+ if (readOnlyDataSource) {
429
+ const roProvider = new SQLServerDataProvider();
430
+ const rConfig = new SQLServerProviderConfigData(readOnlyDataSource, mj_core_schema, 0, undefined, undefined, false);
431
+ await roProvider.Config(rConfig);
432
+ return roProvider as unknown as DatabaseProviderBase;
433
+ }
434
+ }
435
+ catch (_err) {
436
+ // no read only data source available, this is OK
437
+ }
438
+ return null;
439
+ }