@constructive-io/graphql-server 5.28.9 → 5.29.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/esm/middleware/error-handler.js +5 -20
- package/esm/middleware/{mask-error.js → format-error.js} +37 -62
- package/esm/middleware/graphile.js +10 -8
- package/esm/plugins/auth-cookie-plugin.js +1 -0
- package/esm/plugins/error-events-plugin.js +3 -3
- package/esm/plugins/webauthn-plugin.js +392 -0
- package/middleware/error-handler.js +5 -20
- package/middleware/format-error.d.ts +28 -0
- package/middleware/{mask-error.js → format-error.js} +38 -63
- package/middleware/graphile.js +10 -8
- package/package.json +20 -18
- package/plugins/auth-cookie-plugin.js +1 -0
- package/plugins/error-events-plugin.js +4 -4
- package/plugins/webauthn-plugin.d.ts +3 -0
- package/plugins/webauthn-plugin.js +395 -0
- package/middleware/mask-error.d.ts +0 -27
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import './types';
|
|
2
|
-
import { getNodeEnv } from '@pgpmjs/env';
|
|
3
2
|
import { Logger } from '@pgpmjs/logger';
|
|
4
3
|
import errorPage50x from '../errors/50x';
|
|
5
4
|
import errorPage404Message from '../errors/404-message';
|
|
6
5
|
import { isApiError } from '../errors/api-errors';
|
|
7
6
|
const log = new Logger('error-handler');
|
|
8
|
-
const isDevelopment = () => getNodeEnv() === 'development';
|
|
9
7
|
const wantsJson = (req) => {
|
|
10
8
|
const accept = req.get('Accept') || '';
|
|
11
9
|
return accept.includes('application/json')
|
|
12
10
|
|| accept.includes('application/graphql-response+json')
|
|
13
11
|
|| Boolean(req.is('json'));
|
|
14
12
|
};
|
|
15
|
-
const sanitizeMessage = (error) => {
|
|
16
|
-
if (isDevelopment())
|
|
17
|
-
return error.message;
|
|
18
|
-
if (isApiError(error))
|
|
19
|
-
return error.message;
|
|
20
|
-
if (error.message?.includes('ECONNREFUSED'))
|
|
21
|
-
return 'Service temporarily unavailable';
|
|
22
|
-
if (error.message?.includes('timeout') || error.message?.includes('ETIMEDOUT'))
|
|
23
|
-
return 'Request timed out';
|
|
24
|
-
if (error.message?.includes('does not exist'))
|
|
25
|
-
return 'The requested resource does not exist';
|
|
26
|
-
return 'An unexpected error occurred';
|
|
27
|
-
};
|
|
28
13
|
const isCsrfError = (err) => {
|
|
29
14
|
const code = err.code;
|
|
30
15
|
return typeof code === 'string' && code.startsWith('CSRF_');
|
|
@@ -34,7 +19,7 @@ const categorizeError = (err) => {
|
|
|
34
19
|
return {
|
|
35
20
|
statusCode: err.statusCode,
|
|
36
21
|
code: err.code,
|
|
37
|
-
message:
|
|
22
|
+
message: err.message,
|
|
38
23
|
logLevel: err.statusCode >= 500 ? 'error' : 'warn',
|
|
39
24
|
};
|
|
40
25
|
}
|
|
@@ -43,12 +28,12 @@ const categorizeError = (err) => {
|
|
|
43
28
|
return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
|
|
44
29
|
}
|
|
45
30
|
if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
|
|
46
|
-
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message:
|
|
31
|
+
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: err.message, logLevel: 'error' };
|
|
47
32
|
}
|
|
48
33
|
if (err.message?.includes('timeout') || err.message?.includes('ETIMEDOUT')) {
|
|
49
|
-
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message:
|
|
34
|
+
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: err.message, logLevel: 'error' };
|
|
50
35
|
}
|
|
51
|
-
return { statusCode: 500, code: 'INTERNAL_ERROR', message:
|
|
36
|
+
return { statusCode: 500, code: 'INTERNAL_ERROR', message: err.message, logLevel: 'error' };
|
|
52
37
|
};
|
|
53
38
|
const sendResponse = (req, res, { statusCode, code, message }) => {
|
|
54
39
|
if (wantsJson(req)) {
|
|
@@ -72,7 +57,7 @@ const logError = (err, req, level) => {
|
|
|
72
57
|
log[level]({ event: 'api_error', code: err.code, statusCode: err.statusCode, message: err.message, ...context });
|
|
73
58
|
}
|
|
74
59
|
else {
|
|
75
|
-
log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack:
|
|
60
|
+
log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: err.stack, ...context });
|
|
76
61
|
}
|
|
77
62
|
};
|
|
78
63
|
export const errorHandler = (err, req, res, _next) => {
|
|
@@ -1,39 +1,20 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { getNodeEnv } from '@pgpmjs/env';
|
|
2
|
+
import { parse } from '@constructive-io/errors';
|
|
4
3
|
import { Logger } from '@pgpmjs/logger';
|
|
5
|
-
const
|
|
6
|
-
/**
|
|
7
|
-
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
8
|
-
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
9
|
-
* codes in the `@constructive-io/errors` registry. They are always safe to
|
|
10
|
-
* surface — they carry no sensitive detail. Everything else (auth, account,
|
|
11
|
-
* resource, constraint, and every constructive-db code) is classified by the
|
|
12
|
-
* registry, which is the single source of truth for public vs. internal.
|
|
13
|
-
*/
|
|
14
|
-
const GRAPHQL_PROTOCOL_CODES = new Set([
|
|
15
|
-
'GRAPHQL_VALIDATION_FAILED',
|
|
16
|
-
'GRAPHQL_PARSE_FAILED',
|
|
17
|
-
'PERSISTED_QUERY_NOT_FOUND',
|
|
18
|
-
'PERSISTED_QUERY_NOT_SUPPORTED'
|
|
19
|
-
]);
|
|
20
|
-
/** A code is safe to surface when the registry classifies it public, or it is a
|
|
21
|
-
* GraphQL framework protocol code. */
|
|
22
|
-
const isPublicCode = (code) => Boolean(code) && (classify(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
|
|
4
|
+
const formatErrorLog = new Logger('graphile:formatError');
|
|
23
5
|
/**
|
|
24
6
|
* An error the GraphQL layer raised about the *request*, before any resolver
|
|
25
7
|
* ran: an unknown input field, a value of the wrong type, a missing required
|
|
26
8
|
* variable. graphql-js reports variable coercion without an `extensions.code`
|
|
27
9
|
* (unlike parse/validation, which carry `GRAPHQL_PARSE_FAILED` /
|
|
28
|
-
* `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone
|
|
29
|
-
*
|
|
30
|
-
* failure, with a reference id pointing at nothing.
|
|
10
|
+
* `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone would read
|
|
11
|
+
* it as a server failure instead of the client's own malformed query.
|
|
31
12
|
*
|
|
32
13
|
* A request error is answered before a field is resolved, so it carries no
|
|
33
14
|
* response `path` — every execution error has one. Coercion wraps the inner
|
|
34
15
|
* complaint about the value, so `originalError` may be set, but only ever to
|
|
35
16
|
* another GraphQL-layer error: anything a resolver or the database threw arrives
|
|
36
|
-
* as a foreign error (a pg error, an `Error`)
|
|
17
|
+
* as a foreign error (a pg error, an `Error`). The wrap
|
|
37
18
|
* is recognized by name rather than by `instanceof`, because the error is raised
|
|
38
19
|
* by whichever copy of graphql-js grafast resolved, not by this package's.
|
|
39
20
|
*/
|
|
@@ -63,8 +44,10 @@ const withoutInputValues = (error) => {
|
|
|
63
44
|
.replace(/^Value "[\s\S]*" does not exist in/, 'Value does not exist in');
|
|
64
45
|
return `${variable} got invalid value${at}; ${complaint}`;
|
|
65
46
|
};
|
|
66
|
-
/** The code a
|
|
47
|
+
/** The code a request error carries when graphql-js supplied none. */
|
|
67
48
|
const BAD_USER_INPUT = 'BAD_USER_INPUT';
|
|
49
|
+
/** The code any other error carries when nothing supplied one. */
|
|
50
|
+
const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR';
|
|
68
51
|
/**
|
|
69
52
|
* Normalize any GraphQL/database error into a canonical Constructive shape.
|
|
70
53
|
*
|
|
@@ -80,21 +63,21 @@ export const normalizeError = (error) => {
|
|
|
80
63
|
return { code: parsed.code, context: parsed.context, class: parsed.class };
|
|
81
64
|
};
|
|
82
65
|
/**
|
|
83
|
-
*
|
|
66
|
+
* Format every GraphQL error the same way, in every environment. Nothing is
|
|
67
|
+
* masked: the client always receives the real message.
|
|
84
68
|
*
|
|
85
|
-
* 1.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* 2.
|
|
89
|
-
* 3.
|
|
90
|
-
* 4.
|
|
91
|
-
* the
|
|
69
|
+
* 1. Lift the structured code onto `extensions.code`/`class`/`context` from the
|
|
70
|
+
* parsed error, so database errors reach clients with a machine-readable
|
|
71
|
+
* code instead of a bare message with empty `extensions`.
|
|
72
|
+
* 2. A request error graphql-js raised without a code is `BAD_USER_INPUT`.
|
|
73
|
+
* 3. Any other error without a code is `INTERNAL_SERVER_ERROR`.
|
|
74
|
+
* 4. Every internal error (unknown, or registered as internal) carries an
|
|
75
|
+
* `errorId` and is logged under it, so a report can be matched to the log.
|
|
92
76
|
*/
|
|
93
|
-
export const
|
|
77
|
+
export const formatError = (error) => {
|
|
94
78
|
const { code, context, class: errorClass } = normalizeError(error);
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// on GraphQLError, so we build a formatted error rather than mutating it).
|
|
79
|
+
// `extensions` is read-only on GraphQLError, so build a formatted error
|
|
80
|
+
// rather than mutating it.
|
|
98
81
|
const extensions = { ...error.extensions };
|
|
99
82
|
if (code) {
|
|
100
83
|
extensions.code = code;
|
|
@@ -103,33 +86,25 @@ export const maskError = (error) => {
|
|
|
103
86
|
extensions.context = context;
|
|
104
87
|
}
|
|
105
88
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
89
|
+
let internal = Boolean(code) && errorClass === 'internal';
|
|
90
|
+
let message = error.message;
|
|
91
|
+
if (!code && !error.extensions?.code) {
|
|
92
|
+
const requestError = isRequestError(error);
|
|
93
|
+
if (requestError)
|
|
94
|
+
message = withoutInputValues(error);
|
|
95
|
+
extensions.code = requestError ? BAD_USER_INPUT : INTERNAL_SERVER_ERROR;
|
|
96
|
+
internal = !requestError;
|
|
114
97
|
}
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
message: error.message,
|
|
120
|
-
...(error.locations ? { locations: error.locations } : {}),
|
|
121
|
-
...(error.path ? { path: error.path } : {}),
|
|
122
|
-
extensions,
|
|
123
|
-
};
|
|
98
|
+
if (internal) {
|
|
99
|
+
const errorId = crypto.randomBytes(8).toString('hex');
|
|
100
|
+
extensions.errorId = errorId;
|
|
101
|
+
formatErrorLog.error(`[graphql-error:${errorId}]`, error);
|
|
124
102
|
}
|
|
125
|
-
//
|
|
126
|
-
const errorId = crypto.randomBytes(8).toString('hex');
|
|
127
|
-
maskErrorLog.error(`[masked-error:${errorId}]`, error);
|
|
103
|
+
// grafserv strips originalError before serializing to the client.
|
|
128
104
|
return {
|
|
129
|
-
message
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
105
|
+
message,
|
|
106
|
+
...(error.locations ? { locations: error.locations } : {}),
|
|
107
|
+
...(error.path ? { path: error.path } : {}),
|
|
108
|
+
extensions
|
|
134
109
|
};
|
|
135
110
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import './types'; // for Request type
|
|
2
2
|
import { errors } from '@constructive-io/errors';
|
|
3
3
|
import { DEFAULT_REQUEST_PROTECTION, protectionPgSettings } from '@constructive-io/express-context';
|
|
4
|
-
import { getNodeEnv } from '@pgpmjs/env';
|
|
5
4
|
import { Logger } from '@pgpmjs/logger';
|
|
6
5
|
import { createGraphileInstance, graphileCache } from 'graphile-cache';
|
|
7
6
|
import { createFunctionBindingsPlugin } from 'graphile-function-bindings';
|
|
@@ -14,10 +13,10 @@ import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
|
14
13
|
import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
|
|
15
14
|
import { createErrorEventsPlugin } from '../plugins/error-events-plugin';
|
|
16
15
|
import { RequestProtectionPlugin } from '../plugins/request-protection-plugin';
|
|
16
|
+
import { WebauthnPlugin } from '../plugins/webauthn-plugin';
|
|
17
|
+
import { formatError } from './format-error';
|
|
17
18
|
import { makeIntrospectionWiring } from './graphile-introspection';
|
|
18
|
-
import { maskError } from './mask-error';
|
|
19
19
|
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
20
|
-
const isDev = () => getNodeEnv() === 'development';
|
|
21
20
|
// =============================================================================
|
|
22
21
|
// Single-Flight Pattern: In-Flight Tracking
|
|
23
22
|
// =============================================================================
|
|
@@ -57,7 +56,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
57
56
|
* plugin preset. Without settings the default preset is used
|
|
58
57
|
* (everything on except aggregates).
|
|
59
58
|
*/
|
|
60
|
-
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute) => {
|
|
59
|
+
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
|
|
61
60
|
const introspection = await makeIntrospectionWiring(pool, schemas, graphileOptions, undefined, introspectionRole);
|
|
62
61
|
const configuredPreset = graphileOptions?.preset ?? {};
|
|
63
62
|
const grafastCachePreset = createGrafastCacheLimitsPreset(graphileOptions?.grafastCache);
|
|
@@ -77,6 +76,9 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
77
76
|
AuthCookiePlugin,
|
|
78
77
|
RequestProtectionPlugin,
|
|
79
78
|
createErrorEventsPlugin(pool),
|
|
79
|
+
// Only when the tenant provisioned the WebAuthn module; the policy
|
|
80
|
+
// itself is read per request from `req.api.webauthnSettings`.
|
|
81
|
+
...(webauthn ? [WebauthnPlugin] : []),
|
|
80
82
|
// Only registered when the compute module is provisioned for this
|
|
81
83
|
// database — all schema/table names come from the constructive
|
|
82
84
|
// metaschema (express-context compute module loader); the plugin has
|
|
@@ -103,10 +105,10 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
|
|
|
103
105
|
graphiqlPath: '/graphiql',
|
|
104
106
|
graphiql: true,
|
|
105
107
|
graphiqlOnGraphQLGET: false,
|
|
106
|
-
maskError
|
|
108
|
+
maskError: formatError
|
|
107
109
|
},
|
|
108
110
|
grafast: {
|
|
109
|
-
explain:
|
|
111
|
+
explain: graphileOptions?.explain === true,
|
|
110
112
|
context: (requestContext) => {
|
|
111
113
|
// In grafserv/express/v4, the request is available at requestContext.expressv4.req
|
|
112
114
|
const req = requestContext?.expressv4?.req;
|
|
@@ -309,7 +311,7 @@ export const graphile = (opts) => {
|
|
|
309
311
|
// concurrent requests coalesce while optional plugin modules load.
|
|
310
312
|
const creationPromise = (async () => {
|
|
311
313
|
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
312
|
-
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute);
|
|
314
|
+
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
|
|
313
315
|
return observeGraphileBuild({
|
|
314
316
|
cacheKey: key,
|
|
315
317
|
serviceKey: key,
|
|
@@ -343,7 +345,7 @@ export const graphile = (opts) => {
|
|
|
343
345
|
log.error(`${label} PostGraphile middleware error`, e);
|
|
344
346
|
if (!res.headersSent) {
|
|
345
347
|
respondWithGraphQLError(res, errors.INTERNAL_FAILURE({
|
|
346
|
-
details:
|
|
348
|
+
details: e?.message ?? String(e)
|
|
347
349
|
}));
|
|
348
350
|
return;
|
|
349
351
|
}
|
|
@@ -3,13 +3,13 @@ import { Logger } from '@pgpmjs/logger';
|
|
|
3
3
|
import { getOperationAST } from 'graphql';
|
|
4
4
|
import { escapeIdentifier } from 'pg';
|
|
5
5
|
import { withPgClient } from 'pg-query-context';
|
|
6
|
-
import { normalizeError } from '../middleware/
|
|
6
|
+
import { normalizeError } from '../middleware/format-error';
|
|
7
7
|
const log = new Logger('error-events');
|
|
8
8
|
const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
|
|
9
9
|
/**
|
|
10
10
|
* The first structured, public-classified registry code among the errors.
|
|
11
|
-
* Internal/unknown errors are bugs, not refusals: they are
|
|
12
|
-
*
|
|
11
|
+
* Internal/unknown errors are bugs, not refusals: they are logged by
|
|
12
|
+
* `formatError` and never recorded as tenant events.
|
|
13
13
|
*/
|
|
14
14
|
const refusalCode = (errors) => {
|
|
15
15
|
for (const error of errors ?? []) {
|