@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
|
@@ -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,47 @@
|
|
|
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
|
+
console.log(`[PubSubManager] Publishing to topic "${topic}":`, JSON.stringify(payload).substring(0, 200));
|
|
42
|
+
this._pubSub.publish(topic, payload);
|
|
43
|
+
} else {
|
|
44
|
+
console.warn(`[PubSubManager] Cannot publish to "${topic}" — PubSubEngine not set`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -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
|
|
|
@@ -128,6 +130,31 @@ export class ResolverBase {
|
|
|
128
130
|
return dataObject;
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Reverse-maps GraphQL-safe field names back to entity CodeNames in a mutation input object.
|
|
135
|
+
* For example, `_mj__integration_SyncStatus` is mapped back to `__mj_integration_SyncStatus`.
|
|
136
|
+
* Also reverse-maps keys inside the `OldValues___` array if present.
|
|
137
|
+
* This is the inverse of MapFieldNamesToCodeNames and must be called before passing
|
|
138
|
+
* GraphQL input to entity SetMany() or field lookups.
|
|
139
|
+
*/
|
|
140
|
+
protected ReverseMapInputFieldNames(input: Record<string, unknown>): Record<string, unknown> {
|
|
141
|
+
const mapper = new FieldMapper();
|
|
142
|
+
const mapped: Record<string, unknown> = {};
|
|
143
|
+
for (const key of Object.keys(input)) {
|
|
144
|
+
if (key === 'OldValues___') {
|
|
145
|
+
// Reverse-map the Key property inside each OldValues entry
|
|
146
|
+
const oldValues = input[key] as Array<{ Key: string; Value: unknown }>;
|
|
147
|
+
mapped[key] = oldValues.map((item) => ({
|
|
148
|
+
Key: mapper.ReverseMapFieldName(item.Key),
|
|
149
|
+
Value: item.Value,
|
|
150
|
+
}));
|
|
151
|
+
} else {
|
|
152
|
+
mapped[mapper.ReverseMapFieldName(key)] = input[key];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return mapped;
|
|
156
|
+
}
|
|
157
|
+
|
|
131
158
|
protected async ArrayMapFieldNamesToCodeNames(entityName: string, dataObjectArray: any[], contextUser?: UserInfo): Promise<any[]> {
|
|
132
159
|
// iterate through the array and call MapFieldNamesToCodeNames for each element
|
|
133
160
|
if (dataObjectArray && dataObjectArray.length > 0) {
|
|
@@ -947,6 +974,23 @@ export class ResolverBase {
|
|
|
947
974
|
return Metadata.Provider.ConfigData.MJCoreSchemaName;
|
|
948
975
|
}
|
|
949
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Publishes a CACHE_INVALIDATION event to connected browser clients after a successful
|
|
979
|
+
* entity save or delete. Includes the originSessionId so the originating browser can
|
|
980
|
+
* skip redundant re-fetches (it already handled the event locally).
|
|
981
|
+
*/
|
|
982
|
+
protected PublishCacheInvalidation(entityObject: BaseEntity, action: 'save' | 'delete', userPayload: UserPayload): void {
|
|
983
|
+
PubSubManager.Instance.Publish(CACHE_INVALIDATION_TOPIC, {
|
|
984
|
+
entityName: entityObject.EntityInfo.Name,
|
|
985
|
+
primaryKeyValues: JSON.stringify(entityObject.PrimaryKey.KeyValuePairs),
|
|
986
|
+
action,
|
|
987
|
+
sourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
988
|
+
timestamp: new Date(),
|
|
989
|
+
originSessionId: userPayload?.sessionId || null,
|
|
990
|
+
recordData: action === 'save' ? JSON.stringify(entityObject.GetAll()) : undefined,
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
950
994
|
protected ListenForEntityMessages(entityObject: BaseEntity, pubSub: PubSubEngine, userPayload: UserPayload) {
|
|
951
995
|
// 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
996
|
// 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 +1041,9 @@ export class ResolverBase {
|
|
|
997
1041
|
// Check API key scope authorization for entity create operations
|
|
998
1042
|
await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
|
|
999
1043
|
|
|
1044
|
+
// Reverse-map GraphQL field names (e.g. _mj__*) back to entity CodeNames (e.g. __mj_*)
|
|
1045
|
+
input = this.ReverseMapInputFieldNames(input);
|
|
1046
|
+
|
|
1000
1047
|
if (await this.BeforeCreate(provider, input)) {
|
|
1001
1048
|
// fire event and proceed if it wasn't cancelled
|
|
1002
1049
|
const entityObject = await provider.GetEntityObject(entityName, this.GetUserFromPayload(userPayload));
|
|
@@ -1009,6 +1056,7 @@ export class ResolverBase {
|
|
|
1009
1056
|
if (await entityObject.Save()) {
|
|
1010
1057
|
// save worked, fire the AfterCreate event and then return all the data
|
|
1011
1058
|
await this.AfterCreate(provider, input); // fire event
|
|
1059
|
+
this.PublishCacheInvalidation(entityObject, 'save', userPayload);
|
|
1012
1060
|
const contextUser = this.GetUserFromPayload(userPayload);
|
|
1013
1061
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1014
1062
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
|
|
@@ -1032,6 +1080,9 @@ export class ResolverBase {
|
|
|
1032
1080
|
// Check API key scope authorization for entity update operations
|
|
1033
1081
|
await this.CheckAPIKeyScopeAuthorization('entity:update', entityName, userPayload);
|
|
1034
1082
|
|
|
1083
|
+
// Reverse-map GraphQL field names (e.g. _mj__*) back to entity CodeNames (e.g. __mj_*)
|
|
1084
|
+
input = this.ReverseMapInputFieldNames(input);
|
|
1085
|
+
|
|
1035
1086
|
if (await this.BeforeUpdate(provider, input)) {
|
|
1036
1087
|
// fire event and proceed if it wasn't cancelled
|
|
1037
1088
|
const userInfo = this.GetUserFromPayload(userPayload);
|
|
@@ -1088,6 +1139,7 @@ export class ResolverBase {
|
|
|
1088
1139
|
if (await entityObject.Save()) {
|
|
1089
1140
|
// save worked, fire afterevent and return all the data
|
|
1090
1141
|
await this.AfterUpdate(provider, input); // fire event
|
|
1142
|
+
this.PublishCacheInvalidation(entityObject, 'save', userPayload);
|
|
1091
1143
|
|
|
1092
1144
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1093
1145
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
@@ -1307,6 +1359,7 @@ export class ResolverBase {
|
|
|
1307
1359
|
|
|
1308
1360
|
if (await entityObject.Delete(options)) {
|
|
1309
1361
|
await this.AfterDelete(provider, key); // fire event
|
|
1362
|
+
this.PublishCacheInvalidation(entityObject, 'delete', userPayload);
|
|
1310
1363
|
return returnValue;
|
|
1311
1364
|
} else {
|
|
1312
1365
|
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
|
+
}
|