@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.
package/README.md CHANGED
@@ -99,6 +99,18 @@ For the operational workflow, sampler output, and heap snapshot usage, see [docs
99
99
  - `GET /debug/memory` -> memory/process/Graphile debug snapshot when observability is enabled
100
100
  - `GET /debug/db` -> PostgreSQL activity/locks/pool debug snapshot when observability is enabled
101
101
 
102
+ ## Auth cookies and CSRF
103
+
104
+ Browsers authenticate with the `constructive_session` cookie; every other client sends `Authorization: Bearer <accessToken>`.
105
+
106
+ - A successful sign-in mutation (`signIn`, `signUp`, `signInSso`, `signInMagicLink`, `signInEmailOtp`, `completeMfaChallenge`, …) returns the access token in the response body and also sets `constructive_session` (plus `constructive_device_token` when the mutation returns a device id). `Max-Age` comes from the API's auth settings (default 86400 s; `rememberMe: true` uses the remember-me duration).
107
+ - `signOut` always answers with expired `constructive_session` and `constructive_device_token` cookies (`Max-Age=0`), however the request authenticated and whether or not the mutation succeeded. `revokeSession` / `revokeAllSessions` clear them on success.
108
+ - Every request gets a `csrf_token` cookie (readable by JS, one day). A `POST /graphql` that carries `constructive_session` and no Bearer header must echo that value in `X-CSRF-Token`, or it is refused with 403 `CSRF_TOKEN_MISSING` / `CSRF_TOKEN_INVALID`. Bearer requests and requests without a session cookie skip the check.
109
+
110
+ ### Non-browser clients (native apps, CLIs, servers)
111
+
112
+ Send `X-Constructive-Session-Cookie: omit` on every request. Sign-in mutations then set no auth cookie: read `accessToken` from the response body and send it as `Authorization: Bearer <accessToken>`. Any other value of the header is refused with 400 `INVALID_SESSION_COOKIE_HEADER` before the operation runs. The header does not bypass CSRF: a request that still carries `constructive_session` without a Bearer header is checked like a browser's, so sign out with the Bearer token (its response expires any session cookie the client was given before it opted out).
113
+
102
114
  ## Scoped routing
103
115
 
104
116
  This is a production-only server: every request is resolved through the scoped-routing plane. There is no static single-tenant mode and no flag to disable routing. For single-database local development without route resolution or a database id, use [`@constructive-io/graphql-dev-server`](../dev-server/README.md).
@@ -22,6 +22,7 @@ export declare const ErrorCodes: {
22
22
  readonly DATABASE_CONNECTION_ERROR: "DATABASE_CONNECTION_ERROR";
23
23
  readonly AMBIGUOUS_TENANT: "AMBIGUOUS_TENANT";
24
24
  readonly ADMIN_AUTH_REQUIRED: "ADMIN_AUTH_REQUIRED";
25
+ readonly INVALID_SESSION_COOKIE_HEADER: "INVALID_SESSION_COOKIE_HEADER";
25
26
  };
26
27
  /**
27
28
  * Type alias for valid error codes.
@@ -161,6 +162,13 @@ export declare class AmbiguousTenantError extends ApiError {
161
162
  export declare class AdminAuthRequiredError extends ApiError {
162
163
  constructor(reason: string);
163
164
  }
165
+ /**
166
+ * Thrown when the session-cookie opt-out header carries a value other than
167
+ * the one the server accepts, so a typo never silently falls back to cookies.
168
+ */
169
+ export declare class InvalidSessionCookieHeaderError extends ApiError {
170
+ constructor(header: string, value: string, accepted: string);
171
+ }
164
172
  /**
165
173
  * Check if an error is an instance of ApiError or any of its subclasses.
166
174
  *
@@ -10,7 +10,7 @@
10
10
  * @module errors/api-errors
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.AdminAuthRequiredError = exports.AmbiguousTenantError = exports.SchemaAccessDeniedError = exports.DatabaseConnectionError = exports.HandlerCreationError = exports.SchemaValidationError = exports.NoValidSchemasError = exports.ApiNotFoundError = exports.DomainNotFoundError = exports.ApiError = exports.ErrorCodes = void 0;
13
+ exports.InvalidSessionCookieHeaderError = exports.AdminAuthRequiredError = exports.AmbiguousTenantError = exports.SchemaAccessDeniedError = exports.DatabaseConnectionError = exports.HandlerCreationError = exports.SchemaValidationError = exports.NoValidSchemasError = exports.ApiNotFoundError = exports.DomainNotFoundError = exports.ApiError = exports.ErrorCodes = void 0;
14
14
  exports.isApiError = isApiError;
15
15
  exports.hasErrorCode = hasErrorCode;
16
16
  // =============================================================================
@@ -30,6 +30,7 @@ exports.ErrorCodes = {
30
30
  DATABASE_CONNECTION_ERROR: 'DATABASE_CONNECTION_ERROR',
31
31
  AMBIGUOUS_TENANT: 'AMBIGUOUS_TENANT',
32
32
  ADMIN_AUTH_REQUIRED: 'ADMIN_AUTH_REQUIRED',
33
+ INVALID_SESSION_COOKIE_HEADER: 'INVALID_SESSION_COOKIE_HEADER',
33
34
  };
34
35
  // =============================================================================
35
36
  // Base Error Class
@@ -230,6 +231,17 @@ class AdminAuthRequiredError extends ApiError {
230
231
  }
231
232
  }
232
233
  exports.AdminAuthRequiredError = AdminAuthRequiredError;
234
+ /**
235
+ * Thrown when the session-cookie opt-out header carries a value other than
236
+ * the one the server accepts, so a typo never silently falls back to cookies.
237
+ */
238
+ class InvalidSessionCookieHeaderError extends ApiError {
239
+ constructor(header, value, accepted) {
240
+ super(exports.ErrorCodes.INVALID_SESSION_COOKIE_HEADER, 400, `${header} accepts only "${accepted}"`, { header, value, accepted });
241
+ this.name = 'InvalidSessionCookieHeaderError';
242
+ }
243
+ }
244
+ exports.InvalidSessionCookieHeaderError = InvalidSessionCookieHeaderError;
233
245
  // =============================================================================
234
246
  // Type Guards
235
247
  // =============================================================================
@@ -25,6 +25,7 @@ export const ErrorCodes = {
25
25
  DATABASE_CONNECTION_ERROR: 'DATABASE_CONNECTION_ERROR',
26
26
  AMBIGUOUS_TENANT: 'AMBIGUOUS_TENANT',
27
27
  ADMIN_AUTH_REQUIRED: 'ADMIN_AUTH_REQUIRED',
28
+ INVALID_SESSION_COOKIE_HEADER: 'INVALID_SESSION_COOKIE_HEADER',
28
29
  };
29
30
  // =============================================================================
30
31
  // Base Error Class
@@ -215,6 +216,16 @@ export class AdminAuthRequiredError extends ApiError {
215
216
  this.name = 'AdminAuthRequiredError';
216
217
  }
217
218
  }
219
+ /**
220
+ * Thrown when the session-cookie opt-out header carries a value other than
221
+ * the one the server accepts, so a typo never silently falls back to cookies.
222
+ */
223
+ export class InvalidSessionCookieHeaderError extends ApiError {
224
+ constructor(header, value, accepted) {
225
+ super(ErrorCodes.INVALID_SESSION_COOKIE_HEADER, 400, `${header} accepts only "${accepted}"`, { header, value, accepted });
226
+ this.name = 'InvalidSessionCookieHeaderError';
227
+ }
228
+ }
218
229
  // =============================================================================
219
230
  // Type Guards
220
231
  // =============================================================================
@@ -1,5 +1,13 @@
1
+ import { InvalidSessionCookieHeaderError } from '../errors/api-errors';
1
2
  export const SESSION_COOKIE_NAME = 'constructive_session';
2
3
  export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token';
4
+ /**
5
+ * Request header a non-browser client (native app, CLI, server) sends to opt
6
+ * out of auth cookies: sign-in mutations then return the access token in the
7
+ * response body only and set neither the session nor the device-token cookie.
8
+ */
9
+ export const SESSION_COOKIE_HEADER = 'X-Constructive-Session-Cookie';
10
+ export const SESSION_COOKIE_OMIT = 'omit';
3
11
  const DEVICE_TOKEN_MAX_AGE = 90 * 24 * 60 * 60; // 90 days in seconds
4
12
  /**
5
13
  * Build cookie config from AuthSettings with optional remember_me override.
@@ -89,6 +97,22 @@ export const clearDeviceTokenCookie = (res, config) => {
89
97
  path: config.path,
90
98
  });
91
99
  };
100
+ /**
101
+ * True when the request opted out of auth cookies via `SESSION_COOKIE_HEADER`.
102
+ */
103
+ export const isSessionCookieOmitted = (req) => req.get(SESSION_COOKIE_HEADER) === SESSION_COOKIE_OMIT;
104
+ /**
105
+ * Reject a `SESSION_COOKIE_HEADER` with any value but `omit`, before the
106
+ * request executes, so a misspelt opt-out fails loudly instead of setting the
107
+ * cookie the client asked not to receive.
108
+ */
109
+ export const rejectInvalidSessionCookieHeader = (req, _res, next) => {
110
+ const value = req.get(SESSION_COOKIE_HEADER);
111
+ if (value !== undefined && value !== SESSION_COOKIE_OMIT) {
112
+ return next(new InvalidSessionCookieHeaderError(SESSION_COOKIE_HEADER, value, SESSION_COOKIE_OMIT));
113
+ }
114
+ next();
115
+ };
92
116
  /**
93
117
  * Parse a cookie value from the raw Cookie header.
94
118
  * Avoids pulling in cookie-parser as a dependency.
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import '../middleware/types';
2
2
  import { Logger } from '@pgpmjs/logger';
3
- import { DEVICE_TOKEN_COOKIE_NAME, getDeviceTokenCookieConfig, getSessionCookieConfig, SESSION_COOKIE_NAME, } from '../middleware/cookie';
3
+ import { getOperationAST, Kind, parse } from 'graphql';
4
+ import { DEVICE_TOKEN_COOKIE_NAME, getDeviceTokenCookieConfig, getSessionCookieConfig, isSessionCookieOmitted, SESSION_COOKIE_NAME, } from '../middleware/cookie';
4
5
  const log = new Logger('auth-cookie');
5
6
  /**
6
7
  * Serialize a cookie to a Set-Cookie header value.
@@ -66,6 +67,7 @@ const SIGN_IN_MUTATIONS = new Set([
66
67
  'completeMfaChallenge',
67
68
  'signInOneTimeToken',
68
69
  'signInCrossOrigin',
70
+ 'webauthnSignInFinish',
69
71
  ]);
70
72
  /**
71
73
  * Auth mutations that should clear the session cookie.
@@ -76,26 +78,21 @@ const SIGN_OUT_MUTATIONS = new Set([
76
78
  'revokeAllSessions',
77
79
  ]);
78
80
  /**
79
- * Extract mutation names from a GraphQL query string.
81
+ * Top-level field names of the mutation that will execute: the operation
82
+ * picked by `operationName`, so other operations in the document never count.
80
83
  */
81
- const extractMutationNames = (query) => {
82
- const mutations = [];
83
- if (!/^\s*mutation\b/i.test(query)) {
84
- return mutations;
84
+ const extractMutationNames = (query, operationName) => {
85
+ let document;
86
+ try {
87
+ document = parse(query);
85
88
  }
86
- const bodyStart = query.indexOf('{');
87
- if (bodyStart === -1)
88
- return mutations;
89
- const bodyContent = query.slice(bodyStart + 1);
90
- const fieldPattern = /(\w+)\s*(?:\(|{)/g;
91
- let match;
92
- while ((match = fieldPattern.exec(bodyContent)) !== null) {
93
- const name = match[1];
94
- if (name !== 'mutation' && name !== 'query' && name !== 'fragment') {
95
- mutations.push(name);
96
- }
89
+ catch {
90
+ return [];
97
91
  }
98
- return mutations;
92
+ const operation = getOperationAST(document, operationName);
93
+ if (operation?.operation !== 'mutation')
94
+ return [];
95
+ return operation.selectionSet.selections.flatMap((selection) => selection.kind === Kind.FIELD ? [selection.name.value] : []);
99
96
  };
100
97
  /**
101
98
  * Extract access token from mutation response.
@@ -198,7 +195,7 @@ export const AuthCookiePlugin = {
198
195
  return result;
199
196
  }
200
197
  // Extract mutation names
201
- const mutationNames = extractMutationNames(body.query);
198
+ const mutationNames = extractMutationNames(body.query, body.operationName);
202
199
  if (mutationNames.length === 0) {
203
200
  return result;
204
201
  }
@@ -213,24 +210,25 @@ export const AuthCookiePlugin = {
213
210
  // Parse response body
214
211
  const payload = bufferResult.buffer.toString('utf8');
215
212
  const graphqlResponse = JSON.parse(payload);
216
- // Skip if there are GraphQL errors
217
- if (graphqlResponse.errors?.length || !graphqlResponse.data) {
218
- return result;
219
- }
220
- const data = graphqlResponse.data;
213
+ const succeeded = !graphqlResponse.errors?.length && Boolean(graphqlResponse.data);
214
+ const data = graphqlResponse.data ?? {};
221
215
  const authSettings = req.api?.authSettings;
222
216
  const cookiesToSet = [];
223
- // Handle sign-out mutations
224
- if (signOutMutation && data[signOutMutation]) {
225
- log.info('[auth-cookie] Sign-out mutation succeeded, clearing session cookie');
217
+ // signOut always clears the cookies, whatever the mutation returned
218
+ // and however the request authenticated: a Bearer client that was
219
+ // once handed a session cookie must not keep sending it afterwards.
220
+ // revokeSession/revokeAllSessions clear them only on success.
221
+ if (signOutMutation === 'signOut' || (signOutMutation && succeeded && data[signOutMutation])) {
222
+ log.info(`[auth-cookie] ${signOutMutation}: clearing session cookie`);
226
223
  const config = getSessionCookieConfig(authSettings);
227
224
  cookiesToSet.push(serializeClearCookie(SESSION_COOKIE_NAME, config));
228
225
  // Also clear device token on sign-out
229
226
  const deviceConfig = getDeviceTokenCookieConfig(authSettings);
230
227
  cookiesToSet.push(serializeClearCookie(DEVICE_TOKEN_COOKIE_NAME, deviceConfig));
231
228
  }
232
- // Handle sign-in mutations
233
- if (signInMutation) {
229
+ // Handle sign-in mutations; a client that sent the opt-out header
230
+ // reads the token from the response body and gets no cookies.
231
+ if (signInMutation && succeeded && !isSessionCookieOmitted(req)) {
234
232
  const accessToken = extractAccessToken(data, signInMutation);
235
233
  if (accessToken) {
236
234
  const rememberMe = hasRememberMe(body.variables);
@@ -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 ?? []) {