@constructive-io/graphql-server 5.28.8 → 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.
@@ -3,43 +3,24 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.maskError = exports.normalizeError = void 0;
6
+ exports.formatError = exports.normalizeError = void 0;
7
7
  const node_crypto_1 = __importDefault(require("node:crypto"));
8
8
  const errors_1 = require("@constructive-io/errors");
9
- const env_1 = require("@pgpmjs/env");
10
9
  const logger_1 = require("@pgpmjs/logger");
11
- const maskErrorLog = new logger_1.Logger('graphile:maskError');
12
- /**
13
- * GraphQL framework protocol codes. These originate in the GraphQL/grafast
14
- * transport layer (not in constructive-db), so they are not Constructive domain
15
- * codes in the `@constructive-io/errors` registry. They are always safe to
16
- * surface — they carry no sensitive detail. Everything else (auth, account,
17
- * resource, constraint, and every constructive-db code) is classified by the
18
- * registry, which is the single source of truth for public vs. internal.
19
- */
20
- const GRAPHQL_PROTOCOL_CODES = new Set([
21
- 'GRAPHQL_VALIDATION_FAILED',
22
- 'GRAPHQL_PARSE_FAILED',
23
- 'PERSISTED_QUERY_NOT_FOUND',
24
- 'PERSISTED_QUERY_NOT_SUPPORTED'
25
- ]);
26
- /** A code is safe to surface when the registry classifies it public, or it is a
27
- * GraphQL framework protocol code. */
28
- const isPublicCode = (code) => Boolean(code) && ((0, errors_1.classify)(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
10
+ const formatErrorLog = new logger_1.Logger('graphile:formatError');
29
11
  /**
30
12
  * An error the GraphQL layer raised about the *request*, before any resolver
31
13
  * ran: an unknown input field, a value of the wrong type, a missing required
32
14
  * variable. graphql-js reports variable coercion without an `extensions.code`
33
15
  * (unlike parse/validation, which carry `GRAPHQL_PARSE_FAILED` /
34
- * `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone reads it as
35
- * unknown and masks it — telling a client its own malformed query was a server
36
- * failure, with a reference id pointing at nothing.
16
+ * `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone would read
17
+ * it as a server failure instead of the client's own malformed query.
37
18
  *
38
19
  * A request error is answered before a field is resolved, so it carries no
39
20
  * response `path` — every execution error has one. Coercion wraps the inner
40
21
  * complaint about the value, so `originalError` may be set, but only ever to
41
22
  * another GraphQL-layer error: anything a resolver or the database threw arrives
42
- * as a foreign error (a pg error, an `Error`) and is masked as before. The wrap
23
+ * as a foreign error (a pg error, an `Error`). The wrap
43
24
  * is recognized by name rather than by `instanceof`, because the error is raised
44
25
  * by whichever copy of graphql-js grafast resolved, not by this package's.
45
26
  */
@@ -69,8 +50,10 @@ const withoutInputValues = (error) => {
69
50
  .replace(/^Value "[\s\S]*" does not exist in/, 'Value does not exist in');
70
51
  return `${variable} got invalid value${at}; ${complaint}`;
71
52
  };
72
- /** The code a surfaced request error carries when graphql-js supplied none. */
53
+ /** The code a request error carries when graphql-js supplied none. */
73
54
  const BAD_USER_INPUT = 'BAD_USER_INPUT';
55
+ /** The code any other error carries when nothing supplied one. */
56
+ const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR';
74
57
  /**
75
58
  * Normalize any GraphQL/database error into a canonical Constructive shape.
76
59
  *
@@ -87,21 +70,21 @@ const normalizeError = (error) => {
87
70
  };
88
71
  exports.normalizeError = normalizeError;
89
72
  /**
90
- * Production-aware error handling backed by `@constructive-io/errors`.
73
+ * Format every GraphQL error the same way, in every environment. Nothing is
74
+ * masked: the client always receives the real message.
91
75
  *
92
- * 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
93
- * always receive a machine-readable code (fixing the gap where database
94
- * errors reached clients as a bare message with empty `extensions`).
95
- * 2. Surface public (registered/allowlisted) errors as-is.
96
- * 3. In development, pass everything through (enriched) for debugging.
97
- * 4. In production, mask internal/unknown errors behind a reference ID and log
98
- * the original.
76
+ * 1. Lift the structured code onto `extensions.code`/`class`/`context` from the
77
+ * parsed error, so database errors reach clients with a machine-readable
78
+ * code instead of a bare message with empty `extensions`.
79
+ * 2. A request error graphql-js raised without a code is `BAD_USER_INPUT`.
80
+ * 3. Any other error without a code is `INTERNAL_SERVER_ERROR`.
81
+ * 4. Every internal error (unknown, or registered as internal) carries an
82
+ * `errorId` and is logged under it, so a report can be matched to the log.
99
83
  */
100
- const maskError = (error) => {
84
+ const formatError = (error) => {
101
85
  const { code, context, class: errorClass } = (0, exports.normalizeError)(error);
102
- // Lift the structured code onto extensions for every recognized error so
103
- // clients always receive a machine-readable code (`extensions` is read-only
104
- // on GraphQLError, so we build a formatted error rather than mutating it).
86
+ // `extensions` is read-only on GraphQLError, so build a formatted error
87
+ // rather than mutating it.
105
88
  const extensions = { ...error.extensions };
106
89
  if (code) {
107
90
  extensions.code = code;
@@ -110,34 +93,26 @@ const maskError = (error) => {
110
93
  extensions.context = context;
111
94
  }
112
95
  }
113
- const effectiveCode = code ?? error.extensions?.code;
114
- if (!effectiveCode && isRequestError(error)) {
115
- extensions.code = BAD_USER_INPUT;
116
- return {
117
- message: withoutInputValues(error),
118
- ...(error.locations ? { locations: error.locations } : {}),
119
- extensions,
120
- };
96
+ let internal = Boolean(code) && errorClass === 'internal';
97
+ let message = error.message;
98
+ if (!code && !error.extensions?.code) {
99
+ const requestError = isRequestError(error);
100
+ if (requestError)
101
+ message = withoutInputValues(error);
102
+ extensions.code = requestError ? BAD_USER_INPUT : INTERNAL_SERVER_ERROR;
103
+ internal = !requestError;
121
104
  }
122
- if (isPublicCode(effectiveCode) || (0, env_1.getNodeEnv)() === 'development') {
123
- // Note: grafserv strips originalError and internal extensions before
124
- // serializing to the client, so returning the enriched error is safe.
125
- return {
126
- message: error.message,
127
- ...(error.locations ? { locations: error.locations } : {}),
128
- ...(error.path ? { path: error.path } : {}),
129
- extensions,
130
- };
105
+ if (internal) {
106
+ const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
107
+ extensions.errorId = errorId;
108
+ formatErrorLog.error(`[graphql-error:${errorId}]`, error);
131
109
  }
132
- // Mask internal/unknown errors with a reference ID.
133
- const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
134
- maskErrorLog.error(`[masked-error:${errorId}]`, error);
110
+ // grafserv strips originalError before serializing to the client.
135
111
  return {
136
- message: `An unexpected error occurred. Reference: ${errorId}`,
137
- extensions: {
138
- code: 'INTERNAL_SERVER_ERROR',
139
- errorId
140
- }
112
+ message,
113
+ ...(error.locations ? { locations: error.locations } : {}),
114
+ ...(error.path ? { path: error.path } : {}),
115
+ extensions
141
116
  };
142
117
  };
143
- exports.maskError = maskError;
118
+ exports.formatError = formatError;
@@ -7,7 +7,6 @@ exports.clearInFlightMap = clearInFlightMap;
7
7
  require("./types"); // for Request type
8
8
  const errors_1 = require("@constructive-io/errors");
9
9
  const express_context_1 = require("@constructive-io/express-context");
10
- const env_1 = require("@pgpmjs/env");
11
10
  const logger_1 = require("@pgpmjs/logger");
12
11
  const graphile_cache_1 = require("graphile-cache");
13
12
  const graphile_function_bindings_1 = require("graphile-function-bindings");
@@ -20,10 +19,10 @@ const graphql_response_1 = require("../errors/graphql-response");
20
19
  const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
21
20
  const error_events_plugin_1 = require("../plugins/error-events-plugin");
22
21
  const request_protection_plugin_1 = require("../plugins/request-protection-plugin");
22
+ const webauthn_plugin_1 = require("../plugins/webauthn-plugin");
23
+ const format_error_1 = require("./format-error");
23
24
  const graphile_introspection_1 = require("./graphile-introspection");
24
- const mask_error_1 = require("./mask-error");
25
25
  const graphile_build_stats_1 = require("./observability/graphile-build-stats");
26
- const isDev = () => (0, env_1.getNodeEnv)() === 'development';
27
26
  // =============================================================================
28
27
  // Single-Flight Pattern: In-Flight Tracking
29
28
  // =============================================================================
@@ -63,7 +62,7 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
63
62
  * plugin preset. Without settings the default preset is used
64
63
  * (everything on except aggregates).
65
64
  */
66
- const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute) => {
65
+ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute, webauthn) => {
67
66
  const introspection = await (0, graphile_introspection_1.makeIntrospectionWiring)(pool, schemas, graphileOptions, undefined, introspectionRole);
68
67
  const configuredPreset = graphileOptions?.preset ?? {};
69
68
  const grafastCachePreset = (0, graphile_settings_1.createGrafastCacheLimitsPreset)(graphileOptions?.grafastCache);
@@ -83,6 +82,9 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
83
82
  auth_cookie_plugin_1.AuthCookiePlugin,
84
83
  request_protection_plugin_1.RequestProtectionPlugin,
85
84
  (0, error_events_plugin_1.createErrorEventsPlugin)(pool),
85
+ // Only when the tenant provisioned the WebAuthn module; the policy
86
+ // itself is read per request from `req.api.webauthnSettings`.
87
+ ...(webauthn ? [webauthn_plugin_1.WebauthnPlugin] : []),
86
88
  // Only registered when the compute module is provisioned for this
87
89
  // database — all schema/table names come from the constructive
88
90
  // metaschema (express-context compute module loader); the plugin has
@@ -109,10 +111,10 @@ const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole,
109
111
  graphiqlPath: '/graphiql',
110
112
  graphiql: true,
111
113
  graphiqlOnGraphQLGET: false,
112
- maskError: mask_error_1.maskError
114
+ maskError: format_error_1.formatError
113
115
  },
114
116
  grafast: {
115
- explain: process.env.NODE_ENV === 'development',
117
+ explain: graphileOptions?.explain === true,
116
118
  context: (requestContext) => {
117
119
  // In grafserv/express/v4, the request is available at requestContext.expressv4.req
118
120
  const req = requestContext?.expressv4?.req;
@@ -315,7 +317,7 @@ const graphile = (opts) => {
315
317
  // concurrent requests coalesce while optional plugin modules load.
316
318
  const creationPromise = (async () => {
317
319
  const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
318
- const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute);
320
+ const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute, Boolean(api.webauthnSettings));
319
321
  return (0, graphile_build_stats_1.observeGraphileBuild)({
320
322
  cacheKey: key,
321
323
  serviceKey: key,
@@ -349,7 +351,7 @@ const graphile = (opts) => {
349
351
  log.error(`${label} PostGraphile middleware error`, e);
350
352
  if (!res.headersSent) {
351
353
  (0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.INTERNAL_FAILURE({
352
- details: isDev() ? e?.message ?? String(e) : 'An unexpected error occurred'
354
+ details: e?.message ?? String(e)
353
355
  }));
354
356
  return;
355
357
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.28.8",
3
+ "version": "5.29.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -30,8 +30,7 @@
30
30
  "debug:heap:capture": "node scripts/capture-heap-snapshot.mjs",
31
31
  "lint": "eslint . --fix",
32
32
  "test": "jest --passWithNoTests",
33
- "test:watch": "jest --watch",
34
- "bucket:create": "ts-node src/scripts/create-bucket.ts"
33
+ "test:watch": "jest --watch"
35
34
  },
36
35
  "keywords": [
37
36
  "server",
@@ -42,20 +41,22 @@
42
41
  ],
43
42
  "dependencies": {
44
43
  "@agentic-kit/ollama": "2.15.0",
45
- "@constructive-io/csrf": "^0.30.0",
46
- "@constructive-io/errors": "^0.22.0",
47
- "@constructive-io/express-context": "^0.33.5",
48
- "@constructive-io/graphql-env": "^3.34.5",
49
- "@constructive-io/graphql-types": "^3.33.5",
44
+ "@constructive-io/csrf": "^0.30.1",
45
+ "@constructive-io/errors": "^0.24.0",
46
+ "@constructive-io/express-context": "^0.33.7",
47
+ "@constructive-io/graphql-env": "^3.35.0",
48
+ "@constructive-io/graphql-types": "^3.34.0",
50
49
  "@constructive-io/llm-env": "^0.15.0",
51
- "@constructive-io/query-builder": "^3.16.6",
50
+ "@constructive-io/query-builder": "^3.16.8",
52
51
  "@constructive-io/s3-utils": "^2.34.0",
53
52
  "@constructive-io/url-domains": "^2.31.0",
54
53
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
55
- "@pgpmjs/env": "^2.46.2",
54
+ "@pgpmjs/env": "^2.47.0",
56
55
  "@pgpmjs/logger": "^2.26.0",
57
- "@pgpmjs/server-utils": "^3.29.5",
58
- "@pgpmjs/types": "^2.56.2",
56
+ "@pgpmjs/server-utils": "^3.29.7",
57
+ "@pgpmjs/types": "^2.57.0",
58
+ "@pgsql/quotes": "^18.2.4",
59
+ "@simplewebauthn/server": "13.3.2",
59
60
  "cors": "^2.8.6",
60
61
  "deepmerge": "^4.3.1",
61
62
  "express": "^5.2.1",
@@ -64,17 +65,18 @@
64
65
  "grafserv": "1.0.1",
65
66
  "graphile-build": "5.1.1",
66
67
  "graphile-build-pg": "5.1.3",
67
- "graphile-cache": "^4.14.5",
68
+ "graphile-cache": "^4.14.7",
68
69
  "graphile-config": "1.1.0",
69
- "graphile-function-bindings": "^1.17.6",
70
- "graphile-scoped-introspection": "^0.4.6",
71
- "graphile-settings": "^6.24.7",
70
+ "graphile-function-bindings": "^1.17.8",
71
+ "graphile-plugin-utils": "^1.13.0",
72
+ "graphile-scoped-introspection": "^0.4.8",
73
+ "graphile-settings": "^6.25.0",
72
74
  "graphile-utils": "5.0.3",
73
75
  "graphql": "16.13.0",
74
76
  "graphql-upload": "^13.0.0",
75
77
  "lru-cache": "^11.2.7",
76
78
  "pg": "^8.21.0",
77
- "pg-cache": "^3.29.5",
79
+ "pg-cache": "^3.29.7",
78
80
  "pg-env": "^1.32.0",
79
81
  "pg-query-context": "^2.31.0",
80
82
  "pg-sql2": "5.0.1",
@@ -91,11 +93,11 @@
91
93
  "@types/request-ip": "^0.0.41",
92
94
  "@types/supertest": "^7.2.1",
93
95
  "cookie-parser": "^1.4.7",
94
- "graphile-test": "5.17.6",
96
+ "graphile-test": "5.17.8",
95
97
  "makage": "^0.8.0",
96
98
  "nodemon": "^3.1.14",
97
99
  "supertest": "^7.2.2",
98
100
  "ts-node": "^10.9.2"
99
101
  },
100
- "gitHead": "31fb086361c3e2f3806d7945abe5074b3ce58f76"
102
+ "gitHead": "1f3501d518e175803a1a3372cf9249922d82bfe5"
101
103
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AuthCookiePlugin = void 0;
4
4
  require("../middleware/types");
5
5
  const logger_1 = require("@pgpmjs/logger");
6
+ const graphql_1 = require("graphql");
6
7
  const cookie_1 = require("../middleware/cookie");
7
8
  const log = new logger_1.Logger('auth-cookie');
8
9
  /**
@@ -69,6 +70,7 @@ const SIGN_IN_MUTATIONS = new Set([
69
70
  'completeMfaChallenge',
70
71
  'signInOneTimeToken',
71
72
  'signInCrossOrigin',
73
+ 'webauthnSignInFinish',
72
74
  ]);
73
75
  /**
74
76
  * Auth mutations that should clear the session cookie.
@@ -79,26 +81,21 @@ const SIGN_OUT_MUTATIONS = new Set([
79
81
  'revokeAllSessions',
80
82
  ]);
81
83
  /**
82
- * Extract mutation names from a GraphQL query string.
84
+ * Top-level field names of the mutation that will execute: the operation
85
+ * picked by `operationName`, so other operations in the document never count.
83
86
  */
84
- const extractMutationNames = (query) => {
85
- const mutations = [];
86
- if (!/^\s*mutation\b/i.test(query)) {
87
- return mutations;
87
+ const extractMutationNames = (query, operationName) => {
88
+ let document;
89
+ try {
90
+ document = (0, graphql_1.parse)(query);
88
91
  }
89
- const bodyStart = query.indexOf('{');
90
- if (bodyStart === -1)
91
- return mutations;
92
- const bodyContent = query.slice(bodyStart + 1);
93
- const fieldPattern = /(\w+)\s*(?:\(|{)/g;
94
- let match;
95
- while ((match = fieldPattern.exec(bodyContent)) !== null) {
96
- const name = match[1];
97
- if (name !== 'mutation' && name !== 'query' && name !== 'fragment') {
98
- mutations.push(name);
99
- }
92
+ catch {
93
+ return [];
100
94
  }
101
- return mutations;
95
+ const operation = (0, graphql_1.getOperationAST)(document, operationName);
96
+ if (operation?.operation !== 'mutation')
97
+ return [];
98
+ return operation.selectionSet.selections.flatMap((selection) => selection.kind === graphql_1.Kind.FIELD ? [selection.name.value] : []);
102
99
  };
103
100
  /**
104
101
  * Extract access token from mutation response.
@@ -201,7 +198,7 @@ exports.AuthCookiePlugin = {
201
198
  return result;
202
199
  }
203
200
  // Extract mutation names
204
- const mutationNames = extractMutationNames(body.query);
201
+ const mutationNames = extractMutationNames(body.query, body.operationName);
205
202
  if (mutationNames.length === 0) {
206
203
  return result;
207
204
  }
@@ -216,24 +213,25 @@ exports.AuthCookiePlugin = {
216
213
  // Parse response body
217
214
  const payload = bufferResult.buffer.toString('utf8');
218
215
  const graphqlResponse = JSON.parse(payload);
219
- // Skip if there are GraphQL errors
220
- if (graphqlResponse.errors?.length || !graphqlResponse.data) {
221
- return result;
222
- }
223
- const data = graphqlResponse.data;
216
+ const succeeded = !graphqlResponse.errors?.length && Boolean(graphqlResponse.data);
217
+ const data = graphqlResponse.data ?? {};
224
218
  const authSettings = req.api?.authSettings;
225
219
  const cookiesToSet = [];
226
- // Handle sign-out mutations
227
- if (signOutMutation && data[signOutMutation]) {
228
- log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie');
220
+ // signOut always clears the cookies, whatever the mutation returned
221
+ // and however the request authenticated: a Bearer client that was
222
+ // once handed a session cookie must not keep sending it afterwards.
223
+ // revokeSession/revokeAllSessions clear them only on success.
224
+ if (signOutMutation === 'signOut' || (signOutMutation && succeeded && data[signOutMutation])) {
225
+ log.info(`[auth-cookie] ${signOutMutation}: clearing session cookie`);
229
226
  const config = (0, cookie_1.getSessionCookieConfig)(authSettings);
230
227
  cookiesToSet.push(serializeClearCookie(cookie_1.SESSION_COOKIE_NAME, config));
231
228
  // Also clear device token on sign-out
232
229
  const deviceConfig = (0, cookie_1.getDeviceTokenCookieConfig)(authSettings);
233
230
  cookiesToSet.push(serializeClearCookie(cookie_1.DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
234
231
  }
235
- // Handle sign-in mutations
236
- if (signInMutation) {
232
+ // Handle sign-in mutations; a client that sent the opt-out header
233
+ // reads the token from the response body and gets no cookies.
234
+ if (signInMutation && succeeded && !(0, cookie_1.isSessionCookieOmitted)(req)) {
237
235
  const accessToken = extractAccessToken(data, signInMutation);
238
236
  if (accessToken) {
239
237
  const rememberMe = hasRememberMe(body.variables);
@@ -6,17 +6,17 @@ const logger_1 = require("@pgpmjs/logger");
6
6
  const graphql_1 = require("graphql");
7
7
  const pg_1 = require("pg");
8
8
  const pg_query_context_1 = require("pg-query-context");
9
- const mask_error_1 = require("../middleware/mask-error");
9
+ const format_error_1 = require("../middleware/format-error");
10
10
  const log = new logger_1.Logger('error-events');
11
11
  const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
12
12
  /**
13
13
  * The first structured, public-classified registry code among the errors.
14
- * Internal/unknown errors are bugs, not refusals: they are masked and logged
15
- * by `maskError` and never recorded as tenant events.
14
+ * Internal/unknown errors are bugs, not refusals: they are logged by
15
+ * `formatError` and never recorded as tenant events.
16
16
  */
17
17
  const refusalCode = (errors) => {
18
18
  for (const error of errors ?? []) {
19
- const { code, class: errorClass } = (0, mask_error_1.normalizeError)(error);
19
+ const { code, class: errorClass } = (0, format_error_1.normalizeError)(error);
20
20
  if (code && errorClass === 'public')
21
21
  return code;
22
22
  }
@@ -0,0 +1,3 @@
1
+ import '../middleware/types';
2
+ import type { GraphileConfig } from 'graphile-config';
3
+ export declare const WebauthnPlugin: GraphileConfig.Plugin;