@memberjunction/server 5.8.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/apolloServer/index.d.ts +10 -2
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +22 -8
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +125 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +144 -62
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +207 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1112 -76
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
- package/dist/generic/CacheInvalidationResolver.js +80 -0
- package/dist/generic/CacheInvalidationResolver.js.map +1 -0
- package/dist/generic/PubSubManager.d.ts +27 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -0
- package/dist/generic/PubSubManager.js +42 -0
- package/dist/generic/PubSubManager.js.map +1 -0
- package/dist/generic/ResolverBase.d.ts +14 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +50 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/hooks.d.ts +65 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -45
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts +47 -0
- package/dist/multiTenancy/index.d.ts.map +1 -0
- package/dist/multiTenancy/index.js +152 -0
- package/dist/multiTenancy/index.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
- package/dist/rest/RESTEndpointHandler.d.ts +3 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +14 -33
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +61 -57
- package/src/__tests__/multiTenancy.security.test.ts +334 -0
- package/src/__tests__/multiTenancy.test.ts +225 -0
- package/src/__tests__/unifiedAuth.test.ts +416 -0
- package/src/apolloServer/index.ts +32 -16
- package/src/config.ts +25 -0
- package/src/context.ts +205 -98
- package/src/generated/generated.ts +736 -1
- package/src/generic/CacheInvalidationResolver.ts +66 -0
- package/src/generic/PubSubManager.ts +47 -0
- package/src/generic/ResolverBase.ts +53 -0
- package/src/hooks.ts +77 -0
- package/src/index.ts +198 -49
- package/src/multiTenancy/index.ts +183 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
- package/src/rest/RESTEndpointHandler.ts +23 -42
- package/src/types.ts +10 -0
package/src/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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
+
}
|