@memberjunction/server 5.4.1 → 5.6.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 (74) hide show
  1. package/dist/agents/skip-sdk.js +2 -2
  2. package/dist/agents/skip-sdk.js.map +1 -1
  3. package/dist/auth/AuthProviderFactory.d.ts +9 -0
  4. package/dist/auth/AuthProviderFactory.d.ts.map +1 -1
  5. package/dist/auth/AuthProviderFactory.js +27 -1
  6. package/dist/auth/AuthProviderFactory.js.map +1 -1
  7. package/dist/auth/index.d.ts +6 -4
  8. package/dist/auth/index.d.ts.map +1 -1
  9. package/dist/auth/index.js +10 -6
  10. package/dist/auth/index.js.map +1 -1
  11. package/dist/config.d.ts +37 -0
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +7 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/context.d.ts +2 -2
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +52 -15
  18. package/dist/context.js.map +1 -1
  19. package/dist/generated/generated.d.ts +944 -843
  20. package/dist/generated/generated.d.ts.map +1 -1
  21. package/dist/generated/generated.js +5269 -6194
  22. package/dist/generated/generated.js.map +1 -1
  23. package/dist/generic/ResolverBase.d.ts.map +1 -1
  24. package/dist/generic/ResolverBase.js +9 -0
  25. package/dist/generic/ResolverBase.js.map +1 -1
  26. package/dist/generic/RunViewResolver.d.ts +3 -1
  27. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  28. package/dist/generic/RunViewResolver.js +25 -6
  29. package/dist/generic/RunViewResolver.js.map +1 -1
  30. package/dist/index.d.ts +6 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +181 -22
  33. package/dist/index.js.map +1 -1
  34. package/dist/resolvers/ActionResolver.js +4 -4
  35. package/dist/resolvers/ActionResolver.js.map +1 -1
  36. package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
  37. package/dist/resolvers/ComponentRegistryResolver.js +5 -4
  38. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  39. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  40. package/dist/resolvers/FileResolver.js +3 -2
  41. package/dist/resolvers/FileResolver.js.map +1 -1
  42. package/dist/resolvers/RunAIAgentResolver.js +3 -3
  43. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  44. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  45. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  46. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  47. package/dist/resolvers/SyncRolesUsersResolver.js +4 -3
  48. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  49. package/dist/resolvers/UserFavoriteResolver.d.ts.map +1 -1
  50. package/dist/resolvers/UserFavoriteResolver.js +4 -3
  51. package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
  52. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  53. package/dist/rest/OAuthCallbackHandler.js +4 -3
  54. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  55. package/package.json +58 -53
  56. package/src/__tests__/databaseAbstraction.test.ts +816 -0
  57. package/src/agents/skip-sdk.ts +2 -2
  58. package/src/auth/AuthProviderFactory.ts +31 -1
  59. package/src/auth/__tests__/backward-compatibility.test.ts +114 -0
  60. package/src/auth/index.ts +14 -9
  61. package/src/config.ts +9 -0
  62. package/src/context.ts +65 -20
  63. package/src/generated/generated.ts +5056 -6164
  64. package/src/generic/ResolverBase.ts +9 -0
  65. package/src/generic/RunViewResolver.ts +24 -7
  66. package/src/index.ts +207 -23
  67. package/src/resolvers/ActionResolver.ts +4 -4
  68. package/src/resolvers/ComponentRegistryResolver.ts +8 -7
  69. package/src/resolvers/FileResolver.ts +3 -2
  70. package/src/resolvers/RunAIAgentResolver.ts +3 -3
  71. package/src/resolvers/SqlLoggingConfigResolver.ts +8 -7
  72. package/src/resolvers/SyncRolesUsersResolver.ts +4 -3
  73. package/src/resolvers/UserFavoriteResolver.ts +4 -3
  74. package/src/rest/OAuthCallbackHandler.ts +4 -3
@@ -34,7 +34,7 @@ import { gzip as gzipCompress, createGunzip } from 'zlib';
34
34
  import { configInfo, baseUrl, publicUrl, graphqlPort, graphqlRootPath, apiKey as callbackAPIKey } from '../config.js';
35
35
  import { GetAIAPIKey } from '@memberjunction/ai';
36
36
  import { AIEngine } from '@memberjunction/aiengine';
37
- import { CopyScalarsAndArrays } from '@memberjunction/global';
37
+ import { CopyScalarsAndArrays, UUIDsEqual } from '@memberjunction/global';
38
38
  import mssql from 'mssql';
39
39
  import { registerAccessToken, GetDataAccessToken } from '../resolvers/GetDataResolver.js';
40
40
  import { BehaviorSubject } from 'rxjs';
@@ -493,7 +493,7 @@ export class SkipSDK {
493
493
  * Recursively build category path for a query
494
494
  */
495
495
  private buildQueryCategoryPath(md: Metadata, categoryID: string): string {
496
- const cat = md.QueryCategories.find((c) => c.ID === categoryID);
496
+ const cat = md.QueryCategories.find((c) => UUIDsEqual(c.ID, categoryID));
497
497
  if (!cat) return '';
498
498
  if (!cat.ParentID) return cat.Name;
499
499
  const parentPath = this.buildQueryCategoryPath(md, cat.ParentID);
@@ -18,6 +18,7 @@ export class AuthProviderFactory {
18
18
  private static instance: AuthProviderFactory;
19
19
  private providers: Map<string, IAuthProvider> = new Map();
20
20
  private issuerCache: Map<string, IAuthProvider> = new Map();
21
+ private issuerMultiCache: Map<string, IAuthProvider[]> = new Map();
21
22
 
22
23
  private constructor() {}
23
24
 
@@ -67,8 +68,9 @@ export class AuthProviderFactory {
67
68
 
68
69
  this.providers.set(provider.name, provider);
69
70
 
70
- // Clear issuer cache when registering new provider
71
+ // Clear issuer caches when registering new provider
71
72
  this.issuerCache.clear();
73
+ this.issuerMultiCache.clear();
72
74
 
73
75
  console.log(`Registered auth provider: ${provider.name} with issuer: ${provider.issuer}`);
74
76
  }
@@ -94,6 +96,33 @@ export class AuthProviderFactory {
94
96
  return undefined;
95
97
  }
96
98
 
99
+ /**
100
+ * Gets all providers matching an issuer URL.
101
+ * Unlike getByIssuer() which returns only the first match, this returns
102
+ * all providers for a given issuer. This is needed when multiple apps
103
+ * (e.g. MJExplorer + MJCentral) share the same Auth0 domain but have
104
+ * different audiences (client IDs).
105
+ */
106
+ getAllByIssuer(issuer: string): IAuthProvider[] {
107
+ // Check multi-provider cache first
108
+ if (this.issuerMultiCache.has(issuer)) {
109
+ return this.issuerMultiCache.get(issuer)!;
110
+ }
111
+
112
+ const matches: IAuthProvider[] = [];
113
+ for (const provider of this.providers.values()) {
114
+ if (provider.matchesIssuer(issuer)) {
115
+ matches.push(provider);
116
+ }
117
+ }
118
+
119
+ if (matches.length > 0) {
120
+ this.issuerMultiCache.set(issuer, matches);
121
+ }
122
+
123
+ return matches;
124
+ }
125
+
97
126
  /**
98
127
  * Gets a provider by its name
99
128
  */
@@ -121,6 +150,7 @@ export class AuthProviderFactory {
121
150
  clear(): void {
122
151
  this.providers.clear();
123
152
  this.issuerCache.clear();
153
+ this.issuerMultiCache.clear();
124
154
  }
125
155
 
126
156
  /**
@@ -147,6 +147,120 @@ describe('Authentication Provider Backward Compatibility', () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe('Multi-Audience Same-Issuer Support', () => {
151
+ it('should return all providers matching the same issuer via getAllByIssuer()', () => {
152
+ const sharedIssuer = 'https://auth.example.com/';
153
+ const provider1 = {
154
+ name: 'app1',
155
+ issuer: sharedIssuer,
156
+ audience: 'client-id-app1',
157
+ jwksUri: 'https://auth.example.com/.well-known/jwks.json',
158
+ validateConfig: () => true,
159
+ getSigningKey: vi.fn(),
160
+ extractUserInfo: vi.fn(),
161
+ matchesIssuer: (iss: string) => iss === sharedIssuer,
162
+ } as IAuthProvider;
163
+ const provider2 = {
164
+ name: 'app2',
165
+ issuer: sharedIssuer,
166
+ audience: 'client-id-app2',
167
+ jwksUri: 'https://auth.example.com/.well-known/jwks.json',
168
+ validateConfig: () => true,
169
+ getSigningKey: vi.fn(),
170
+ extractUserInfo: vi.fn(),
171
+ matchesIssuer: (iss: string) => iss === sharedIssuer,
172
+ } as IAuthProvider;
173
+
174
+ factory.register(provider1);
175
+ factory.register(provider2);
176
+
177
+ const results = factory.getAllByIssuer(sharedIssuer);
178
+ expect(results).toHaveLength(2);
179
+ expect(results).toContain(provider1);
180
+ expect(results).toContain(provider2);
181
+ });
182
+
183
+ it('should return empty array from getAllByIssuer() for unknown issuer', () => {
184
+ const results = factory.getAllByIssuer('https://unknown.example.com');
185
+ expect(results).toEqual([]);
186
+ });
187
+
188
+ it('should cache getAllByIssuer() results and invalidate on registration', () => {
189
+ const sharedIssuer = 'https://auth.example.com/';
190
+ const provider1 = {
191
+ name: 'app1',
192
+ issuer: sharedIssuer,
193
+ audience: 'client-id-app1',
194
+ jwksUri: 'https://auth.example.com/.well-known/jwks.json',
195
+ validateConfig: () => true,
196
+ getSigningKey: vi.fn(),
197
+ extractUserInfo: vi.fn(),
198
+ matchesIssuer: vi.fn((iss: string): boolean => iss === sharedIssuer),
199
+ } as IAuthProvider;
200
+
201
+ factory.register(provider1);
202
+
203
+ // First call populates cache
204
+ const first = factory.getAllByIssuer(sharedIssuer);
205
+ expect(first).toHaveLength(1);
206
+ expect(provider1.matchesIssuer).toHaveBeenCalledTimes(1);
207
+
208
+ // Second call uses cache
209
+ const second = factory.getAllByIssuer(sharedIssuer);
210
+ expect(second).toHaveLength(1);
211
+ expect(provider1.matchesIssuer).toHaveBeenCalledTimes(1);
212
+
213
+ // Registering a new provider invalidates the cache
214
+ const provider2 = {
215
+ name: 'app2',
216
+ issuer: sharedIssuer,
217
+ audience: 'client-id-app2',
218
+ jwksUri: 'https://auth.example.com/.well-known/jwks.json',
219
+ validateConfig: () => true,
220
+ getSigningKey: vi.fn(),
221
+ extractUserInfo: vi.fn(),
222
+ matchesIssuer: vi.fn((iss: string): boolean => iss === sharedIssuer),
223
+ } as IAuthProvider;
224
+ factory.register(provider2);
225
+
226
+ // After registration, cache is cleared, so matchesIssuer is called again
227
+ const third = factory.getAllByIssuer(sharedIssuer);
228
+ expect(third).toHaveLength(2);
229
+ // provider1.matchesIssuer called once more (from the new lookup)
230
+ expect(provider1.matchesIssuer).toHaveBeenCalledTimes(2);
231
+ });
232
+
233
+ it('should not include providers with different issuers', () => {
234
+ const provider1 = {
235
+ name: 'app1',
236
+ issuer: 'https://issuer-a.com/',
237
+ audience: 'client-a',
238
+ jwksUri: 'https://issuer-a.com/.well-known/jwks.json',
239
+ validateConfig: () => true,
240
+ getSigningKey: vi.fn(),
241
+ extractUserInfo: vi.fn(),
242
+ matchesIssuer: (iss: string) => iss === 'https://issuer-a.com/',
243
+ } as IAuthProvider;
244
+ const provider2 = {
245
+ name: 'app2',
246
+ issuer: 'https://issuer-b.com/',
247
+ audience: 'client-b',
248
+ jwksUri: 'https://issuer-b.com/.well-known/jwks.json',
249
+ validateConfig: () => true,
250
+ getSigningKey: vi.fn(),
251
+ extractUserInfo: vi.fn(),
252
+ matchesIssuer: (iss: string) => iss === 'https://issuer-b.com/',
253
+ } as IAuthProvider;
254
+
255
+ factory.register(provider1);
256
+ factory.register(provider2);
257
+
258
+ const results = factory.getAllByIssuer('https://issuer-a.com/');
259
+ expect(results).toHaveLength(1);
260
+ expect(results[0]).toBe(provider1);
261
+ });
262
+ });
263
+
150
264
  describe('Error Handling', () => {
151
265
  it('should handle missing provider gracefully', () => {
152
266
  const unknownIssuer = 'https://unknown.provider.com';
package/src/auth/index.ts CHANGED
@@ -47,20 +47,25 @@ const refreshUserCache = async (dataSource?: sql.ConnectionPool) => {
47
47
  };
48
48
 
49
49
  /**
50
- * Gets validation options for a specific issuer
51
- * This maintains backward compatibility with the old structure
50
+ * Gets validation options for a specific issuer.
51
+ * When multiple providers share the same issuer (e.g. two Auth0 apps on
52
+ * the same domain with different audiences/client IDs), all unique audiences
53
+ * are aggregated into an array. jwt.verify() natively accepts string | string[].
52
54
  */
53
- export const getValidationOptions = (issuer: string): { audience: string; jwksUri: string } | undefined => {
55
+ export const getValidationOptions = (issuer: string): { audience: string | string[]; jwksUri: string } | undefined => {
54
56
  const factory = AuthProviderFactory.getInstance();
55
- const provider = factory.getByIssuer(issuer);
56
-
57
- if (!provider) {
57
+ const providers = factory.getAllByIssuer(issuer);
58
+
59
+ if (providers.length === 0) {
58
60
  return undefined;
59
61
  }
60
62
 
63
+ // Collect unique audiences from all providers matching this issuer
64
+ const audiences = [...new Set(providers.map(p => p.audience))];
65
+
61
66
  return {
62
- audience: provider.audience,
63
- jwksUri: provider.jwksUri
67
+ audience: audiences.length === 1 ? audiences[0] : audiences,
68
+ jwksUri: providers[0].jwksUri // Same issuer = same JWKS endpoint
64
69
  };
65
70
  };
66
71
 
@@ -68,7 +73,7 @@ export const getValidationOptions = (issuer: string): { audience: string; jwksUr
68
73
  * Backward compatible validationOptions object
69
74
  * @deprecated Use getValidationOptions() or AuthProviderRegistry instead
70
75
  */
71
- export const validationOptions: Record<string, { audience: string; jwksUri: string }> = new Proxy({}, {
76
+ export const validationOptions: Record<string, { audience: string | string[]; jwksUri: string }> = new Proxy({}, {
72
77
  get: (target, prop: string) => {
73
78
  return getValidationOptions(prop);
74
79
  },
package/src/config.ts CHANGED
@@ -140,6 +140,13 @@ const scheduledJobsSchema = z.object({
140
140
  staleLockCleanupInterval: z.number().optional().default(300000), // 5 minutes in ms
141
141
  });
142
142
 
143
+ const queryDialectSchema = z.object({
144
+ /** When true, saving a Query entity auto-generates QuerySQL entries for configured target dialects */
145
+ autoConvertOnSave: zodBooleanWithTransforms().default(false),
146
+ /** List of SQLDialect PlatformKey values to auto-convert to (e.g., ['postgresql']) */
147
+ targetPlatforms: z.array(z.string()).optional().default([]),
148
+ });
149
+
143
150
  const telemetrySchema = z.object({
144
151
  enabled: zodBooleanWithTransforms().default(
145
152
  process.env.MJ_TELEMETRY_ENABLED !== 'false' // Enabled by default unless explicitly disabled
@@ -158,6 +165,7 @@ const configInfoSchema = z.object({
158
165
  componentRegistries: z.array(componentRegistrySchema).optional(),
159
166
  scheduledJobs: scheduledJobsSchema.optional().default({}),
160
167
  telemetry: telemetrySchema.optional().default({}),
168
+ queryDialects: queryDialectSchema.optional().default({}),
161
169
 
162
170
  apiKey: z.string().optional(),
163
171
  baseUrl: z.string().default('http://localhost'),
@@ -201,6 +209,7 @@ export type AuthProviderConfig = z.infer<typeof authProviderSchema>;
201
209
  export type ComponentRegistryConfig = z.infer<typeof componentRegistrySchema>;
202
210
  export type ScheduledJobsConfig = z.infer<typeof scheduledJobsSchema>;
203
211
  export type TelemetryConfig = z.infer<typeof telemetrySchema>;
212
+ export type QueryDialectConfig = z.infer<typeof queryDialectSchema>;
204
213
  export type ConfigInfo = z.infer<typeof configInfoSchema>;
205
214
 
206
215
  /**
package/src/context.ts CHANGED
@@ -16,6 +16,7 @@ import { DatabaseProviderBase } from '@memberjunction/core';
16
16
  import { SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
17
17
  import { AuthProviderFactory } from './auth/AuthProviderFactory.js';
18
18
  import { Metadata } from '@memberjunction/core';
19
+ import { UUIDsEqual } from '@memberjunction/global';
19
20
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
20
21
 
21
22
  const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayload> =>
@@ -27,7 +28,14 @@ const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayloa
27
28
  return;
28
29
  }
29
30
 
30
- jwt.verify(token, getSigningKeys(issuer), options, (err, jwt) => {
31
+ const verifyOptions: jwt.VerifyOptions = {};
32
+ if (Array.isArray(options.audience)) {
33
+ verifyOptions.audience = options.audience as [string, ...string[]];
34
+ } else {
35
+ verifyOptions.audience = options.audience;
36
+ }
37
+
38
+ jwt.verify(token, getSigningKeys(issuer), verifyOptions, (err, jwt) => {
31
39
  if (jwt && typeof jwt !== 'string' && !err) {
32
40
  const payload = jwt.payload ?? jwt;
33
41
 
@@ -96,7 +104,7 @@ export const getUserPayload = async (
96
104
  // Get the user from UserCache to ensure UserRoles is properly populated
97
105
  // The validationResult.User from APIKeyEngine doesn't include UserRoles
98
106
  const cachedUser = UserCache.Instance.Users.find(
99
- u => u.ID === validationResult.User.ID
107
+ u => UUIDsEqual(u.ID, validationResult.User.ID)
100
108
  );
101
109
 
102
110
  // Use cached user if available, otherwise fall back to the validation result
@@ -247,22 +255,59 @@ export const contextFunction =
247
255
  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!!!');
248
256
  }
249
257
 
250
- // now create a new instance of SQLServerDataProvider for each request
251
- const config = new SQLServerProviderConfigData(dataSource, mj_core_schema, 0, undefined, undefined, false);
252
- const p = new SQLServerDataProvider();
253
- await p.Config(config);
254
-
255
- let rp = null;
256
- try {
257
- const readOnlyDataSource = GetReadOnlyDataSource(dataSources, { allowFallbackToReadWrite: false });
258
- if (readOnlyDataSource) {
259
- rp = new SQLServerDataProvider();
260
- const rConfig = new SQLServerProviderConfigData(readOnlyDataSource, mj_core_schema, 0, undefined, undefined, false);
261
- await rp.Config(rConfig);
258
+ // Create per-request provider instance based on database type
259
+ const dbType = process.env.DB_TYPE?.toLowerCase();
260
+ const isPostgres = dbType === 'postgresql' || dbType === 'postgres' || dbType === 'pg';
261
+
262
+ let p: DatabaseProviderBase;
263
+ if (isPostgres) {
264
+ const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
265
+ const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
266
+ const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
267
+ const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
268
+ const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
269
+ const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
270
+
271
+ const pgProvider = new PostgreSQLDataProvider();
272
+ const pgConfig = new PostgreSQLProviderConfigData(
273
+ { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgUser, Password: pgPass },
274
+ mj_core_schema,
275
+ 0,
276
+ undefined,
277
+ undefined,
278
+ false, // use existing metadata from global provider
279
+ );
280
+
281
+ // Share the connection pool from the primary provider to avoid pool exhaustion.
282
+ // Each per-request provider was creating its own pool, leading to "too many clients" errors.
283
+ const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool };
284
+ if (primaryProvider?.DatabaseConnection) {
285
+ await pgProvider.ConfigWithSharedPool(pgConfig, primaryProvider.DatabaseConnection);
286
+ } else {
287
+ await pgProvider.Config(pgConfig);
262
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;
263
295
  }
264
- catch (e) {
265
- // no read only data source available, so rp will remain null, this is OK!
296
+
297
+ let rp: DatabaseProviderBase | null = null;
298
+ if (!isPostgres) {
299
+ try {
300
+ const readOnlyDataSource = GetReadOnlyDataSource(dataSources, { allowFallbackToReadWrite: false });
301
+ if (readOnlyDataSource) {
302
+ const roProvider = new SQLServerDataProvider();
303
+ const rConfig = new SQLServerProviderConfigData(readOnlyDataSource, mj_core_schema, 0, undefined, undefined, false);
304
+ await roProvider.Config(rConfig);
305
+ rp = roProvider as unknown as DatabaseProviderBase;
306
+ }
307
+ }
308
+ catch (_err) {
309
+ // no read only data source available, so rp will remain null, this is OK!
310
+ }
266
311
  }
267
312
 
268
313
  const providers = [{
@@ -276,12 +321,12 @@ export const contextFunction =
276
321
  });
277
322
  }
278
323
 
279
- const contextResult = {
280
- dataSource,
281
- dataSources,
324
+ const contextResult = {
325
+ dataSource,
326
+ dataSources,
282
327
  userPayload: userPayload,
283
328
  providers,
284
329
  };
285
-
330
+
286
331
  return contextResult;
287
332
  };