@constructive-io/graphql-server 5.28.9 → 5.29.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.
@@ -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: sanitizeMessage(err),
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: sanitizeMessage(err), logLevel: 'error' };
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: sanitizeMessage(err), logLevel: 'error' };
34
+ return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: err.message, logLevel: 'error' };
50
35
  }
51
- return { statusCode: 500, code: 'INTERNAL_ERROR', message: sanitizeMessage(err), logLevel: 'error' };
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: isDevelopment() ? err.stack : undefined, ...context });
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 { classify, parse } from '@constructive-io/errors';
3
- import { getNodeEnv } from '@pgpmjs/env';
2
+ import { parse } from '@constructive-io/errors';
4
3
  import { Logger } from '@pgpmjs/logger';
5
- const maskErrorLog = new Logger('graphile:maskError');
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 reads it as
29
- * unknown and masks it — telling a client its own malformed query was a server
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`) and is masked as before. The wrap
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 surfaced request error carries when graphql-js supplied none. */
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
- * Production-aware error handling backed by `@constructive-io/errors`.
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. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
86
- * always receive a machine-readable code (fixing the gap where database
87
- * errors reached clients as a bare message with empty `extensions`).
88
- * 2. Surface public (registered/allowlisted) errors as-is.
89
- * 3. In development, pass everything through (enriched) for debugging.
90
- * 4. In production, mask internal/unknown errors behind a reference ID and log
91
- * the original.
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 maskError = (error) => {
77
+ export const formatError = (error) => {
94
78
  const { code, context, class: errorClass } = normalizeError(error);
95
- // Lift the structured code onto extensions for every recognized error so
96
- // clients always receive a machine-readable code (`extensions` is read-only
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
- const effectiveCode = code ?? error.extensions?.code;
107
- if (!effectiveCode && isRequestError(error)) {
108
- extensions.code = BAD_USER_INPUT;
109
- return {
110
- message: withoutInputValues(error),
111
- ...(error.locations ? { locations: error.locations } : {}),
112
- extensions,
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 (isPublicCode(effectiveCode) || getNodeEnv() === 'development') {
116
- // Note: grafserv strips originalError and internal extensions before
117
- // serializing to the client, so returning the enriched error is safe.
118
- return {
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
- // Mask internal/unknown errors with a reference ID.
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: `An unexpected error occurred. Reference: ${errorId}`,
130
- extensions: {
131
- code: 'INTERNAL_SERVER_ERROR',
132
- errorId
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: process.env.NODE_ENV === 'development',
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: isDev() ? e?.message ?? String(e) : 'An unexpected error occurred'
348
+ details: e?.message ?? String(e)
347
349
  }));
348
350
  return;
349
351
  }
@@ -67,6 +67,7 @@ const SIGN_IN_MUTATIONS = new Set([
67
67
  'completeMfaChallenge',
68
68
  'signInOneTimeToken',
69
69
  'signInCrossOrigin',
70
+ 'webauthnSignInFinish',
70
71
  ]);
71
72
  /**
72
73
  * Auth mutations that should clear the session cookie.
@@ -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/mask-error';
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 masked and logged
12
- * by `maskError` and never recorded as tenant events.
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 ?? []) {