@memberjunction/server 5.11.0 → 5.13.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/dist/agents/skip-sdk.d.ts.map +1 -1
- package/dist/agents/skip-sdk.js +1 -0
- package/dist/agents/skip-sdk.js.map +1 -1
- package/dist/generated/generated.d.ts +163 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +897 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.js +3 -3
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +121 -90
- package/dist/index.js.map +1 -1
- package/dist/middleware/BaseServerMiddleware.d.ts +103 -0
- package/dist/middleware/BaseServerMiddleware.d.ts.map +1 -0
- package/dist/middleware/BaseServerMiddleware.js +104 -0
- package/dist/middleware/BaseServerMiddleware.js.map +1 -0
- package/dist/middleware/MJTenantFilterMiddleware.d.ts +22 -0
- package/dist/middleware/MJTenantFilterMiddleware.d.ts.map +1 -0
- package/dist/middleware/MJTenantFilterMiddleware.js +41 -0
- package/dist/middleware/MJTenantFilterMiddleware.js.map +1 -0
- package/dist/middleware/index.d.ts +3 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +3 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +8 -0
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/CreateQueryResolver.d.ts +2 -0
- package/dist/resolvers/CreateQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/CreateQueryResolver.js +11 -0
- package/dist/resolvers/CreateQueryResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +16 -2
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +2 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +20 -0
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +24 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +264 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/package.json +59 -59
- package/src/__tests__/bcsaas-integration.test.ts +1 -1
- package/src/agents/skip-sdk.ts +1 -0
- package/src/generated/generated.ts +623 -1
- package/src/generic/ResolverBase.ts +3 -3
- package/src/index.ts +139 -96
- package/src/middleware/BaseServerMiddleware.ts +141 -0
- package/src/middleware/MJTenantFilterMiddleware.ts +39 -0
- package/src/middleware/index.ts +2 -0
- package/src/resolvers/AdhocQueryResolver.ts +8 -0
- package/src/resolvers/CreateQueryResolver.ts +9 -0
- package/src/resolvers/GetDataResolver.ts +18 -2
- package/src/resolvers/QueryResolver.ts +18 -0
- package/src/resolvers/RunAIAgentResolver.ts +301 -2
- package/dist/hooks.d.ts +0 -65
- package/dist/hooks.d.ts.map +0 -1
- package/dist/hooks.js +0 -14
- package/dist/hooks.js.map +0 -1
- package/dist/test-dynamic-plugin.d.ts +0 -6
- package/dist/test-dynamic-plugin.d.ts.map +0 -1
- package/dist/test-dynamic-plugin.js +0 -18
- package/dist/test-dynamic-plugin.js.map +0 -1
- package/src/hooks.ts +0 -77
- package/src/test-dynamic-plugin.ts +0 -36
|
@@ -1061,7 +1061,7 @@ export class ResolverBase {
|
|
|
1061
1061
|
if (await entityObject.Save()) {
|
|
1062
1062
|
// save worked, fire the AfterCreate event and then return all the data
|
|
1063
1063
|
await this.AfterCreate(provider, input); // fire event
|
|
1064
|
-
|
|
1064
|
+
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1065
1065
|
const contextUser = this.GetUserFromPayload(userPayload);
|
|
1066
1066
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1067
1067
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
|
|
@@ -1145,7 +1145,7 @@ export class ResolverBase {
|
|
|
1145
1145
|
if (await entityObject.Save()) {
|
|
1146
1146
|
// save worked, fire afterevent and return all the data
|
|
1147
1147
|
await this.AfterUpdate(provider, input); // fire event
|
|
1148
|
-
|
|
1148
|
+
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1149
1149
|
|
|
1150
1150
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1151
1151
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
@@ -1372,7 +1372,7 @@ export class ResolverBase {
|
|
|
1372
1372
|
|
|
1373
1373
|
if (await entityObject.Delete(options)) {
|
|
1374
1374
|
await this.AfterDelete(provider, key); // fire event
|
|
1375
|
-
|
|
1375
|
+
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1376
1376
|
return returnValue;
|
|
1377
1377
|
} else {
|
|
1378
1378
|
throw new GraphQLError(entityObject.LatestResult?.Message ?? 'Unknown error', {
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,8 @@ dotenv.config({ quiet: true });
|
|
|
4
4
|
|
|
5
5
|
import { expressMiddleware } from '@as-integrations/express5';
|
|
6
6
|
import { mergeSchemas } from '@graphql-tools/schema';
|
|
7
|
-
import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport } from '@memberjunction/core';
|
|
8
|
-
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
7
|
+
import { Metadata, DatabasePlatform, SetProvider, StartupManager as StartupManagerImport, BaseEntity, BaseEntityEvent } from '@memberjunction/core';
|
|
8
|
+
import { MJGlobal, MJEventType, UUIDsEqual } from '@memberjunction/global';
|
|
9
9
|
import { setupSQLServerClient, SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
10
10
|
import { extendConnectionPoolWithQuery } from './util.js';
|
|
11
11
|
import { default as BodyParser } from 'body-parser';
|
|
@@ -126,32 +126,16 @@ export * from './resolvers/CurrentUserContextResolver.js';
|
|
|
126
126
|
export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
|
|
127
127
|
|
|
128
128
|
export * from './generated/generated.js';
|
|
129
|
-
export * from './hooks.js';
|
|
130
129
|
export * from './multiTenancy/index.js';
|
|
130
|
+
export * from './middleware/index.js';
|
|
131
131
|
|
|
132
|
-
import
|
|
133
|
-
import {
|
|
134
|
-
import type { HookRegistrationOptions } from '@memberjunction/core';
|
|
132
|
+
import { RegisterDataHook } from '@memberjunction/core';
|
|
133
|
+
import type { RequestHandler, ErrorRequestHandler } from 'express';
|
|
135
134
|
import type { ApolloServerPlugin } from '@apollo/server';
|
|
136
|
-
import {
|
|
135
|
+
import type { GraphQLSchema } from 'graphql';
|
|
136
|
+
import { BaseServerMiddleware } from './middleware/BaseServerMiddleware.js';
|
|
137
137
|
|
|
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 & {
|
|
138
|
+
export type MJServerOptions = {
|
|
155
139
|
onBeforeServe?: () => void | Promise<void>;
|
|
156
140
|
restApiOptions?: Partial<RESTApiOptions>; // Options for REST API configuration
|
|
157
141
|
};
|
|
@@ -417,6 +401,91 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
417
401
|
Object.values(module).filter((value) => typeof value === 'function')
|
|
418
402
|
) as BuildSchemaOptions['resolvers'];
|
|
419
403
|
|
|
404
|
+
// ─── Discover all server middleware via ClassFactory ─────────────────────
|
|
405
|
+
const allRegistrations = MJGlobal.Instance.ClassFactory.GetAllRegistrations(BaseServerMiddleware);
|
|
406
|
+
|
|
407
|
+
// Deduplicate by key (same key -> highest priority wins).
|
|
408
|
+
// This is the replacement mechanism: if BCSaaS registers with the same
|
|
409
|
+
// key as MJ's built-in tenant filter, ClassFactory's priority system
|
|
410
|
+
// ensures only the higher-priority one is used.
|
|
411
|
+
const uniqueKeys = new Set(
|
|
412
|
+
allRegistrations.map(r => r.Key?.trim().toLowerCase()).filter((k): k is string => k != null)
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const winnerRegistrations: typeof allRegistrations = [];
|
|
416
|
+
for (const key of uniqueKeys) {
|
|
417
|
+
const winner = MJGlobal.Instance.ClassFactory.GetRegistration(BaseServerMiddleware, key);
|
|
418
|
+
if (winner) winnerRegistrations.push(winner);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Instantiate and filter by Enabled
|
|
422
|
+
const middlewares: BaseServerMiddleware[] = [];
|
|
423
|
+
for (const reg of winnerRegistrations) {
|
|
424
|
+
const MwClass = reg.SubClass as new () => BaseServerMiddleware;
|
|
425
|
+
const mw = new MwClass();
|
|
426
|
+
if (mw.Enabled) {
|
|
427
|
+
middlewares.push(mw);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Initialize all middleware
|
|
432
|
+
for (const mw of middlewares) {
|
|
433
|
+
await mw.Initialize();
|
|
434
|
+
console.log(` [Middleware] ${mw.Label}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Collect middleware contributions for each pipeline stage
|
|
438
|
+
const mwPreAuth: RequestHandler[] = [];
|
|
439
|
+
const mwPostAuth: RequestHandler[] = [];
|
|
440
|
+
const mwPostRoute: (RequestHandler | ErrorRequestHandler)[] = [];
|
|
441
|
+
const mwApolloPlugins: ApolloServerPlugin[] = [];
|
|
442
|
+
const mwSchemaTransformers: ((schema: GraphQLSchema) => GraphQLSchema)[] = [];
|
|
443
|
+
const mwResolverPaths: string[] = [];
|
|
444
|
+
|
|
445
|
+
for (const mw of middlewares) {
|
|
446
|
+
mwPreAuth.push(...mw.GetPreAuthMiddleware());
|
|
447
|
+
mwPostAuth.push(...mw.GetPostAuthMiddleware());
|
|
448
|
+
mwPostRoute.push(...mw.GetPostRouteMiddleware());
|
|
449
|
+
mwApolloPlugins.push(...mw.GetApolloPlugins());
|
|
450
|
+
mwSchemaTransformers.push(...mw.GetSchemaTransformers());
|
|
451
|
+
mwResolverPaths.push(...mw.GetResolverPaths());
|
|
452
|
+
|
|
453
|
+
// Express app configuration escape hatch
|
|
454
|
+
if (mw.ConfigureExpressApp) {
|
|
455
|
+
await mw.ConfigureExpressApp(app);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Extract hook methods and register in the global hook store
|
|
459
|
+
// (ProviderBase/BaseEntity will read these via GetDataHooks())
|
|
460
|
+
RegisterDataHook('PreRunView', mw.PreRunView.bind(mw));
|
|
461
|
+
RegisterDataHook('PostRunView', mw.PostRunView.bind(mw));
|
|
462
|
+
RegisterDataHook('PreSave', mw.PreSave.bind(mw));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── Resolve middleware-contributed resolver paths and merge into resolvers ───
|
|
466
|
+
let allResolvers = resolvers;
|
|
467
|
+
if (mwResolverPaths.length > 0) {
|
|
468
|
+
const mwGlobs = mwResolverPaths.flatMap((p) => (isWindows ? p.replace(/\\/g, '/') : p));
|
|
469
|
+
const mwResolverFiles = fg.globSync(mwGlobs);
|
|
470
|
+
if (mwResolverFiles.length > 0) {
|
|
471
|
+
const mwModules = await Promise.all(
|
|
472
|
+
mwResolverFiles.map((modulePath) => {
|
|
473
|
+
try {
|
|
474
|
+
return import(isWindows ? `file://${modulePath}` : modulePath);
|
|
475
|
+
} catch (e) {
|
|
476
|
+
console.error(`Error loading middleware resolver at '${modulePath}'`, e);
|
|
477
|
+
throw e;
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
const mwResolvers = mwModules.flatMap((module) =>
|
|
482
|
+
Object.values(module).filter((value) => typeof value === 'function')
|
|
483
|
+
);
|
|
484
|
+
allResolvers = [...resolvers, ...mwResolvers] as BuildSchemaOptions['resolvers'];
|
|
485
|
+
console.log(` [Middleware Resolvers] Loaded ${mwResolverFiles.length} resolver file(s) from middleware`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
420
489
|
// Create an explicit PubSub instance so we can reference it outside of resolvers
|
|
421
490
|
// graphql-subscriptions v3 renamed asyncIterator→asyncIterableIterator, but
|
|
422
491
|
// type-graphql still calls asyncIterator. Shim for compatibility.
|
|
@@ -426,10 +495,32 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
426
495
|
}
|
|
427
496
|
PubSubManager.Instance.SetPubSubEngine(pubSub as unknown as PubSubEngine);
|
|
428
497
|
|
|
498
|
+
// Global listener: broadcast CACHE_INVALIDATION to all browser clients whenever
|
|
499
|
+
// ANY BaseEntity save/delete occurs on this server — regardless of whether it
|
|
500
|
+
// originated from a GraphQL mutation or internal server-side code (agents, actions,
|
|
501
|
+
// task orchestrator, etc.). This closes the gap where server-internal saves were
|
|
502
|
+
// invisible to browser BaseEngine caches.
|
|
503
|
+
MJGlobal.Instance.GetEventListener(false).subscribe((event) => {
|
|
504
|
+
if (event.event === MJEventType.ComponentEvent && event.eventCode === BaseEntity.BaseEventCode) {
|
|
505
|
+
const beEvent = event.args as BaseEntityEvent;
|
|
506
|
+
if (beEvent.type === 'save' || beEvent.type === 'delete') {
|
|
507
|
+
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
|
|
508
|
+
entityName: beEvent.baseEntity.EntityInfo.Name,
|
|
509
|
+
primaryKeyValues: JSON.stringify(beEvent.baseEntity.PrimaryKey.KeyValuePairs),
|
|
510
|
+
action: beEvent.type,
|
|
511
|
+
sourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
512
|
+
timestamp: new Date(),
|
|
513
|
+
originSessionId: null,
|
|
514
|
+
recordData: beEvent.type === 'save' ? JSON.stringify(beEvent.baseEntity.GetAll()) : undefined,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
429
520
|
let schema = mergeSchemas({
|
|
430
521
|
schemas: [
|
|
431
522
|
buildSchemaSync({
|
|
432
|
-
resolvers,
|
|
523
|
+
resolvers: allResolvers,
|
|
433
524
|
validate: false,
|
|
434
525
|
scalarsMap: [{ type: Date, scalar: GraphQLTimestamp }],
|
|
435
526
|
emitSchemaFile: websiteRunFromPackage !== 1,
|
|
@@ -441,11 +532,9 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
441
532
|
schema = requireSystemUserDirective.transformer(schema);
|
|
442
533
|
schema = publicDirective.transformer(schema);
|
|
443
534
|
|
|
444
|
-
// Apply
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
schema = transformer(schema);
|
|
448
|
-
}
|
|
535
|
+
// Apply middleware-contributed schema transformers (after built-in directive transformers)
|
|
536
|
+
for (const transformer of mwSchemaTransformers) {
|
|
537
|
+
schema = transformer(schema);
|
|
449
538
|
}
|
|
450
539
|
|
|
451
540
|
const httpServer = createServer(app);
|
|
@@ -480,7 +569,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
480
569
|
const apolloServer = buildApolloServer(
|
|
481
570
|
{ schema },
|
|
482
571
|
{ httpServer, serverCleanup },
|
|
483
|
-
|
|
572
|
+
mwApolloPlugins.length > 0 ? mwApolloPlugins : undefined
|
|
484
573
|
);
|
|
485
574
|
await apolloServer.start();
|
|
486
575
|
|
|
@@ -505,16 +594,16 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
505
594
|
level: 6
|
|
506
595
|
}));
|
|
507
596
|
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
}
|
|
597
|
+
// Health check endpoint - registered before auth middleware so cloud
|
|
598
|
+
// platform probes (Azure App Service, AWS ALB, k8s, etc.) don't
|
|
599
|
+
// generate noisy auth errors in the logs.
|
|
600
|
+
app.get('/healthcheck', (_req, res) => {
|
|
601
|
+
res.status(200).json({ status: 'ok' });
|
|
602
|
+
});
|
|
514
603
|
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
604
|
+
// Apply middleware-contributed pre-auth handlers (after compression, before routes)
|
|
605
|
+
for (const mw of mwPreAuth) {
|
|
606
|
+
app.use(mw);
|
|
518
607
|
}
|
|
519
608
|
|
|
520
609
|
// ─── OAuth callback routes (unauthenticated, registered BEFORE auth) ─────
|
|
@@ -546,20 +635,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
546
635
|
// ─── Unified auth middleware (replaces both REST authMiddleware and contextFunction auth) ─────
|
|
547
636
|
app.use(createUnifiedAuthMiddleware(dataSources));
|
|
548
637
|
|
|
549
|
-
// ───
|
|
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 ─────
|
|
638
|
+
// ─── Post-auth middleware from BaseServerMiddleware plugins ─────
|
|
557
639
|
// Middleware here has access to the authenticated user via req.userPayload.
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
640
|
+
// Contributions come from @RegisterClass(BaseServerMiddleware, key) classes
|
|
641
|
+
// (e.g., MJTenantFilterMiddleware for multi-tenancy, BCSaaSMiddleware for org context).
|
|
642
|
+
for (const mw of mwPostAuth) {
|
|
643
|
+
app.use(mw);
|
|
563
644
|
}
|
|
564
645
|
|
|
565
646
|
// ─── OAuth authenticated routes (auth already handled by unified middleware) ─────
|
|
@@ -616,10 +697,8 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
616
697
|
);
|
|
617
698
|
|
|
618
699
|
// ─── Post-route middleware (error handlers, catch-alls) ─────
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
app.use(mw);
|
|
622
|
-
}
|
|
700
|
+
for (const mw of mwPostRoute) {
|
|
701
|
+
app.use(mw);
|
|
623
702
|
}
|
|
624
703
|
|
|
625
704
|
// Initialize and start scheduled jobs service if enabled
|
|
@@ -635,45 +714,9 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
635
714
|
}
|
|
636
715
|
}
|
|
637
716
|
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
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
|
-
}
|
|
717
|
+
// Data hooks are now registered via BaseServerMiddleware classes above
|
|
718
|
+
// (e.g., MJTenantFilterMiddleware registers PreRunView and PreSave hooks).
|
|
719
|
+
// No config-bag hook registration needed.
|
|
677
720
|
|
|
678
721
|
if (options?.onBeforeServe) {
|
|
679
722
|
await Promise.resolve(options.onBeforeServe());
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for server middleware that intercepts the MJServer request pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Subclasses register via @RegisterClass(BaseServerMiddleware, key) and are
|
|
5
|
+
* discovered by serve() via ClassFactory.GetAllRegistrations().
|
|
6
|
+
*
|
|
7
|
+
* Key-based deduplication: If two middleware classes register with the same key,
|
|
8
|
+
* ClassFactory returns the highest-priority one -- the other is replaced.
|
|
9
|
+
* This is how BCSaaS replaces MJ's built-in tenant filtering.
|
|
10
|
+
*
|
|
11
|
+
* For adding new routes/endpoints (webhooks, bot endpoints), see
|
|
12
|
+
* ServerExtensionsCore and BaseServerExtension (PR #2037).
|
|
13
|
+
* BaseServerMiddleware is for intercepting the existing pipeline, not adding to it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { RequestHandler, ErrorRequestHandler, Application } from 'express';
|
|
17
|
+
import type { ApolloServerPlugin } from '@apollo/server';
|
|
18
|
+
import type { GraphQLSchema } from 'graphql';
|
|
19
|
+
import type { RunViewParams, UserInfo, BaseEntity, RunViewResult } from '@memberjunction/core';
|
|
20
|
+
|
|
21
|
+
export abstract class BaseServerMiddleware {
|
|
22
|
+
// --- Identity ---
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Human-readable label for logging (e.g., 'mj:tenantFilter', 'bcsaas').
|
|
26
|
+
*/
|
|
27
|
+
abstract get Label(): string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether this middleware is active. Override to check config, env vars, etc.
|
|
31
|
+
* Disabled middleware classes are never instantiated or activated by serve().
|
|
32
|
+
* Default: true.
|
|
33
|
+
*/
|
|
34
|
+
get Enabled(): boolean { return true; }
|
|
35
|
+
|
|
36
|
+
// --- Lifecycle ---
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Optional async initialization (read config, warm caches, etc.).
|
|
40
|
+
* Called by serve() after instantiation, before middleware/hooks are extracted.
|
|
41
|
+
*/
|
|
42
|
+
async Initialize(): Promise<void> { /* no-op by default */ }
|
|
43
|
+
|
|
44
|
+
// --- Express Middleware ---
|
|
45
|
+
// Each method corresponds to a named slot in the request pipeline.
|
|
46
|
+
// Override to contribute middleware at that stage.
|
|
47
|
+
// Default implementations return empty arrays (no-op).
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Express middleware applied BEFORE authentication.
|
|
51
|
+
* Runs after compression but before OAuth/REST/GraphQL routes.
|
|
52
|
+
* Use for: rate limiting, request logging, custom headers.
|
|
53
|
+
*/
|
|
54
|
+
GetPreAuthMiddleware(): RequestHandler[] { return []; }
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Express middleware that runs AFTER authentication has resolved UserInfo.
|
|
58
|
+
* The authenticated user payload is available at req.userPayload.
|
|
59
|
+
* Use for: tenant context resolution, org membership loading.
|
|
60
|
+
*/
|
|
61
|
+
GetPostAuthMiddleware(): RequestHandler[] { return []; }
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Express middleware/error handlers applied AFTER all routes.
|
|
65
|
+
* Use for: catch-all error handlers, response logging.
|
|
66
|
+
*/
|
|
67
|
+
GetPostRouteMiddleware(): (RequestHandler | ErrorRequestHandler)[] { return []; }
|
|
68
|
+
|
|
69
|
+
// --- Express App Configuration ---
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Optional escape hatch for advanced Express app configuration.
|
|
73
|
+
* Called once during server setup with the Express app instance.
|
|
74
|
+
* Use for: trust proxy settings, custom CORS, body parser config.
|
|
75
|
+
* Return undefined to skip (default).
|
|
76
|
+
*/
|
|
77
|
+
ConfigureExpressApp?(app: Application): void | Promise<void>;
|
|
78
|
+
|
|
79
|
+
// --- Apollo / GraphQL Extensions ---
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Additional Apollo Server plugins merged with built-in plugins.
|
|
83
|
+
* Use for: query tracing, caching, custom error formatting.
|
|
84
|
+
*/
|
|
85
|
+
GetApolloPlugins(): ApolloServerPlugin[] { return []; }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Schema transformers applied after built-in directive transformers.
|
|
89
|
+
* Use for: custom directives, schema stitching, field-level auth.
|
|
90
|
+
*/
|
|
91
|
+
GetSchemaTransformers(): ((schema: GraphQLSchema) => GraphQLSchema)[] { return []; }
|
|
92
|
+
|
|
93
|
+
// --- Resolver Discovery ---
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Additional TypeGraphQL resolver glob paths to include in the Apollo schema.
|
|
97
|
+
* serve() collects these from all active middleware and adds them to the
|
|
98
|
+
* resolvers array passed to buildSchema().
|
|
99
|
+
*
|
|
100
|
+
* Return absolute glob paths (use `path.join(__dirname, ...)` or equivalent).
|
|
101
|
+
*
|
|
102
|
+
* Use for: Open App resolvers, domain-specific GraphQL queries/mutations
|
|
103
|
+
* that live outside MJServer's built-in resolver set.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* GetResolverPaths(): string[] {
|
|
108
|
+
* return [path.join(__dirname, 'resolvers', '*Resolver.{js,ts}')];
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
GetResolverPaths(): string[] { return []; }
|
|
113
|
+
|
|
114
|
+
// --- Data Hooks ---
|
|
115
|
+
// Override any of these to intercept data operations.
|
|
116
|
+
// Default implementations are pass-through (no-op).
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hook that runs before a RunView operation. Can modify the RunViewParams
|
|
120
|
+
* (e.g., injecting tenant filters) before execution.
|
|
121
|
+
*/
|
|
122
|
+
PreRunView(params: RunViewParams, contextUser: UserInfo | undefined): RunViewParams | Promise<RunViewParams> {
|
|
123
|
+
return params;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Hook that runs after a RunView operation completes. Can modify the result
|
|
128
|
+
* (e.g., filtering or augmenting data) before it is returned to the caller.
|
|
129
|
+
*/
|
|
130
|
+
PostRunView(params: RunViewParams, results: RunViewResult, contextUser: UserInfo | undefined): RunViewResult | Promise<RunViewResult> {
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Hook that runs before a Save operation on a BaseEntity.
|
|
136
|
+
* Return true to allow, false to reject silently, or a string to reject with that error message.
|
|
137
|
+
*/
|
|
138
|
+
PreSave(entity: BaseEntity, contextUser: UserInfo | undefined): boolean | string | Promise<boolean | string> {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MJ's built-in multi-tenancy middleware.
|
|
3
|
+
*
|
|
4
|
+
* Registers with key 'mj:tenantFilter' so that downstream layers (e.g., BCSaaS)
|
|
5
|
+
* can replace it by registering with the same key at higher ClassFactory priority.
|
|
6
|
+
*
|
|
7
|
+
* Conditionally enabled via configInfo.multiTenancy?.enabled.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
11
|
+
import { BaseServerMiddleware } from './BaseServerMiddleware.js';
|
|
12
|
+
import { configInfo } from '../config.js';
|
|
13
|
+
import { createTenantMiddleware, createTenantPreRunViewHook, createTenantPreSaveHook } from '../multiTenancy/index.js';
|
|
14
|
+
import type { RequestHandler } from 'express';
|
|
15
|
+
import type { RunViewParams, UserInfo, BaseEntity } from '@memberjunction/core';
|
|
16
|
+
|
|
17
|
+
@RegisterClass(BaseServerMiddleware, 'mj:tenantFilter')
|
|
18
|
+
export class MJTenantFilterMiddleware extends BaseServerMiddleware {
|
|
19
|
+
get Label(): string { return 'mj:tenantFilter'; }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Only active when multiTenancy is enabled in config.
|
|
23
|
+
*/
|
|
24
|
+
get Enabled(): boolean {
|
|
25
|
+
return configInfo.multiTenancy?.enabled ?? false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
GetPostAuthMiddleware(): RequestHandler[] {
|
|
29
|
+
return [createTenantMiddleware(configInfo.multiTenancy!)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
PreRunView(params: RunViewParams, contextUser: UserInfo | undefined): RunViewParams | Promise<RunViewParams> {
|
|
33
|
+
return createTenantPreRunViewHook(configInfo.multiTenancy!)(params, contextUser);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
PreSave(entity: BaseEntity, contextUser: UserInfo | undefined): boolean | string | Promise<boolean | string> {
|
|
37
|
+
return createTenantPreSaveHook(configInfo.multiTenancy!)(entity, contextUser);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -76,6 +76,8 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
76
76
|
Results: JSON.stringify(result.recordset ?? []),
|
|
77
77
|
RowCount: result.recordset?.length ?? 0,
|
|
78
78
|
TotalRowCount: result.recordset?.length ?? 0,
|
|
79
|
+
PageNumber: undefined,
|
|
80
|
+
PageSize: undefined,
|
|
79
81
|
ExecutionTime: executionTimeMs,
|
|
80
82
|
ErrorMessage: ''
|
|
81
83
|
};
|
|
@@ -92,6 +94,8 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
92
94
|
Results: '[]',
|
|
93
95
|
RowCount: 0,
|
|
94
96
|
TotalRowCount: 0,
|
|
97
|
+
PageNumber: undefined,
|
|
98
|
+
PageSize: undefined,
|
|
95
99
|
ExecutionTime: executionTimeMs,
|
|
96
100
|
ErrorMessage: `Query execution exceeded ${input.TimeoutSeconds ?? 30} second timeout`
|
|
97
101
|
};
|
|
@@ -105,6 +109,8 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
105
109
|
Results: '[]',
|
|
106
110
|
RowCount: 0,
|
|
107
111
|
TotalRowCount: 0,
|
|
112
|
+
PageNumber: undefined,
|
|
113
|
+
PageSize: undefined,
|
|
108
114
|
ExecutionTime: executionTimeMs,
|
|
109
115
|
ErrorMessage: `Query execution failed: ${errorMessage}`
|
|
110
116
|
};
|
|
@@ -119,6 +125,8 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
119
125
|
Results: '[]',
|
|
120
126
|
RowCount: 0,
|
|
121
127
|
TotalRowCount: 0,
|
|
128
|
+
PageNumber: undefined,
|
|
129
|
+
PageSize: undefined,
|
|
122
130
|
ExecutionTime: 0,
|
|
123
131
|
ErrorMessage: errorMessage
|
|
124
132
|
};
|
|
@@ -293,6 +293,9 @@ export class CreateQueryResultType {
|
|
|
293
293
|
@Field(() => String, { nullable: true })
|
|
294
294
|
EmbeddingModelName?: string;
|
|
295
295
|
|
|
296
|
+
@Field(() => String, { nullable: true })
|
|
297
|
+
TechnicalDescription?: string;
|
|
298
|
+
|
|
296
299
|
// Related collections
|
|
297
300
|
@Field(() => [QueryFieldType], { nullable: true })
|
|
298
301
|
Fields?: QueryFieldType[];
|
|
@@ -349,6 +352,9 @@ export class UpdateQueryResultType {
|
|
|
349
352
|
@Field(() => String, { nullable: true })
|
|
350
353
|
EmbeddingModelName?: string;
|
|
351
354
|
|
|
355
|
+
@Field(() => String, { nullable: true })
|
|
356
|
+
TechnicalDescription?: string;
|
|
357
|
+
|
|
352
358
|
// Related collections
|
|
353
359
|
@Field(() => [QueryFieldType], { nullable: true })
|
|
354
360
|
Fields?: QueryFieldType[];
|
|
@@ -477,6 +483,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
477
483
|
EmbeddingVector: record.EmbeddingVector,
|
|
478
484
|
EmbeddingModelID: record.EmbeddingModelID,
|
|
479
485
|
EmbeddingModelName: record.EmbeddingModel,
|
|
486
|
+
TechnicalDescription: record.TechnicalDescription,
|
|
480
487
|
Fields: record.QueryFields.map(f => ({
|
|
481
488
|
ID: f.ID,
|
|
482
489
|
QueryID: f.QueryID,
|
|
@@ -540,6 +547,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
540
547
|
EmbeddingVector: existingQuery.EmbeddingVector,
|
|
541
548
|
EmbeddingModelID: existingQuery.EmbeddingModelID,
|
|
542
549
|
EmbeddingModelName: existingQuery.EmbeddingModel,
|
|
550
|
+
TechnicalDescription: existingQuery.TechnicalDescription,
|
|
543
551
|
Fields: existingQuery.Fields?.map((f: any) => ({
|
|
544
552
|
ID: f.ID,
|
|
545
553
|
QueryID: f.QueryID,
|
|
@@ -736,6 +744,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
736
744
|
EmbeddingVector: queryEntity.EmbeddingVector,
|
|
737
745
|
EmbeddingModelID: queryEntity.EmbeddingModelID,
|
|
738
746
|
EmbeddingModelName: queryEntity.EmbeddingModel,
|
|
747
|
+
TechnicalDescription: queryEntity.TechnicalDescription,
|
|
739
748
|
Fields: queryEntity.QueryFields.map(f => ({
|
|
740
749
|
ID: f.ID,
|
|
741
750
|
QueryID: f.QueryID,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Arg, Ctx, Field, InputType, ObjectType, Query } from 'type-graphql';
|
|
2
2
|
import { AppContext } from '../types.js';
|
|
3
|
-
import { LogError, LogStatus, Metadata } from '@memberjunction/core';
|
|
3
|
+
import { LogError, LogStatus, Metadata, QueryCompositionEngine } from '@memberjunction/core';
|
|
4
4
|
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
5
5
|
import { v4 as uuidv4 } from 'uuid';
|
|
6
6
|
import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
|
|
7
|
+
import { getDbType } from '../index.js';
|
|
8
|
+
import { getSystemUser } from '../auth/index.js';
|
|
7
9
|
import sql from 'mssql';
|
|
8
10
|
|
|
9
11
|
@InputType()
|
|
@@ -128,13 +130,27 @@ export class GetDataResolver {
|
|
|
128
130
|
throw new Error('Read-only data source not found');
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
// Resolve composition tokens in queries before execution.
|
|
134
|
+
// Queries may contain {{query:"CategoryPath/QueryName(params)"}} tokens that
|
|
135
|
+
// reference reusable queries. These must be resolved to CTEs before raw SQL execution.
|
|
136
|
+
const compositionEngine = new QueryCompositionEngine();
|
|
137
|
+
const platform = getDbType();
|
|
138
|
+
const systemUser = await getSystemUser();
|
|
139
|
+
|
|
131
140
|
// Execute all queries in parallel, but execute each individual query in its own try catch block so that if one fails, the others can still be processed
|
|
132
141
|
// and also so that we can capture the error message for each query and return it
|
|
133
142
|
const results = await Promise.allSettled(
|
|
134
143
|
input.Queries.map(async (query) => {
|
|
135
144
|
try {
|
|
145
|
+
// Resolve composition tokens if present
|
|
146
|
+
let resolvedSQL = query;
|
|
147
|
+
if (compositionEngine.HasCompositionTokens(query)) {
|
|
148
|
+
const compositionResult = compositionEngine.ResolveComposition(query, platform, systemUser);
|
|
149
|
+
resolvedSQL = compositionResult.ResolvedSQL;
|
|
150
|
+
}
|
|
151
|
+
|
|
136
152
|
const request = new sql.Request(readOnlyDataSource);
|
|
137
|
-
const result = await request.query(
|
|
153
|
+
const result = await request.query(resolvedSQL);
|
|
138
154
|
return { result: result.recordset, error: null };
|
|
139
155
|
} catch (err) {
|
|
140
156
|
// Extract clean SQL error message
|