@adobe/aio-cli-plugin-api-mesh 5.6.6 → 5.7.0-alpha.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/aio-cli-plugin-api-mesh",
3
- "version": "5.6.6",
3
+ "version": "5.7.0-alpha.1",
4
4
  "description": "Adobe I/O CLI plugin to develop and manage API mesh sources",
5
5
  "keywords": [
6
6
  "oclif-plugin"
@@ -38,7 +38,7 @@
38
38
  "version": "oclif-dev readme && git add README.md"
39
39
  },
40
40
  "dependencies": {
41
- "@adobe-apimesh/mesh-builder": "^2.4.1",
41
+ "@adobe-apimesh/mesh-builder": "^2.4.2-alpha.1",
42
42
  "@adobe/aio-cli-lib-console": "^5.0.0",
43
43
  "@adobe/aio-lib-core-config": "^5.0.0",
44
44
  "@adobe/aio-lib-core-logging": "^3.0.0",
@@ -48,6 +48,7 @@
48
48
  "@adobe/plugin-on-fetch": "0.1.1",
49
49
  "@adobe/plugin-source-headers": "^0.0.2",
50
50
  "@envelop/disable-introspection": "^6.0.0",
51
+ "@escape.tech/graphql-armor": "3.2.0",
51
52
  "@graphql-mesh/cli": "0.82.30",
52
53
  "@graphql-mesh/graphql": "0.34.13",
53
54
  "@graphql-mesh/http": "^0.96.9",
@@ -0,0 +1,23 @@
1
+ {
2
+ "meshConfig": {
3
+ "sources": [
4
+ {
5
+ "name": "<api_name>",
6
+ "handler": {
7
+ "graphql": {
8
+ "endpoint": "<gql_endpoint>"
9
+ }
10
+ }
11
+ }
12
+ ],
13
+ "queryConfig": {
14
+ "maxDepth": { "enabled": true, "limit": 4 },
15
+ "maxAliases": { "enabled": true, "limit": 10 },
16
+ "maxTokens": { "enabled": true, "limit": 500 },
17
+ "maxDirectives": { "enabled": true, "limit": 20 },
18
+ "costLimit": { "enabled": true, "maxCost": 1000 },
19
+ "blockFieldSuggestion": { "enabled": true, "mask": "[hidden]" },
20
+ "maskErrors": { "enabled": true, "message": "Something went wrong." }
21
+ }
22
+ }
23
+ }
@@ -32,6 +32,7 @@ jest.mock('../../../lib/smsClient');
32
32
  const CreateCommand = require('../create');
33
33
  const sampleCreateMeshConfig = require('../../__fixtures__/sample_mesh.json');
34
34
  const meshConfigWithComposerFiles = require('../../__fixtures__/sample_mesh_with_composer_files.json');
35
+ const sampleMeshWithQueryConfig = require('../../__fixtures__/sample_mesh_with_queryConfig.json');
35
36
  const { initSdk, promptConfirm, interpolateMesh, importFiles } = require('../../../helpers');
36
37
  const {
37
38
  getMesh,
@@ -407,6 +408,77 @@ describe('create command tests', () => {
407
408
  expect(errorLogSpy.mock.calls).toMatchInlineSnapshot(`[]`);
408
409
  });
409
410
 
411
+ test('should pass queryConfig fields through to createMesh without modification', async () => {
412
+ createMesh.mockResolvedValueOnce({
413
+ mesh: {
414
+ meshId: 'dummy_mesh_id',
415
+ meshConfig: sampleMeshWithQueryConfig.meshConfig,
416
+ },
417
+ });
418
+ parseSpy.mockResolvedValueOnce({
419
+ args: { file: 'src/commands/__fixtures__/sample_mesh_with_queryConfig.json' },
420
+ flags: {
421
+ ignoreCache: mockIgnoreCacheFlag,
422
+ autoConfirmAction: mockAutoApproveAction,
423
+ },
424
+ });
425
+ await CreateCommand.run();
426
+ expect(createMesh.mock.calls[0]).toMatchInlineSnapshot(`
427
+ [
428
+ "CODE1234@AdobeOrg",
429
+ "5678",
430
+ "123456789",
431
+ "Workspace01",
432
+ "ORG01",
433
+ "Project01",
434
+ {
435
+ "meshConfig": {
436
+ "queryConfig": {
437
+ "blockFieldSuggestion": {
438
+ "enabled": true,
439
+ "mask": "[hidden]",
440
+ },
441
+ "costLimit": {
442
+ "enabled": true,
443
+ "maxCost": 1000,
444
+ },
445
+ "maskErrors": {
446
+ "enabled": true,
447
+ "message": "Something went wrong.",
448
+ },
449
+ "maxAliases": {
450
+ "enabled": true,
451
+ "limit": 10,
452
+ },
453
+ "maxDepth": {
454
+ "enabled": true,
455
+ "limit": 4,
456
+ },
457
+ "maxDirectives": {
458
+ "enabled": true,
459
+ "limit": 20,
460
+ },
461
+ "maxTokens": {
462
+ "enabled": true,
463
+ "limit": 500,
464
+ },
465
+ },
466
+ "sources": [
467
+ {
468
+ "handler": {
469
+ "graphql": {
470
+ "endpoint": "<gql_endpoint>",
471
+ },
472
+ },
473
+ "name": "<api_name>",
474
+ },
475
+ ],
476
+ },
477
+ },
478
+ ]
479
+ `);
480
+ });
481
+
410
482
  test('should create and return Ti mesh url if a valid mesh config file for TI client is provided', async () => {
411
483
  let fetchedMeshConfig = sampleCreateMeshConfig;
412
484
  fetchedMeshConfig.meshId = 'dummy_id';
@@ -1104,6 +1104,26 @@ describe('run command tests', () => {
1104
1104
  );
1105
1105
  });
1106
1106
 
1107
+ test('should successfully run the mesh with queryConfig', async () => {
1108
+ const cliInput = {
1109
+ args: { file: 'src/commands/__fixtures__/sample_mesh_with_queryConfig.json' },
1110
+ flags: {
1111
+ port: 5000,
1112
+ debug: false,
1113
+ inspectPort: 9229,
1114
+ },
1115
+ };
1116
+ parseSpy.mockResolvedValueOnce(cliInput);
1117
+
1118
+ await RunCommand.run();
1119
+ expect(start).toHaveBeenCalledWith(
1120
+ expect.anything(),
1121
+ cliInput.flags.port,
1122
+ cliInput.flags.debug,
1123
+ cliInput.flags.inspectPort,
1124
+ );
1125
+ });
1126
+
1107
1127
  test('should escape variables that are preceded by backslash symbol', async () => {
1108
1128
  const cliInput = {
1109
1129
  args: { file: 'src/commands/__fixtures__/sample_mesh_with_escaped_secrets.json' },
@@ -137,6 +137,155 @@ describe('update command tests', () => {
137
137
  expect(errorLogSpy.mock.calls).toMatchInlineSnapshot(`[]`);
138
138
  });
139
139
 
140
+ test('should pass queryConfig with all fields enabled through to updateMesh', async () => {
141
+ const meshWithQueryConfigEnabled = {
142
+ meshConfig: {
143
+ sources: [{ name: '<api_name>', handler: { graphql: { endpoint: '<gql_endpoint>' } } }],
144
+ queryConfig: {
145
+ maxDepth: { enabled: true, limit: 4 },
146
+ maxAliases: { enabled: true, limit: 10 },
147
+ maxTokens: { enabled: true, limit: 500 },
148
+ maxDirectives: { enabled: true, limit: 20 },
149
+ costLimit: { enabled: true, maxCost: 1000 },
150
+ blockFieldSuggestion: { enabled: true, mask: '[hidden]' },
151
+ maskErrors: { enabled: true, message: 'Something went wrong.' },
152
+ },
153
+ },
154
+ };
155
+ readFile.mockResolvedValueOnce(JSON.stringify(meshWithQueryConfigEnabled));
156
+ parseSpy.mockResolvedValueOnce({
157
+ args: { file: 'src/commands/__fixtures__/sample_mesh_with_queryConfig.json' },
158
+ flags: { ignoreCache: mockIgnoreCacheFlag, autoConfirmAction: mockAutoApproveAction },
159
+ });
160
+ await UpdateCommand.run();
161
+ expect(updateMesh.mock.calls[0]).toMatchInlineSnapshot(`
162
+ [
163
+ "CODE1234@AdobeOrg",
164
+ "5678",
165
+ "123456789",
166
+ "Workspace01",
167
+ "ORG01",
168
+ "Project01",
169
+ "mesh_id",
170
+ {
171
+ "meshConfig": {
172
+ "queryConfig": {
173
+ "blockFieldSuggestion": {
174
+ "enabled": true,
175
+ "mask": "[hidden]",
176
+ },
177
+ "costLimit": {
178
+ "enabled": true,
179
+ "maxCost": 1000,
180
+ },
181
+ "maskErrors": {
182
+ "enabled": true,
183
+ "message": "Something went wrong.",
184
+ },
185
+ "maxAliases": {
186
+ "enabled": true,
187
+ "limit": 10,
188
+ },
189
+ "maxDepth": {
190
+ "enabled": true,
191
+ "limit": 4,
192
+ },
193
+ "maxDirectives": {
194
+ "enabled": true,
195
+ "limit": 20,
196
+ },
197
+ "maxTokens": {
198
+ "enabled": true,
199
+ "limit": 500,
200
+ },
201
+ },
202
+ "sources": [
203
+ {
204
+ "handler": {
205
+ "graphql": {
206
+ "endpoint": "<gql_endpoint>",
207
+ },
208
+ },
209
+ "name": "<api_name>",
210
+ },
211
+ ],
212
+ },
213
+ },
214
+ ]
215
+ `);
216
+ });
217
+
218
+ test('should pass queryConfig with all fields disabled through to updateMesh', async () => {
219
+ const meshWithQueryConfigDisabled = {
220
+ meshConfig: {
221
+ sources: [{ name: '<api_name>', handler: { graphql: { endpoint: '<gql_endpoint>' } } }],
222
+ queryConfig: {
223
+ maxDepth: { enabled: false },
224
+ maxAliases: { enabled: false },
225
+ maxTokens: { enabled: false },
226
+ maxDirectives: { enabled: false },
227
+ costLimit: { enabled: false },
228
+ blockFieldSuggestion: { enabled: false },
229
+ maskErrors: { enabled: false },
230
+ },
231
+ },
232
+ };
233
+ readFile.mockResolvedValueOnce(JSON.stringify(meshWithQueryConfigDisabled));
234
+ parseSpy.mockResolvedValueOnce({
235
+ args: { file: 'src/commands/__fixtures__/sample_mesh.json' },
236
+ flags: { ignoreCache: mockIgnoreCacheFlag, autoConfirmAction: mockAutoApproveAction },
237
+ });
238
+ await UpdateCommand.run();
239
+ expect(updateMesh.mock.calls[0]).toMatchInlineSnapshot(`
240
+ [
241
+ "CODE1234@AdobeOrg",
242
+ "5678",
243
+ "123456789",
244
+ "Workspace01",
245
+ "ORG01",
246
+ "Project01",
247
+ "mesh_id",
248
+ {
249
+ "meshConfig": {
250
+ "queryConfig": {
251
+ "blockFieldSuggestion": {
252
+ "enabled": false,
253
+ },
254
+ "costLimit": {
255
+ "enabled": false,
256
+ },
257
+ "maskErrors": {
258
+ "enabled": false,
259
+ },
260
+ "maxAliases": {
261
+ "enabled": false,
262
+ },
263
+ "maxDepth": {
264
+ "enabled": false,
265
+ },
266
+ "maxDirectives": {
267
+ "enabled": false,
268
+ },
269
+ "maxTokens": {
270
+ "enabled": false,
271
+ },
272
+ },
273
+ "sources": [
274
+ {
275
+ "handler": {
276
+ "graphql": {
277
+ "endpoint": "<gql_endpoint>",
278
+ },
279
+ },
280
+ "name": "<api_name>",
281
+ },
282
+ ],
283
+ },
284
+ },
285
+ ]
286
+ `);
287
+ });
288
+
140
289
  test('should pass with valid args and ignoreCache flag', async () => {
141
290
  let sampleMesh = {
142
291
  meshConfig: {
@@ -0,0 +1,167 @@
1
+ const { EnvelopArmorPlugin } = require('@escape.tech/graphql-armor');
2
+ const useQueryConfig = require('../index');
3
+
4
+ jest.mock('@escape.tech/graphql-armor', () => ({
5
+ EnvelopArmorPlugin: jest.fn(() => ({ pluginName: 'EnvelopArmor' })),
6
+ }));
7
+
8
+ beforeEach(() => {
9
+ EnvelopArmorPlugin.mockClear();
10
+ });
11
+
12
+ describe('useQueryConfig', () => {
13
+ describe('no config — all protections disabled', () => {
14
+ it('disables all protections when called with undefined', () => {
15
+ useQueryConfig(undefined);
16
+ expect(EnvelopArmorPlugin).toHaveBeenCalledWith({
17
+ costLimit: { enabled: false },
18
+ maxDepth: { enabled: false },
19
+ maxAliases: { enabled: false },
20
+ maxTokens: { enabled: false },
21
+ maxDirectives: { enabled: false },
22
+ blockFieldSuggestion: { enabled: false },
23
+ });
24
+ });
25
+
26
+ it('disables all protections when called with empty object', () => {
27
+ useQueryConfig({});
28
+ expect(EnvelopArmorPlugin).toHaveBeenCalledWith({
29
+ costLimit: { enabled: false },
30
+ maxDepth: { enabled: false },
31
+ maxAliases: { enabled: false },
32
+ maxTokens: { enabled: false },
33
+ maxDirectives: { enabled: false },
34
+ blockFieldSuggestion: { enabled: false },
35
+ });
36
+ });
37
+ });
38
+
39
+ describe('maxDepth', () => {
40
+ it('passes enabled and limit (mapped to n) to armor', () => {
41
+ useQueryConfig({ maxDepth: { enabled: true, limit: 5 } });
42
+ expect(EnvelopArmorPlugin.mock.calls[0][0].maxDepth).toEqual({ enabled: true, n: 5 });
43
+ });
44
+
45
+ it('passes enabled: false with no limit', () => {
46
+ useQueryConfig({ maxDepth: { enabled: false } });
47
+ expect(EnvelopArmorPlugin.mock.calls[0][0].maxDepth).toEqual({
48
+ enabled: false,
49
+ n: undefined,
50
+ });
51
+ });
52
+
53
+ it('passes enabled: false with limit retained', () => {
54
+ useQueryConfig({ maxDepth: { enabled: false, limit: 3 } });
55
+ expect(EnvelopArmorPlugin.mock.calls[0][0].maxDepth).toEqual({ enabled: false, n: 3 });
56
+ });
57
+ });
58
+
59
+ describe('maxAliases', () => {
60
+ it('passes enabled and limit (mapped to n)', () => {
61
+ useQueryConfig({ maxAliases: { enabled: true, limit: 10 } });
62
+ expect(EnvelopArmorPlugin.mock.calls[0][0].maxAliases).toEqual({ enabled: true, n: 10 });
63
+ });
64
+
65
+ it('passes n: undefined when enabled but no limit — armor uses its default', () => {
66
+ useQueryConfig({ maxAliases: { enabled: true } });
67
+ expect(EnvelopArmorPlugin.mock.calls[0][0].maxAliases).toEqual({
68
+ enabled: true,
69
+ n: undefined,
70
+ });
71
+ });
72
+ });
73
+
74
+ describe('maxTokens', () => {
75
+ it('passes enabled and limit (mapped to n)', () => {
76
+ useQueryConfig({ maxTokens: { enabled: true, limit: 500 } });
77
+ expect(EnvelopArmorPlugin.mock.calls[0][0].maxTokens).toEqual({ enabled: true, n: 500 });
78
+ });
79
+ });
80
+
81
+ describe('maxDirectives', () => {
82
+ it('passes enabled and limit (mapped to n)', () => {
83
+ useQueryConfig({ maxDirectives: { enabled: true, limit: 25 } });
84
+ expect(EnvelopArmorPlugin.mock.calls[0][0].maxDirectives).toEqual({ enabled: true, n: 25 });
85
+ });
86
+ });
87
+
88
+ describe('costLimit', () => {
89
+ it('passes enabled and maxCost', () => {
90
+ useQueryConfig({ costLimit: { enabled: true, maxCost: 2000 } });
91
+ expect(EnvelopArmorPlugin.mock.calls[0][0].costLimit).toEqual({
92
+ enabled: true,
93
+ maxCost: 2000,
94
+ });
95
+ });
96
+
97
+ it('passes enabled: false with maxCost retained', () => {
98
+ useQueryConfig({ costLimit: { enabled: false, maxCost: 1000 } });
99
+ expect(EnvelopArmorPlugin.mock.calls[0][0].costLimit).toEqual({
100
+ enabled: false,
101
+ maxCost: 1000,
102
+ });
103
+ });
104
+ });
105
+
106
+ describe('blockFieldSuggestion', () => {
107
+ function getPlugin(queryConfig) {
108
+ const [, plugin] = useQueryConfig(queryConfig);
109
+ return plugin;
110
+ }
111
+
112
+ function runValidate(plugin, messages) {
113
+ const errors = messages.map(m => Object.assign(new Error(m), {}));
114
+ let result = errors;
115
+ plugin.onValidate()({
116
+ valid: false,
117
+ result: errors,
118
+ setResult: e => {
119
+ result = e;
120
+ },
121
+ });
122
+ return result.map(e => e.message);
123
+ }
124
+
125
+ it('should always pass { enabled: false } to armor', () => {
126
+ useQueryConfig({ blockFieldSuggestion: { enabled: true, mask: '[hidden]' } });
127
+ expect(EnvelopArmorPlugin.mock.calls[0][0].blockFieldSuggestion).toEqual({ enabled: false });
128
+ });
129
+
130
+ it('should strip "Did you mean" hint when enabled', () => {
131
+ const out = runValidate(getPlugin({ blockFieldSuggestion: { enabled: true } }), [
132
+ 'Cannot query field "continentz". Did you mean "continents"?',
133
+ ]);
134
+ expect(out[0]).toBe('Cannot query field "continentz".');
135
+ });
136
+
137
+ it('should use custom mask when provided', () => {
138
+ const out = runValidate(
139
+ getPlugin({ blockFieldSuggestion: { enabled: true, mask: '[hidden]' } }),
140
+ ['Cannot query field "foo". Did you mean "bar"?'],
141
+ );
142
+ expect(out[0]).toBe('Cannot query field "foo". [hidden]');
143
+ });
144
+
145
+ it('should not strip hint when disabled', () => {
146
+ const out = runValidate(getPlugin({ blockFieldSuggestion: { enabled: false } }), [
147
+ 'Cannot query field "x". Did you mean "y"?',
148
+ ]);
149
+ expect(out[0]).toBe('Cannot query field "x". Did you mean "y"?');
150
+ });
151
+
152
+ it('should strip multi-candidate hint with custom mask', () => {
153
+ const out = runValidate(
154
+ getPlugin({ blockFieldSuggestion: { enabled: true, mask: '[hidden]' } }),
155
+ ['Cannot query field "nam". Did you mean "name", "names", or "named"?'],
156
+ );
157
+ expect(out[0]).toBe('Cannot query field "nam". [hidden]');
158
+ });
159
+ });
160
+
161
+ describe('maskErrors', () => {
162
+ it('is not passed to EnvelopArmorPlugin — handled separately via yoga maskedErrors', () => {
163
+ useQueryConfig({ maskErrors: { enabled: true, message: 'Unexpected error.' } });
164
+ expect(EnvelopArmorPlugin.mock.calls[0][0]).not.toHaveProperty('maskErrors');
165
+ });
166
+ });
167
+ });
@@ -0,0 +1,51 @@
1
+ const { EnvelopArmorPlugin } = require('@escape.tech/graphql-armor');
2
+
3
+ // All protections are opt-in — nothing enabled unless explicitly configured.
4
+ // blockFieldSuggestion uses a custom onValidate hook: armor's graphql@^16.10.0 creates a
5
+ // duplicate class under nohoist, breaking its instanceof GraphQLError check.
6
+ function useQueryConfig(queryConfig) {
7
+ return [
8
+ EnvelopArmorPlugin({
9
+ costLimit: queryConfig?.costLimit
10
+ ? { enabled: queryConfig.costLimit.enabled, maxCost: queryConfig.costLimit.maxCost }
11
+ : { enabled: false },
12
+ maxDepth: queryConfig?.maxDepth
13
+ ? { enabled: queryConfig.maxDepth.enabled, n: queryConfig.maxDepth.limit }
14
+ : { enabled: false },
15
+ maxAliases: queryConfig?.maxAliases
16
+ ? { enabled: queryConfig.maxAliases.enabled, n: queryConfig.maxAliases.limit }
17
+ : { enabled: false },
18
+ maxTokens: queryConfig?.maxTokens
19
+ ? { enabled: queryConfig.maxTokens.enabled, n: queryConfig.maxTokens.limit }
20
+ : { enabled: false },
21
+ maxDirectives: queryConfig?.maxDirectives
22
+ ? { enabled: queryConfig.maxDirectives.enabled, n: queryConfig.maxDirectives.limit }
23
+ : { enabled: false },
24
+ blockFieldSuggestion: { enabled: false },
25
+ }),
26
+ blockFieldSuggestionPlugin(queryConfig),
27
+ ];
28
+ }
29
+
30
+ function blockFieldSuggestionPlugin(queryConfig) {
31
+ const enabled = queryConfig?.blockFieldSuggestion?.enabled === true;
32
+ const mask = queryConfig?.blockFieldSuggestion?.mask ?? '';
33
+ return {
34
+ onValidate() {
35
+ return function onValidateEnd({ valid, result, setResult }) {
36
+ if (!valid && enabled) {
37
+ setResult(
38
+ result.map(error => {
39
+ if (typeof error.message === 'string') {
40
+ error.message = error.message.replace(/Did you mean ".+"\?/g, mask).trim();
41
+ }
42
+ return error;
43
+ }),
44
+ );
45
+ }
46
+ };
47
+ },
48
+ };
49
+ }
50
+
51
+ module.exports = useQueryConfig;
package/src/server.js CHANGED
@@ -4,13 +4,14 @@ import { KvStateApiImpl } from './state';
4
4
 
5
5
  const { getCorsOptions } = require('./cors');
6
6
  const { createYoga } = require('graphql-yoga');
7
- const { GraphQLError } = require('graphql/error');
8
7
 
9
8
  const { loadMeshSecrets, getSecretsHandler } = require('./secrets');
10
9
  const useComplianceHeaders = require('./plugins/complianceHeaders');
11
10
  const UseHttpDetailsExtensions = require('./plugins/httpDetailsExtensions');
11
+ const useQueryConfig = require('./plugins/queryConfig');
12
12
  const useSourceHeaders = require('@adobe/plugin-source-headers');
13
13
  const { useDisableIntrospection } = require('@envelop/disable-introspection');
14
+ const { maskError } = require('./utils/maskError');
14
15
 
15
16
  let meshInstance$;
16
17
 
@@ -31,6 +32,8 @@ async function buildMeshInstance(meshArtifacts, meshConfig) {
31
32
  options.additionalEnvelopPlugins.push(useDisableIntrospection());
32
33
  }
33
34
 
35
+ options.additionalEnvelopPlugins.push(...useQueryConfig(meshConfig.queryConfig));
36
+
34
37
  return getMesh(options).then(mesh => {
35
38
  const id = mesh.pubsub.subscribe('destroy', () => {
36
39
  meshInstance$ = undefined;
@@ -67,18 +70,14 @@ async function buildYogaServer(env, tenantMesh, meshConfig, meshSecrets) {
67
70
  state: stateApi,
68
71
  }),
69
72
  maskedErrors: {
70
- maskError: maskError,
73
+ maskError: error =>
74
+ maskError(error, {
75
+ mask: meshConfig.queryConfig?.maskErrors?.enabled === true,
76
+ message: meshConfig.queryConfig?.maskErrors?.message,
77
+ }),
71
78
  },
72
79
  logging: 'debug',
73
80
  });
74
81
  }
75
82
 
76
- const maskError = error => {
77
- if (error instanceof GraphQLError && error.extensions?.http?.headers) {
78
- delete error.extensions.http.headers;
79
- }
80
-
81
- return error;
82
- };
83
-
84
83
  module.exports = { buildServer };
@@ -0,0 +1,80 @@
1
+ const { GraphQLError } = require('graphql/error');
2
+ const { maskError } = require('../maskError');
3
+
4
+ describe('maskError', () => {
5
+ describe('header stripping', () => {
6
+ it('should remove http.headers from GraphQLError extensions', () => {
7
+ const error = new GraphQLError('oops', {
8
+ extensions: { http: { headers: { 'set-cookie': 'x' } } },
9
+ });
10
+ maskError(error, { mask: false });
11
+ expect(error.extensions?.http?.headers).toBeUndefined();
12
+ });
13
+
14
+ it('should leave other extensions intact when http.headers is absent', () => {
15
+ const error = new GraphQLError('oops', { extensions: { code: 'SOME_CODE' } });
16
+ maskError(error, { mask: false });
17
+ expect(error.extensions?.code).toBe('SOME_CODE');
18
+ });
19
+
20
+ it('should return non-GraphQL errors unchanged when mask is false', () => {
21
+ const error = new Error('plain error');
22
+ const result = maskError(error, { mask: false });
23
+ expect(result).toBe(error);
24
+ });
25
+ });
26
+
27
+ describe('mask: true (default)', () => {
28
+ it('should pass through errors with extensions.code', () => {
29
+ const error = new GraphQLError('unauthorized', { extensions: { code: 'UNAUTHENTICATED' } });
30
+ const result = maskError(error);
31
+ expect(result).toBe(error);
32
+ });
33
+
34
+ it('should replace unknown GraphQL errors with generic message', () => {
35
+ const error = new GraphQLError('internal details');
36
+ const result = maskError(error);
37
+ expect(result.message).toBe('Oops. Something went wrong.');
38
+ });
39
+
40
+ it('should replace non-GraphQL errors with generic message', () => {
41
+ const error = new Error('internal details');
42
+ const result = maskError(error);
43
+ expect(result.message).toBe('Oops. Something went wrong.');
44
+ });
45
+
46
+ it('should use custom message from opts.message when provided', () => {
47
+ const error = new GraphQLError('internal details');
48
+ const result = maskError(error, { message: 'Something went wrong.' });
49
+ expect(result.message).toBe('Something went wrong.');
50
+ });
51
+
52
+ it('should fall back to generic message when opts.message is not provided', () => {
53
+ const error = new GraphQLError('internal details');
54
+ const result = maskError(error, { mask: true });
55
+ expect(result.message).toBe('Oops. Something went wrong.');
56
+ });
57
+
58
+ it('should strip http.headers before masking', () => {
59
+ const error = new GraphQLError('oops', {
60
+ extensions: { http: { headers: { 'set-cookie': 'x' } } },
61
+ });
62
+ maskError(error);
63
+ expect(error.extensions?.http?.headers).toBeUndefined();
64
+ });
65
+ });
66
+
67
+ describe('mask: false', () => {
68
+ it('should return the original error message unchanged', () => {
69
+ const error = new GraphQLError('internal details');
70
+ const result = maskError(error, { mask: false });
71
+ expect(result.message).toBe('internal details');
72
+ });
73
+
74
+ it('should ignore opts.message when masking is disabled', () => {
75
+ const error = new GraphQLError('internal details');
76
+ const result = maskError(error, { mask: false, message: 'Should be ignored.' });
77
+ expect(result.message).toBe('internal details');
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,21 @@
1
+ const { GraphQLError } = require('graphql/error');
2
+
3
+ // Always strips http.headers from extensions (prevents duplicate Set-Cookie → Cloudflare error).
4
+ // When mask is true (default): errors with extensions.code pass through; all others are replaced
5
+ // with opts.message to avoid leaking stack traces or internal details.
6
+ const maskError = (error, opts) => {
7
+ if (error instanceof GraphQLError && error.extensions?.http?.headers) {
8
+ delete error.extensions.http.headers;
9
+ }
10
+
11
+ if (opts?.mask !== false) {
12
+ if (error instanceof GraphQLError && error.extensions?.code) {
13
+ return error;
14
+ }
15
+ return new GraphQLError(opts?.message || 'Oops. Something went wrong.');
16
+ }
17
+
18
+ return error;
19
+ };
20
+
21
+ module.exports = { maskError };