@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.
@@ -0,0 +1,392 @@
1
+ import '../middleware/types';
2
+ import { QuoteUtils } from '@pgsql/quotes';
3
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
4
+ import { isoBase64URL } from '@simplewebauthn/server/helpers';
5
+ import { context as grafastContext, lambda, object } from 'grafast';
6
+ import { SYSTEM_LANE_ROLE } from 'graphile-plugin-utils';
7
+ import { extendSchema, gql } from 'graphile-utils';
8
+ const CONTEXT_KEY = 'constructiveWebauthn';
9
+ const REGISTER_CHALLENGE = 'webauthn_register_challenge';
10
+ const SIGN_IN_CHALLENGE = 'webauthn_sign_in_challenge';
11
+ const MAX_NAME_LENGTH = 256;
12
+ const MAX_RESPONSE_BYTES = 64 * 1024;
13
+ const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
14
+ const fail = (code, message) => {
15
+ const err = new Error(message);
16
+ err.extensions = { code };
17
+ throw err;
18
+ };
19
+ /** Apply the request's settings transaction-locally, optionally overriding its role. */
20
+ const applySettings = async (tx, pgSettings, role) => {
21
+ const entries = Object.entries(pgSettings ?? {}).filter(([key]) => !role || key !== 'role');
22
+ if (role)
23
+ entries.push(['role', role]);
24
+ if (entries.length === 0)
25
+ return;
26
+ await tx.query({
27
+ text: 'SELECT set_config(el->>0, el->>1, true) FROM json_array_elements($1::json) el',
28
+ values: [JSON.stringify(entries)]
29
+ });
30
+ };
31
+ const withRequestLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
32
+ await applySettings(tx, pgSettings);
33
+ return cb(tx);
34
+ }));
35
+ const withVerifierLane = (withPgClient, pgSettings, cb) => withPgClient(null, (client) => client.withTransaction(async (tx) => {
36
+ await applySettings(tx, pgSettings, SYSTEM_LANE_ROLE);
37
+ return cb(tx);
38
+ }));
39
+ const procedure = (settings, name) => QuoteUtils.quoteQualifiedIdentifier(settings.schema, name);
40
+ const requireSettings = (ctx) => {
41
+ const settings = ctx?.settings;
42
+ if (!settings)
43
+ throw new Error('WebauthnPlugin is loaded but the request resolved no webauthnSettings');
44
+ return settings;
45
+ };
46
+ const requireUserId = (pgSettings) => {
47
+ const userId = pgSettings?.['jwt.claims.user_id'];
48
+ if (!userId)
49
+ fail('UNAUTHENTICATED', 'Passkey registration requires a signed-in user');
50
+ return userId;
51
+ };
52
+ const payloadObject = (operation, value) => {
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
54
+ throw new Error(`${operation} returned no payload`);
55
+ }
56
+ return value;
57
+ };
58
+ const payloadString = (operation, payload, key) => {
59
+ const value = payload[key];
60
+ if (typeof value !== 'string' || !value)
61
+ throw new Error(`${operation} returned no ${key}`);
62
+ return value;
63
+ };
64
+ const credentialDescriptors = (operation, value) => {
65
+ if (value === null || value === undefined)
66
+ return [];
67
+ if (!Array.isArray(value))
68
+ throw new Error(`${operation} returned invalid credentials`);
69
+ return value.map((entry) => {
70
+ const descriptor = payloadObject(operation, entry);
71
+ const transports = descriptor.transports ?? [];
72
+ if (!Array.isArray(transports) || !transports.every((t) => typeof t === 'string')) {
73
+ throw new Error(`${operation} returned invalid credential transports`);
74
+ }
75
+ return {
76
+ id: payloadString(operation, descriptor, 'id'),
77
+ transports: transports
78
+ };
79
+ });
80
+ };
81
+ const requireJsonObject = (value, field) => {
82
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
83
+ fail('BAD_USER_INPUT', `${field} must be a WebAuthn credential JSON object`);
84
+ }
85
+ if (JSON.stringify(value).length > MAX_RESPONSE_BYTES) {
86
+ fail('BAD_USER_INPUT', `${field} is too large`);
87
+ }
88
+ return value;
89
+ };
90
+ const challengeMatches = async (tx, settings, challengeName, challenge, challengeId) => {
91
+ const { rows } = await tx.query({
92
+ text: `SELECT ${procedure(settings, 'webauthn_challenge_matches')}($1, $2, $3::uuid) AS matches`,
93
+ values: [challengeName, challenge, challengeId]
94
+ });
95
+ return rows[0]?.matches === true;
96
+ };
97
+ /**
98
+ * SimpleWebAuthn reports a response that fails verification by throwing; give
99
+ * that a public code. A database error from the challenge lookup inside it
100
+ * keeps its own.
101
+ */
102
+ const verifying = async (verify) => {
103
+ try {
104
+ return await verify();
105
+ }
106
+ catch (err) {
107
+ if (typeof err?.code === 'string' && err.severity)
108
+ throw err;
109
+ return fail('WEBAUTHN_VERIFICATION_FAILED', err?.message ?? String(err));
110
+ }
111
+ };
112
+ const userVerification = (settings) => settings.requireUserVerification ? 'required' : 'preferred';
113
+ const lookupCurrentUser = async (tx, req) => {
114
+ const surface = await req?.constructive?.useModule('authSurface');
115
+ if (!surface)
116
+ return { username: null, displayName: null };
117
+ const { rows } = await tx.query({
118
+ text: `SELECT username, display_name FROM ${QuoteUtils.quoteQualifiedIdentifier(surface.publicSchema, 'current_user')}()`
119
+ });
120
+ const row = rows[0];
121
+ return {
122
+ username: typeof row?.username === 'string' ? row.username : null,
123
+ displayName: typeof row?.display_name === 'string' ? row.display_name : null
124
+ };
125
+ };
126
+ const resolveArgs = (fieldArgs) => object({
127
+ input: fieldArgs.getRaw('input'),
128
+ withPgClient: grafastContext().get('withPgClient'),
129
+ pgSettings: grafastContext().get('pgSettings'),
130
+ webauthn: grafastContext().get(CONTEXT_KEY)
131
+ });
132
+ export const WebauthnPlugin = {
133
+ ...extendSchema(() => ({
134
+ typeDefs: gql `
135
+ input WebauthnRegisterBeginInput {
136
+ clientMutationId: String
137
+ }
138
+
139
+ type WebauthnRegisterBeginPayload {
140
+ """PublicKeyCredentialCreationOptionsJSON for navigator.credentials.create()."""
141
+ options: JSON!
142
+ """The user handle to echo back to webauthnRegisterFinish."""
143
+ webauthnUserId: String!
144
+ }
145
+
146
+ input WebauthnRegisterFinishInput {
147
+ """The browser's RegistrationResponseJSON."""
148
+ response: JSON!
149
+ webauthnUserId: String!
150
+ """A label for the passkey."""
151
+ name: String
152
+ }
153
+
154
+ type WebauthnRegisterFinishPayload {
155
+ credentialId: String!
156
+ }
157
+
158
+ input WebauthnSignInBeginInput {
159
+ clientMutationId: String
160
+ }
161
+
162
+ type WebauthnSignInBeginPayload {
163
+ """PublicKeyCredentialRequestOptionsJSON for navigator.credentials.get()."""
164
+ options: JSON!
165
+ """Names the pending challenge; echo it back to webauthnSignInFinish."""
166
+ challengeId: UUID!
167
+ }
168
+
169
+ input WebauthnSignInFinishInput {
170
+ """The browser's AuthenticationResponseJSON."""
171
+ response: JSON!
172
+ challengeId: UUID!
173
+ }
174
+
175
+ type WebauthnSignInFinishPayload {
176
+ accessToken: String!
177
+ accessTokenExpiresAt: Datetime!
178
+ userId: UUID!
179
+ }
180
+
181
+ extend type Mutation {
182
+ """Begin registering a passkey for the signed-in user."""
183
+ webauthnRegisterBegin(input: WebauthnRegisterBeginInput): WebauthnRegisterBeginPayload
184
+ """Verify the browser's registration response and store the passkey."""
185
+ webauthnRegisterFinish(input: WebauthnRegisterFinishInput!): WebauthnRegisterFinishPayload
186
+ """Begin a passkey sign-in (discoverable when signed out)."""
187
+ webauthnSignInBegin(input: WebauthnSignInBeginInput): WebauthnSignInBeginPayload
188
+ """Verify the browser's assertion and mint a session."""
189
+ webauthnSignInFinish(input: WebauthnSignInFinishInput!): WebauthnSignInFinishPayload
190
+ }
191
+ `,
192
+ plans: {
193
+ Mutation: {
194
+ webauthnRegisterBegin(_$mutation, fieldArgs) {
195
+ return lambda(resolveArgs(fieldArgs), async ({ withPgClient, pgSettings, webauthn }) => {
196
+ const settings = requireSettings(webauthn);
197
+ const userId = requireUserId(pgSettings);
198
+ const { registration, user } = await withRequestLane(withPgClient, pgSettings, async (tx) => {
199
+ const { rows } = await tx.query({
200
+ text: `SELECT ${procedure(settings, 'webauthn_begin_registration')}($1::uuid) AS result`,
201
+ values: [userId]
202
+ });
203
+ const payload = payloadObject('webauthn_begin_registration', rows[0]?.result);
204
+ return {
205
+ registration: {
206
+ challenge: payloadString('webauthn_begin_registration', payload, 'challenge'),
207
+ webauthnUserId: payloadString('webauthn_begin_registration', payload, 'webauthn_user_id'),
208
+ excludeCredentials: credentialDescriptors('webauthn_begin_registration', payload.excludeCredentials)
209
+ },
210
+ user: await lookupCurrentUser(tx, webauthn?.req)
211
+ };
212
+ });
213
+ const authenticatorSelection = {
214
+ residentKey: settings.residentKey,
215
+ userVerification: userVerification(settings)
216
+ };
217
+ const options = await generateRegistrationOptions({
218
+ // The tenant mints the challenge already base64url-encoded; a
219
+ // string challenge would be re-encoded as UTF-8 and the browser
220
+ // would echo a value the tenant never stored.
221
+ challenge: isoBase64URL.toBuffer(registration.challenge),
222
+ rpID: settings.rpId,
223
+ rpName: settings.rpName,
224
+ attestationType: settings.attestationType,
225
+ authenticatorSelection,
226
+ excludeCredentials: registration.excludeCredentials,
227
+ timeout: settings.challengeExpirySeconds * 1000,
228
+ userID: new TextEncoder().encode(registration.webauthnUserId),
229
+ userName: user.username ?? userId,
230
+ userDisplayName: user.displayName ?? user.username ?? userId
231
+ });
232
+ return { options, webauthnUserId: registration.webauthnUserId };
233
+ });
234
+ },
235
+ webauthnRegisterFinish(_$mutation, fieldArgs) {
236
+ return lambda(resolveArgs(fieldArgs), async ({ input, withPgClient, pgSettings, webauthn }) => {
237
+ const settings = requireSettings(webauthn);
238
+ const userId = requireUserId(pgSettings);
239
+ const response = requireJsonObject(input?.response, 'response');
240
+ const webauthnUserId = input?.webauthnUserId;
241
+ const name = input?.name ?? null;
242
+ if (name !== null && name.length > MAX_NAME_LENGTH) {
243
+ fail('BAD_USER_INPUT', `name must be at most ${MAX_NAME_LENGTH} characters`);
244
+ }
245
+ const verified = await withVerifierLane(withPgClient, pgSettings, (tx) => verifying(() => verifyRegistrationResponse({
246
+ response,
247
+ expectedChallenge: (challenge) => challengeMatches(tx, settings, REGISTER_CHALLENGE, challenge, null),
248
+ expectedOrigin: settings.originAllowlist,
249
+ expectedRPID: settings.rpId,
250
+ requireUserVerification: settings.requireUserVerification
251
+ })));
252
+ if (!verified.verified)
253
+ fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn registration verification failed');
254
+ const { credential, credentialDeviceType, credentialBackedUp } = verified.registrationInfo;
255
+ const credentialId = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
256
+ const { rows } = await tx.query({
257
+ text: `SELECT ${procedure(settings, 'webauthn_finish_registration')}(
258
+ $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
259
+ ) AS id`,
260
+ values: [
261
+ credential.id,
262
+ Buffer.from(credential.publicKey),
263
+ credential.counter,
264
+ credential.transports ?? [],
265
+ credentialDeviceType,
266
+ // SimpleWebAuthn reports backup eligibility through the
267
+ // device type; backed-up is the separate current-state flag.
268
+ credentialDeviceType === 'multiDevice',
269
+ credentialBackedUp,
270
+ webauthnUserId,
271
+ userId,
272
+ name
273
+ ]
274
+ });
275
+ return payloadString('webauthn_finish_registration', rows[0] ?? {}, 'id');
276
+ });
277
+ return { credentialId };
278
+ });
279
+ },
280
+ webauthnSignInBegin(_$mutation, fieldArgs) {
281
+ return lambda(resolveArgs(fieldArgs), async ({ withPgClient, pgSettings, webauthn }) => {
282
+ const settings = requireSettings(webauthn);
283
+ const signIn = await withRequestLane(withPgClient, pgSettings, async (tx) => {
284
+ const { rows } = await tx.query({
285
+ text: `SELECT ${procedure(settings, 'webauthn_begin_sign_in')}($1::uuid) AS result`,
286
+ values: [pgSettings?.['jwt.claims.user_id'] ?? null]
287
+ });
288
+ const payload = payloadObject('webauthn_begin_sign_in', rows[0]?.result);
289
+ return {
290
+ challenge: payloadString('webauthn_begin_sign_in', payload, 'challenge'),
291
+ challengeId: payloadString('webauthn_begin_sign_in', payload, 'challengeId'),
292
+ allowCredentials: credentialDescriptors('webauthn_begin_sign_in', payload.allowCredentials)
293
+ };
294
+ });
295
+ const options = await generateAuthenticationOptions({
296
+ challenge: isoBase64URL.toBuffer(signIn.challenge),
297
+ rpID: settings.rpId,
298
+ allowCredentials: signIn.allowCredentials,
299
+ userVerification: userVerification(settings),
300
+ timeout: settings.challengeExpirySeconds * 1000
301
+ });
302
+ return { options, challengeId: signIn.challengeId };
303
+ });
304
+ },
305
+ webauthnSignInFinish(_$mutation, fieldArgs) {
306
+ return lambda(resolveArgs(fieldArgs), async ({ input, withPgClient, pgSettings, webauthn }) => {
307
+ const settings = requireSettings(webauthn);
308
+ const response = requireJsonObject(input?.response, 'response');
309
+ const challengeId = input?.challengeId;
310
+ const credentialId = response.id;
311
+ if (typeof credentialId !== 'string' || !credentialId) {
312
+ fail('BAD_USER_INPUT', 'response.id is required');
313
+ }
314
+ // One transaction: the challenge read, the counter check and the
315
+ // session mint see the same state, and a refusal undoes them all.
316
+ const row = await withVerifierLane(withPgClient, pgSettings, async (tx) => {
317
+ const { rows: credentialRows } = await tx.query({
318
+ text: `SELECT public_key, sign_count, transports
319
+ FROM ${procedure(settings, 'webauthn_credential_for_assertion')}($1, $2::uuid)`,
320
+ values: [credentialId, challengeId]
321
+ });
322
+ const stored = credentialRows[0];
323
+ if (!stored || !(stored.public_key instanceof Uint8Array)) {
324
+ throw new Error('webauthn_credential_for_assertion returned no public key');
325
+ }
326
+ const publicKey = Uint8Array.from(stored.public_key);
327
+ const transports = stored.transports;
328
+ if (!Array.isArray(transports) || !transports.every((t) => typeof t === 'string')) {
329
+ throw new Error('webauthn_credential_for_assertion returned invalid transports');
330
+ }
331
+ const signCount = Number(stored.sign_count);
332
+ if (!Number.isSafeInteger(signCount) || signCount < 0) {
333
+ throw new Error('webauthn_credential_for_assertion returned invalid sign count');
334
+ }
335
+ const verified = await verifying(() => verifyAuthenticationResponse({
336
+ response,
337
+ expectedChallenge: (challenge) => challengeMatches(tx, settings, SIGN_IN_CHALLENGE, challenge, challengeId),
338
+ expectedOrigin: settings.originAllowlist,
339
+ expectedRPID: settings.rpId,
340
+ credential: {
341
+ id: credentialId,
342
+ publicKey,
343
+ counter: signCount,
344
+ transports: transports
345
+ },
346
+ requireUserVerification: settings.requireUserVerification
347
+ }));
348
+ if (!verified.verified) {
349
+ fail('WEBAUTHN_VERIFICATION_FAILED', 'WebAuthn authentication verification failed');
350
+ }
351
+ const { rows } = await tx.query({
352
+ text: `SELECT user_id, access_token, access_token_expires_at
353
+ FROM ${procedure(settings, 'webauthn_finish_sign_in')}($1, $2, $3, $4, $5::uuid)`,
354
+ values: [
355
+ credentialId,
356
+ verified.authenticationInfo.newCounter,
357
+ verified.authenticationInfo.credentialBackedUp,
358
+ 'bearer',
359
+ challengeId
360
+ ]
361
+ });
362
+ return rows[0];
363
+ });
364
+ if (!row?.access_token || !row.user_id) {
365
+ throw new Error('webauthn_finish_sign_in minted no session');
366
+ }
367
+ return {
368
+ accessToken: row.access_token,
369
+ accessTokenExpiresAt: row.access_token_expires_at,
370
+ userId: row.user_id
371
+ };
372
+ });
373
+ }
374
+ }
375
+ }
376
+ })),
377
+ name: 'WebauthnPlugin',
378
+ version: '0.0.0',
379
+ description: 'WebAuthn passkey registration and sign-in mutations, verified in the server.',
380
+ grafast: {
381
+ middleware: {
382
+ prepareArgs(next, event) {
383
+ const req = getExpressRequest(event.args.requestContext);
384
+ // Read per request, not baked into the cached schema: a tenant
385
+ // changing its RP ID or origins takes effect with the next request.
386
+ const ctx = { settings: req?.api?.webauthnSettings, req };
387
+ event.args.contextValue[CONTEXT_KEY] = ctx;
388
+ return next();
389
+ }
390
+ }
391
+ }
392
+ };
@@ -5,32 +5,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.notFoundHandler = exports.errorHandler = void 0;
7
7
  require("./types");
8
- const env_1 = require("@pgpmjs/env");
9
8
  const logger_1 = require("@pgpmjs/logger");
10
9
  const _50x_1 = __importDefault(require("../errors/50x"));
11
10
  const _404_message_1 = __importDefault(require("../errors/404-message"));
12
11
  const api_errors_1 = require("../errors/api-errors");
13
12
  const log = new logger_1.Logger('error-handler');
14
- const isDevelopment = () => (0, env_1.getNodeEnv)() === 'development';
15
13
  const wantsJson = (req) => {
16
14
  const accept = req.get('Accept') || '';
17
15
  return accept.includes('application/json')
18
16
  || accept.includes('application/graphql-response+json')
19
17
  || Boolean(req.is('json'));
20
18
  };
21
- const sanitizeMessage = (error) => {
22
- if (isDevelopment())
23
- return error.message;
24
- if ((0, api_errors_1.isApiError)(error))
25
- return error.message;
26
- if (error.message?.includes('ECONNREFUSED'))
27
- return 'Service temporarily unavailable';
28
- if (error.message?.includes('timeout') || error.message?.includes('ETIMEDOUT'))
29
- return 'Request timed out';
30
- if (error.message?.includes('does not exist'))
31
- return 'The requested resource does not exist';
32
- return 'An unexpected error occurred';
33
- };
34
19
  const isCsrfError = (err) => {
35
20
  const code = err.code;
36
21
  return typeof code === 'string' && code.startsWith('CSRF_');
@@ -40,7 +25,7 @@ const categorizeError = (err) => {
40
25
  return {
41
26
  statusCode: err.statusCode,
42
27
  code: err.code,
43
- message: sanitizeMessage(err),
28
+ message: err.message,
44
29
  logLevel: err.statusCode >= 500 ? 'error' : 'warn',
45
30
  };
46
31
  }
@@ -49,12 +34,12 @@ const categorizeError = (err) => {
49
34
  return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
50
35
  }
51
36
  if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
52
- return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err), logLevel: 'error' };
37
+ return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: err.message, logLevel: 'error' };
53
38
  }
54
39
  if (err.message?.includes('timeout') || err.message?.includes('ETIMEDOUT')) {
55
- return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: sanitizeMessage(err), logLevel: 'error' };
40
+ return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: err.message, logLevel: 'error' };
56
41
  }
57
- return { statusCode: 500, code: 'INTERNAL_ERROR', message: sanitizeMessage(err), logLevel: 'error' };
42
+ return { statusCode: 500, code: 'INTERNAL_ERROR', message: err.message, logLevel: 'error' };
58
43
  };
59
44
  const sendResponse = (req, res, { statusCode, code, message }) => {
60
45
  if (wantsJson(req)) {
@@ -78,7 +63,7 @@ const logError = (err, req, level) => {
78
63
  log[level]({ event: 'api_error', code: err.code, statusCode: err.statusCode, message: err.message, ...context });
79
64
  }
80
65
  else {
81
- log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: isDevelopment() ? err.stack : undefined, ...context });
66
+ log[level]({ event: 'unexpected_error', name: err.name, message: err.message, stack: err.stack, ...context });
82
67
  }
83
68
  };
84
69
  const errorHandler = (err, req, res, _next) => {
@@ -0,0 +1,28 @@
1
+ import { type ErrorContext } from '@constructive-io/errors';
2
+ import { type GraphQLError, type GraphQLFormattedError } from 'graphql';
3
+ /**
4
+ * Normalize any GraphQL/database error into a canonical Constructive shape.
5
+ *
6
+ * Database errors surface through Grafast without a populated `extensions.code`
7
+ * (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
8
+ * underlying pg error at `originalError`). We parse `originalError` first so we
9
+ * can recover the structured code, then fall back to the GraphQL error itself.
10
+ */
11
+ export declare const normalizeError: (error: GraphQLError) => {
12
+ code: string | null;
13
+ context: ErrorContext;
14
+ class: "public" | "internal";
15
+ };
16
+ /**
17
+ * Format every GraphQL error the same way, in every environment. Nothing is
18
+ * masked: the client always receives the real message.
19
+ *
20
+ * 1. Lift the structured code onto `extensions.code`/`class`/`context` from the
21
+ * parsed error, so database errors reach clients with a machine-readable
22
+ * code instead of a bare message with empty `extensions`.
23
+ * 2. A request error graphql-js raised without a code is `BAD_USER_INPUT`.
24
+ * 3. Any other error without a code is `INTERNAL_SERVER_ERROR`.
25
+ * 4. Every internal error (unknown, or registered as internal) carries an
26
+ * `errorId` and is logged under it, so a report can be matched to the log.
27
+ */
28
+ export declare const formatError: (error: GraphQLError) => GraphQLFormattedError;
@@ -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;