@memberjunction/server 5.8.0 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +1 -0
  2. package/dist/agents/skip-agent.d.ts.map +1 -1
  3. package/dist/agents/skip-agent.js +1 -0
  4. package/dist/agents/skip-agent.js.map +1 -1
  5. package/dist/agents/skip-sdk.d.ts +6 -0
  6. package/dist/agents/skip-sdk.d.ts.map +1 -1
  7. package/dist/agents/skip-sdk.js +5 -3
  8. package/dist/agents/skip-sdk.js.map +1 -1
  9. package/dist/apolloServer/index.d.ts +10 -2
  10. package/dist/apolloServer/index.d.ts.map +1 -1
  11. package/dist/apolloServer/index.js +22 -8
  12. package/dist/apolloServer/index.js.map +1 -1
  13. package/dist/config.d.ts +125 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +23 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/context.d.ts +17 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +144 -62
  20. package/dist/context.js.map +1 -1
  21. package/dist/generated/generated.d.ts +210 -0
  22. package/dist/generated/generated.d.ts.map +1 -1
  23. package/dist/generated/generated.js +1049 -0
  24. package/dist/generated/generated.js.map +1 -1
  25. package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
  26. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
  27. package/dist/generic/CacheInvalidationResolver.js +80 -0
  28. package/dist/generic/CacheInvalidationResolver.js.map +1 -0
  29. package/dist/generic/PubSubManager.d.ts +27 -0
  30. package/dist/generic/PubSubManager.d.ts.map +1 -0
  31. package/dist/generic/PubSubManager.js +41 -0
  32. package/dist/generic/PubSubManager.js.map +1 -0
  33. package/dist/generic/ResolverBase.d.ts +14 -0
  34. package/dist/generic/ResolverBase.d.ts.map +1 -1
  35. package/dist/generic/ResolverBase.js +66 -4
  36. package/dist/generic/ResolverBase.js.map +1 -1
  37. package/dist/hooks.d.ts +65 -0
  38. package/dist/hooks.d.ts.map +1 -0
  39. package/dist/hooks.js +14 -0
  40. package/dist/hooks.js.map +1 -0
  41. package/dist/index.d.ts +7 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +173 -45
  44. package/dist/index.js.map +1 -1
  45. package/dist/multiTenancy/index.d.ts +47 -0
  46. package/dist/multiTenancy/index.d.ts.map +1 -0
  47. package/dist/multiTenancy/index.js +152 -0
  48. package/dist/multiTenancy/index.js.map +1 -0
  49. package/dist/resolvers/CurrentUserContextResolver.d.ts +18 -0
  50. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -0
  51. package/dist/resolvers/CurrentUserContextResolver.js +54 -0
  52. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -0
  53. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
  55. package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
  57. package/dist/rest/RESTEndpointHandler.d.ts +3 -1
  58. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  59. package/dist/rest/RESTEndpointHandler.js +14 -33
  60. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  61. package/dist/test-dynamic-plugin.d.ts +6 -0
  62. package/dist/test-dynamic-plugin.d.ts.map +1 -0
  63. package/dist/test-dynamic-plugin.js +18 -0
  64. package/dist/test-dynamic-plugin.js.map +1 -0
  65. package/dist/types.d.ts +9 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js.map +1 -1
  68. package/package.json +61 -57
  69. package/src/__tests__/bcsaas-integration.test.ts +455 -0
  70. package/src/__tests__/middleware-integration.test.ts +877 -0
  71. package/src/__tests__/mjapi-bootstrap.test.ts +29 -0
  72. package/src/__tests__/multiTenancy.security.test.ts +334 -0
  73. package/src/__tests__/multiTenancy.test.ts +225 -0
  74. package/src/__tests__/unifiedAuth.test.ts +416 -0
  75. package/src/agents/skip-agent.ts +1 -0
  76. package/src/agents/skip-sdk.ts +13 -3
  77. package/src/apolloServer/index.ts +32 -16
  78. package/src/config.ts +25 -0
  79. package/src/context.ts +205 -98
  80. package/src/generated/generated.ts +746 -1
  81. package/src/generic/CacheInvalidationResolver.ts +66 -0
  82. package/src/generic/PubSubManager.ts +46 -0
  83. package/src/generic/ResolverBase.ts +70 -4
  84. package/src/hooks.ts +77 -0
  85. package/src/index.ts +199 -49
  86. package/src/multiTenancy/index.ts +183 -0
  87. package/src/resolvers/CurrentUserContextResolver.ts +39 -0
  88. package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
  89. package/src/rest/RESTEndpointHandler.ts +23 -42
  90. package/src/test-dynamic-plugin.ts +36 -0
  91. package/src/types.ts +10 -0
@@ -0,0 +1,66 @@
1
+ import { Field, ObjectType, Resolver, Root, Subscription } from 'type-graphql';
2
+
3
+ export const CACHE_INVALIDATION_TOPIC = 'CACHE_INVALIDATION';
4
+
5
+ @ObjectType()
6
+ export class CacheInvalidationNotification {
7
+ @Field(() => String)
8
+ EntityName!: string;
9
+
10
+ @Field(() => String, { nullable: true })
11
+ PrimaryKeyValues?: string;
12
+
13
+ @Field(() => String)
14
+ Action!: string;
15
+
16
+ @Field(() => String)
17
+ SourceServerID!: string;
18
+
19
+ @Field(() => Date)
20
+ Timestamp!: Date;
21
+
22
+ @Field(() => String, { nullable: true })
23
+ OriginSessionID?: string;
24
+
25
+ @Field(() => String, { nullable: true })
26
+ RecordData?: string;
27
+ }
28
+
29
+ /**
30
+ * Payload interface for publishing cache invalidation events.
31
+ * Used by PubSubManager.Publish() to send events from Redis callbacks.
32
+ */
33
+ export interface CacheInvalidationPayload {
34
+ entityName: string;
35
+ primaryKeyValues: string | null;
36
+ action: string;
37
+ sourceServerId: string;
38
+ timestamp: Date;
39
+ originSessionId?: string;
40
+ recordData?: string;
41
+ }
42
+
43
+ @Resolver()
44
+ export class CacheInvalidationResolver {
45
+ /**
46
+ * Subscription that broadcasts cache invalidation events to ALL connected clients.
47
+ * No session filter — every browser connected via WebSocket receives every event.
48
+ * This enables cross-server cache invalidation to propagate to browser clients.
49
+ */
50
+ @Subscription(() => CacheInvalidationNotification, {
51
+ topics: CACHE_INVALIDATION_TOPIC,
52
+ })
53
+ cacheInvalidation(
54
+ @Root() payload: CacheInvalidationPayload
55
+ ): CacheInvalidationNotification {
56
+ return {
57
+ EntityName: payload.entityName,
58
+ PrimaryKeyValues: payload.primaryKeyValues ?? undefined,
59
+ Action: payload.action,
60
+ SourceServerID: payload.sourceServerId,
61
+ Timestamp: payload.timestamp,
62
+ OriginSessionID: payload.originSessionId ?? undefined,
63
+ RecordData: payload.recordData ?? undefined,
64
+ };
65
+ }
66
+ }
@@ -0,0 +1,46 @@
1
+ import { PubSubEngine } from 'type-graphql';
2
+ import { BaseSingleton } from '@memberjunction/global';
3
+
4
+ /**
5
+ * Singleton manager that holds a reference to the type-graphql PubSubEngine,
6
+ * allowing any server-side code (not just resolvers with @PubSub() injection)
7
+ * to publish events to GraphQL subscriptions.
8
+ */
9
+ export class PubSubManager extends BaseSingleton<PubSubManager> {
10
+ private _pubSub: PubSubEngine | null = null;
11
+
12
+ protected constructor() {
13
+ super();
14
+ }
15
+
16
+ public static get Instance(): PubSubManager {
17
+ return super.getInstance<PubSubManager>();
18
+ }
19
+
20
+ /**
21
+ * Sets the PubSubEngine instance. Should be called once during server startup
22
+ * after buildSchemaSync creates the PubSub instance.
23
+ */
24
+ public SetPubSubEngine(pubSub: PubSubEngine): void {
25
+ this._pubSub = pubSub;
26
+ }
27
+
28
+ /**
29
+ * Gets the current PubSubEngine instance, or null if not yet configured.
30
+ */
31
+ public get PubSubEngine(): PubSubEngine | null {
32
+ return this._pubSub;
33
+ }
34
+
35
+ /**
36
+ * Publishes a payload to the specified topic via the PubSubEngine.
37
+ * No-op if the PubSubEngine has not been configured yet.
38
+ */
39
+ public Publish(topic: string, payload: Record<string, unknown>): void {
40
+ if (this._pubSub) {
41
+ this._pubSub.publish(topic, payload);
42
+ } else {
43
+ console.warn(`[PubSubManager] Cannot publish to "${topic}" — PubSubEngine not set`);
44
+ }
45
+ }
46
+ }
@@ -31,6 +31,8 @@ import { DeleteOptionsInput } from './DeleteOptionsInput.js';
31
31
  import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
32
32
  import { EncryptionEngine } from '@memberjunction/encryption';
33
33
  import { PUSH_STATUS_UPDATES_TOPIC } from './PushStatusResolver.js';
34
+ import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
35
+ import { PubSubManager } from './PubSubManager.js';
34
36
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
35
37
  import { Subscription } from 'rxjs';
36
38
 
@@ -64,12 +66,17 @@ export class ResolverBase {
64
66
  * @returns The processed data object
65
67
  */
66
68
  protected async MapFieldNamesToCodeNames(entityName: string, dataObject: any, contextUser?: UserInfo): Promise<any> {
69
+ // Return null for empty objects (e.g. when no rows found due to RLS filtering)
70
+ if (!dataObject || Object.keys(dataObject).length === 0) {
71
+ return null;
72
+ }
73
+
67
74
  // for the given entity name provided, check to see if there are any fields
68
75
  // where the code name is different from the field name, and for just those
69
76
  // fields, iterate through the dataObject and REPLACE the property that has the field name
70
77
  // with the CodeName, because we can't transfer those via GraphQL as they are not
71
78
  // valid property names in GraphQL
72
- if (dataObject) {
79
+ {
73
80
  const md = new Metadata();
74
81
  const entityInfo = md.Entities.find((e) => e.Name === entityName);
75
82
  if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
@@ -128,6 +135,31 @@ export class ResolverBase {
128
135
  return dataObject;
129
136
  }
130
137
 
138
+ /**
139
+ * Reverse-maps GraphQL-safe field names back to entity CodeNames in a mutation input object.
140
+ * For example, `_mj__integration_SyncStatus` is mapped back to `__mj_integration_SyncStatus`.
141
+ * Also reverse-maps keys inside the `OldValues___` array if present.
142
+ * This is the inverse of MapFieldNamesToCodeNames and must be called before passing
143
+ * GraphQL input to entity SetMany() or field lookups.
144
+ */
145
+ protected ReverseMapInputFieldNames(input: Record<string, unknown>): Record<string, unknown> {
146
+ const mapper = new FieldMapper();
147
+ const mapped: Record<string, unknown> = {};
148
+ for (const key of Object.keys(input)) {
149
+ if (key === 'OldValues___') {
150
+ // Reverse-map the Key property inside each OldValues entry
151
+ const oldValues = input[key] as Array<{ Key: string; Value: unknown }>;
152
+ mapped[key] = oldValues.map((item) => ({
153
+ Key: mapper.ReverseMapFieldName(item.Key),
154
+ Value: item.Value,
155
+ }));
156
+ } else {
157
+ mapped[mapper.ReverseMapFieldName(key)] = input[key];
158
+ }
159
+ }
160
+ return mapped;
161
+ }
162
+
131
163
  protected async ArrayMapFieldNamesToCodeNames(entityName: string, dataObjectArray: any[], contextUser?: UserInfo): Promise<any[]> {
132
164
  // iterate through the array and call MapFieldNamesToCodeNames for each element
133
165
  if (dataObjectArray && dataObjectArray.length > 0) {
@@ -947,6 +979,23 @@ export class ResolverBase {
947
979
  return Metadata.Provider.ConfigData.MJCoreSchemaName;
948
980
  }
949
981
 
982
+ /**
983
+ * Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
984
+ * entity save or delete. Includes the originSessionId so the originating browser can
985
+ * skip redundant re-fetches (it already handled the event locally).
986
+ */
987
+ protected PublishCacheInvalidation(entityObject: BaseEntity, action: 'save' | 'delete', userPayload: UserPayload): void {
988
+ PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
989
+ entityName: entityObject.EntityInfo.Name,
990
+ primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
991
+ action,
992
+ sourceServerId: MJGlobal.Instance.ProcessUUID,
993
+ timestamp: new Date(),
994
+ originSessionId: userPayload?.sessionId || null,
995
+ recordData: action === 'save' ? JSON.stringify(entityObject.GetAll()) : undefined,
996
+ });
997
+ }
998
+
950
999
  protected ListenForEntityMessages(entityObject: BaseEntity, pubSub: PubSubEngine, userPayload: UserPayload) {
951
1000
  // The unique key is set up for each entity object via it's primary key to ensure that we only have one listener at most for each unique
952
1001
  // entity in the system. This is important because we don't want to have multiple listeners for the same entity as it could
@@ -997,6 +1046,9 @@ export class ResolverBase {
997
1046
  // Check API key scope authorization for entity create operations
998
1047
  await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
999
1048
 
1049
+ // Reverse-map GraphQL field names (e.g. _mj__*) back to entity CodeNames (e.g. __mj_*)
1050
+ input = this.ReverseMapInputFieldNames(input);
1051
+
1000
1052
  if (await this.BeforeCreate(provider, input)) {
1001
1053
  // fire event and proceed if it wasn't cancelled
1002
1054
  const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
@@ -1009,6 +1061,7 @@ export class ResolverBase {
1009
1061
  if (await entityObject.Save()) {
1010
1062
  // save worked, fire the AfterCreate event and then return all the data
1011
1063
  await this.AfterCreate(provider, input); // fire event
1064
+ this.PublishCacheInvalidation(entityObject, 'save', userPayload);
1012
1065
  const contextUser = this.GetUserFromPayload(userPayload);
1013
1066
  // MapFieldNamesToCodeNames now handles encryption filtering as well
1014
1067
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
@@ -1032,6 +1085,9 @@ export class ResolverBase {
1032
1085
  // Check API key scope authorization for entity update operations
1033
1086
  await this.CheckAPIKeyScopeAuthorization('entity:update', entityName, userPayload);
1034
1087
 
1088
+ // Reverse-map GraphQL field names (e.g. _mj__*) back to entity CodeNames (e.g. __mj_*)
1089
+ input = this.ReverseMapInputFieldNames(input);
1090
+
1035
1091
  if (await this.BeforeUpdate(provider, input)) {
1036
1092
  // fire event and proceed if it wasn't cancelled
1037
1093
  const userInfo = this.GetUserFromPayload(userPayload);
@@ -1065,8 +1121,9 @@ export class ResolverBase {
1065
1121
  entityObject.SetMany(input);
1066
1122
  }
1067
1123
  } else {
1068
- // save failed, return null
1069
- throw new GraphQLError(`Record not found for ${entityName} with key ${JSON.stringify(cKey)}`, {
1124
+ // Use a generic message to avoid leaking whether a record exists — distinguishing
1125
+ // "not found" from "access denied" would let an attacker enumerate valid record IDs.
1126
+ throw new GraphQLError(`Record not found or access denied`, {
1070
1127
  extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
1071
1128
  });
1072
1129
  }
@@ -1088,6 +1145,7 @@ export class ResolverBase {
1088
1145
  if (await entityObject.Save()) {
1089
1146
  // save worked, fire afterevent and return all the data
1090
1147
  await this.AfterUpdate(provider, input); // fire event
1148
+ this.PublishCacheInvalidation(entityObject, 'save', userPayload);
1091
1149
 
1092
1150
  // MapFieldNamesToCodeNames now handles encryption filtering as well
1093
1151
  return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
@@ -1300,13 +1358,21 @@ export class ResolverBase {
1300
1358
  if (await this.BeforeDelete(provider, key)) {
1301
1359
  // fire event and proceed if it wasn't cancelled
1302
1360
  const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
1303
- await entityObject.InnerLoad(key);
1361
+ const loadSuccess = await entityObject.InnerLoad(key);
1362
+ if (!loadSuccess) {
1363
+ // Use a generic message to avoid leaking whether a record exists — distinguishing
1364
+ // "not found" from "access denied" would let an attacker enumerate valid record IDs.
1365
+ throw new GraphQLError(`Record not found or access denied`, {
1366
+ extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
1367
+ });
1368
+ }
1304
1369
  const returnValue = entityObject.GetAll(); // grab the values before we delete so we can return last state before delete if we are successful.
1305
1370
 
1306
1371
  this.ListenForEntityMessages(entityObject, pubSub, userPayload);
1307
1372
 
1308
1373
  if (await entityObject.Delete(options)) {
1309
1374
  await this.AfterDelete(provider, key); // fire event
1375
+ this.PublishCacheInvalidation(entityObject, 'delete', userPayload);
1310
1376
  return returnValue;
1311
1377
  } else {
1312
1378
  throw new GraphQLError(entityObject.LatestResult?.Message ?? 'Unknown error', {
package/src/hooks.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Server-level extensibility types for MJServer.
3
+ *
4
+ * Provider-level hook types (PreRunViewHook, PostRunViewHook, PreSaveHook)
5
+ * are defined and exported from @memberjunction/core (hookRegistry.ts) so
6
+ * that ProviderBase and BaseEntity can reference them without depending
7
+ * on @memberjunction/server.
8
+ *
9
+ * This file re-exports those types for convenience and adds the
10
+ * server-specific extensibility options (Express middleware, Apollo plugins,
11
+ * schema transformers).
12
+ */
13
+
14
+ export type { PreRunViewHook, PostRunViewHook, PreSaveHook, HookName, HookRegistrationOptions } from '@memberjunction/core';
15
+
16
+ import type { PreRunViewHook, PostRunViewHook, PreSaveHook, HookRegistrationOptions } from '@memberjunction/core';
17
+ import type { RequestHandler, ErrorRequestHandler, Application } from 'express';
18
+ import type { ApolloServerPlugin } from '@apollo/server';
19
+ import type { GraphQLSchema } from 'graphql';
20
+
21
+ /**
22
+ * A hook entry that includes the hook function plus optional registration metadata.
23
+ * Dynamic packages use this format to declare hooks with priority and namespace.
24
+ */
25
+ export interface HookWithOptions<T> {
26
+ hook: T;
27
+ Priority?: number;
28
+ Namespace?: string;
29
+ }
30
+
31
+ /** A hook value is either a plain function or a function with registration options */
32
+ export type HookOrEntry<T> = T | HookWithOptions<T>;
33
+
34
+ /**
35
+ * Extensibility options that can be passed to `serve()` (via MJServerOptions)
36
+ * or to `createMJServer()` (via MJServerConfig).
37
+ *
38
+ * All properties are optional — when omitted, zero behavior change (backward compatible).
39
+ */
40
+ export interface ServerExtensibilityOptions {
41
+ /** Express middleware applied after compression but before OAuth/REST/GraphQL routes */
42
+ ExpressMiddlewareBefore?: RequestHandler[];
43
+
44
+ /** Express middleware/error handlers applied after all routes (catch-alls, error handlers) */
45
+ ExpressMiddlewareAfter?: (RequestHandler | ErrorRequestHandler)[];
46
+
47
+ /** Escape hatch for advanced Express app configuration (CORS, trust proxy, etc.) */
48
+ ConfigureExpressApp?: (app: Application) => void | Promise<void>;
49
+
50
+ /** Additional Apollo Server plugins merged with the built-in drain/cleanup plugins */
51
+ ApolloPlugins?: ApolloServerPlugin[];
52
+
53
+ /** Schema transformers applied after built-in directive transformers */
54
+ SchemaTransformers?: ((schema: GraphQLSchema) => GraphQLSchema)[];
55
+
56
+ /**
57
+ * Express middleware that runs AFTER authentication has resolved UserInfo
58
+ * onto the request. Use this for middleware that needs the authenticated
59
+ * user (e.g., tenant context resolution, org membership loading).
60
+ *
61
+ * The authenticated user payload is available at `req.userPayload`.
62
+ * Middleware in this array runs in registration order.
63
+ */
64
+ ExpressMiddlewarePostAuth?: RequestHandler[];
65
+
66
+ /** Hooks that modify RunViewParams before query execution (e.g., tenant filter injection).
67
+ * Each entry can be a plain hook function or a `{ hook, Priority, Namespace }` object. */
68
+ PreRunViewHooks?: HookOrEntry<PreRunViewHook>[];
69
+
70
+ /** Hooks that modify RunViewResult after query execution (e.g., data masking).
71
+ * Each entry can be a plain hook function or a `{ hook, Priority, Namespace }` object. */
72
+ PostRunViewHooks?: HookOrEntry<PostRunViewHook>[];
73
+
74
+ /** Hooks that validate/reject Save operations before they hit the database.
75
+ * Each entry can be a plain hook function or a `{ hook, Priority, Namespace }` object. */
76
+ PreSaveHooks?: HookOrEntry<PreSaveHook>[];
77
+ }