@constructive-io/graphql-server 5.20.5 → 5.20.7

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,6 +1,6 @@
1
1
  import './types'; // for Request type
2
- import crypto from 'node:crypto';
3
- import { classify, errors, parse } from '@constructive-io/errors';
2
+ import { errors } from '@constructive-io/errors';
3
+ import { DEFAULT_REQUEST_PROTECTION, protectionPgSettings } from '@constructive-io/express-context';
4
4
  import { getNodeEnv } from '@pgpmjs/env';
5
5
  import { Logger } from '@pgpmjs/logger';
6
6
  import { createGraphileInstance, graphileCache } from 'graphile-cache';
@@ -12,86 +12,10 @@ import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
12
12
  import { HandlerCreationError } from '../errors/api-errors';
13
13
  import { respondWithGraphQLError } from '../errors/graphql-response';
14
14
  import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
15
+ import { RequestProtectionPlugin } from '../plugins/request-protection-plugin';
16
+ import { maskError } from './mask-error';
15
17
  import { observeGraphileBuild } from './observability/graphile-build-stats';
16
- const maskErrorLog = new Logger('graphile:maskError');
17
18
  const isDev = () => getNodeEnv() === 'development';
18
- /**
19
- * GraphQL framework protocol codes. These originate in the GraphQL/grafast
20
- * transport layer (not in constructive-db), so they are not Constructive domain
21
- * codes in the `@constructive-io/errors` registry. They are always safe to
22
- * surface — they carry no sensitive detail. Everything else (auth, account,
23
- * resource, constraint, and every constructive-db code) is classified by the
24
- * registry, which is the single source of truth for public vs. internal.
25
- */
26
- const GRAPHQL_PROTOCOL_CODES = new Set([
27
- 'GRAPHQL_VALIDATION_FAILED',
28
- 'GRAPHQL_PARSE_FAILED',
29
- 'PERSISTED_QUERY_NOT_FOUND',
30
- 'PERSISTED_QUERY_NOT_SUPPORTED'
31
- ]);
32
- /** A code is safe to surface when the registry classifies it public, or it is a
33
- * GraphQL framework protocol code. */
34
- const isPublicCode = (code) => Boolean(code) && (classify(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
35
- /**
36
- * Normalize any GraphQL/database error into a canonical Constructive shape.
37
- *
38
- * Database errors surface through Grafast without a populated `extensions.code`
39
- * (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
40
- * underlying pg error at `originalError`). We parse `originalError` first so we
41
- * can recover the structured code, then fall back to the GraphQL error itself.
42
- */
43
- const normalizeError = (error) => {
44
- const original = error.originalError;
45
- const fromOriginal = original ? parse(original) : null;
46
- const parsed = fromOriginal?.code ? fromOriginal : parse(error);
47
- return { code: parsed.code, context: parsed.context, class: parsed.class };
48
- };
49
- /**
50
- * Production-aware error handling backed by `@constructive-io/errors`.
51
- *
52
- * 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
53
- * always receive a machine-readable code (fixing the gap where database
54
- * errors reached clients as a bare message with empty `extensions`).
55
- * 2. Surface public (registered/allowlisted) errors as-is.
56
- * 3. In development, pass everything through (enriched) for debugging.
57
- * 4. In production, mask internal/unknown errors behind a reference ID and log
58
- * the original.
59
- */
60
- const maskError = (error) => {
61
- const { code, context, class: errorClass } = normalizeError(error);
62
- // Lift the structured code onto extensions for every recognized error so
63
- // clients always receive a machine-readable code (`extensions` is read-only
64
- // on GraphQLError, so we build a formatted error rather than mutating it).
65
- const extensions = { ...error.extensions };
66
- if (code) {
67
- extensions.code = code;
68
- extensions.class = errorClass;
69
- if (Object.keys(context).length > 0) {
70
- extensions.context = context;
71
- }
72
- }
73
- const effectiveCode = code ?? error.extensions?.code;
74
- if (isPublicCode(effectiveCode) || getNodeEnv() === 'development') {
75
- // Note: grafserv strips originalError and internal extensions before
76
- // serializing to the client, so returning the enriched error is safe.
77
- return {
78
- message: error.message,
79
- ...(error.locations ? { locations: error.locations } : {}),
80
- ...(error.path ? { path: error.path } : {}),
81
- extensions,
82
- };
83
- }
84
- // Mask internal/unknown errors with a reference ID.
85
- const errorId = crypto.randomBytes(8).toString('hex');
86
- maskErrorLog.error(`[masked-error:${errorId}]`, error);
87
- return {
88
- message: `An unexpected error occurred. Reference: ${errorId}`,
89
- extensions: {
90
- code: 'INTERNAL_SERVER_ERROR',
91
- errorId
92
- }
93
- };
94
- };
95
19
  // =============================================================================
96
20
  // Single-Flight Pattern: In-Flight Tracking
97
21
  // =============================================================================
@@ -136,6 +60,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
136
60
  extends: [createConstructivePreset(databaseSettings)],
137
61
  plugins: [
138
62
  AuthCookiePlugin,
63
+ RequestProtectionPlugin,
139
64
  // Only registered when the compute module is provisioned for this
140
65
  // database — all schema/table names come from the constructive
141
66
  // metaschema (express-context compute module loader); the plugin has
@@ -175,6 +100,11 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
175
100
  // In grafserv/express/v4, the request is available at requestContext.expressv4.req
176
101
  const req = requestContext?.expressv4?.req;
177
102
  const context = {};
103
+ // Timeouts travel with the transaction as GUCs, so they bound the work
104
+ // this request can do inside PostgreSQL whatever the plan turns out to
105
+ // be. Resolved per request (not baked into the cached preset) so a
106
+ // tenant lowering a timeout takes effect on the next request.
107
+ const timeouts = protectionPgSettings(req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION);
178
108
  if (req) {
179
109
  if (req.databaseId) {
180
110
  context['jwt.claims.database_id'] = req.databaseId;
@@ -200,6 +130,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
200
130
  }
201
131
  if (req.token?.user_id) {
202
132
  const pgSettings = {
133
+ ...timeouts,
203
134
  role: roleName,
204
135
  'jwt.claims.token_id': req.token.id,
205
136
  'jwt.claims.user_id': req.token.user_id,
@@ -237,6 +168,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
237
168
  const headerActorId = req.get('X-Actor-Id');
238
169
  if (req.api?.isPublic === false && headerActorId) {
239
170
  const pgSettings = {
171
+ ...timeouts,
240
172
  role: roleName,
241
173
  'jwt.claims.user_id': headerActorId,
242
174
  'jwt.claims.principal_id': headerActorId,
@@ -257,6 +189,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
257
189
  }
258
190
  }
259
191
  const anonSettings = {
192
+ ...timeouts,
260
193
  role: anonRole,
261
194
  ...context
262
195
  };
@@ -0,0 +1,113 @@
1
+ import crypto from 'node:crypto';
2
+ import { classify, parse } from '@constructive-io/errors';
3
+ import { getNodeEnv } from '@pgpmjs/env';
4
+ 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));
23
+ /**
24
+ * An error the GraphQL layer raised about the *request*, before any resolver
25
+ * ran: an unknown input field, a value of the wrong type, a missing required
26
+ * variable. graphql-js reports variable coercion without an `extensions.code`
27
+ * (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.
31
+ *
32
+ * A request error is answered before a field is resolved, so it carries no
33
+ * response `path` — every execution error has one. Coercion wraps the inner
34
+ * complaint about the value, so `originalError` may be set, but only ever to
35
+ * 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
37
+ * is recognized by name rather than by `instanceof`, because the error is raised
38
+ * by whichever copy of graphql-js grafast resolved, not by this package's.
39
+ */
40
+ const isGraphQLLayerError = (value) => value == null ||
41
+ (value.name === 'GraphQLError' &&
42
+ isGraphQLLayerError(value.originalError));
43
+ const isRequestError = (error) => error.path == null && isGraphQLLayerError(error.originalError);
44
+ /** The code a surfaced request error carries when graphql-js supplied none. */
45
+ const BAD_USER_INPUT = 'BAD_USER_INPUT';
46
+ /**
47
+ * Normalize any GraphQL/database error into a canonical Constructive shape.
48
+ *
49
+ * Database errors surface through Grafast without a populated `extensions.code`
50
+ * (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
51
+ * underlying pg error at `originalError`). We parse `originalError` first so we
52
+ * can recover the structured code, then fall back to the GraphQL error itself.
53
+ */
54
+ const normalizeError = (error) => {
55
+ const original = error.originalError;
56
+ const fromOriginal = original ? parse(original) : null;
57
+ const parsed = fromOriginal?.code ? fromOriginal : parse(error);
58
+ return { code: parsed.code, context: parsed.context, class: parsed.class };
59
+ };
60
+ /**
61
+ * Production-aware error handling backed by `@constructive-io/errors`.
62
+ *
63
+ * 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
64
+ * always receive a machine-readable code (fixing the gap where database
65
+ * errors reached clients as a bare message with empty `extensions`).
66
+ * 2. Surface public (registered/allowlisted) errors as-is.
67
+ * 3. In development, pass everything through (enriched) for debugging.
68
+ * 4. In production, mask internal/unknown errors behind a reference ID and log
69
+ * the original.
70
+ */
71
+ export const maskError = (error) => {
72
+ const { code, context, class: errorClass } = normalizeError(error);
73
+ // Lift the structured code onto extensions for every recognized error so
74
+ // clients always receive a machine-readable code (`extensions` is read-only
75
+ // on GraphQLError, so we build a formatted error rather than mutating it).
76
+ const extensions = { ...error.extensions };
77
+ if (code) {
78
+ extensions.code = code;
79
+ extensions.class = errorClass;
80
+ if (Object.keys(context).length > 0) {
81
+ extensions.context = context;
82
+ }
83
+ }
84
+ const effectiveCode = code ?? error.extensions?.code;
85
+ if (!effectiveCode && isRequestError(error)) {
86
+ extensions.code = BAD_USER_INPUT;
87
+ return {
88
+ message: error.message,
89
+ ...(error.locations ? { locations: error.locations } : {}),
90
+ extensions,
91
+ };
92
+ }
93
+ if (isPublicCode(effectiveCode) || getNodeEnv() === 'development') {
94
+ // Note: grafserv strips originalError and internal extensions before
95
+ // serializing to the client, so returning the enriched error is safe.
96
+ return {
97
+ message: error.message,
98
+ ...(error.locations ? { locations: error.locations } : {}),
99
+ ...(error.path ? { path: error.path } : {}),
100
+ extensions,
101
+ };
102
+ }
103
+ // Mask internal/unknown errors with a reference ID.
104
+ const errorId = crypto.randomBytes(8).toString('hex');
105
+ maskErrorLog.error(`[masked-error:${errorId}]`, error);
106
+ return {
107
+ message: `An unexpected error occurred. Reference: ${errorId}`,
108
+ extensions: {
109
+ code: 'INTERNAL_SERVER_ERROR',
110
+ errorId
111
+ }
112
+ };
113
+ };
@@ -0,0 +1,59 @@
1
+ import './types'; // for Request type
2
+ import { errors } from '@constructive-io/errors';
3
+ import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
4
+ import { respondWithGraphQLError } from '../errors/graphql-response';
5
+ /**
6
+ * Resolve the bounds this request runs under and attach them to it.
7
+ *
8
+ * The values come from the tenant's own `database_settings`/`api_settings`
9
+ * (clamped by the platform), read through the cached loader, so the cost is one
10
+ * routing-plane query per database/API per TTL window. A database with no
11
+ * settings row — or a request that arrived before the context middleware could
12
+ * resolve one — still gets the platform defaults, never "unlimited".
13
+ */
14
+ const resolveProtection = async (req) => {
15
+ const resolved = await req.constructive?.useModule('requestProtection');
16
+ return resolved ?? DEFAULT_REQUEST_PROTECTION;
17
+ };
18
+ /**
19
+ * Multipart requests carry file bodies, which are streamed by the upload
20
+ * plugin and bounded by the upload limits rather than by the GraphQL request
21
+ * size: applying a JSON-sized cap to them would reject every upload.
22
+ */
23
+ const isMultipart = (req) => (req.get('content-type') ?? '').toLowerCase().startsWith('multipart/form-data');
24
+ /**
25
+ * Express middleware that resolves request protection and enforces the one
26
+ * bound that has to be checked before the body is read.
27
+ *
28
+ * The document bounds (depth, cost, page size, introspection) are enforced by
29
+ * `RequestProtectionPlugin` inside grafast, which is where the parsed document
30
+ * and coerced variables exist; this middleware exists so the resolved values
31
+ * are on the request by the time either that plugin or the pgSettings builder
32
+ * asks for them.
33
+ *
34
+ * Mount after the context middleware and before the GraphQL handler.
35
+ */
36
+ export const createRequestProtectionMiddleware = () => {
37
+ return async (req, res, next) => {
38
+ // A lookup failure is neither "unlimited" nor "the defaults": the request
39
+ // has no known bounds, so it is handed to the error handler rather than
40
+ // served under numbers nobody chose.
41
+ let protection;
42
+ try {
43
+ protection = await resolveProtection(req);
44
+ }
45
+ catch (e) {
46
+ next(e);
47
+ return;
48
+ }
49
+ req.requestProtection = protection;
50
+ const declaredLength = Number(req.get('content-length') ?? '');
51
+ if (Number.isFinite(declaredLength) &&
52
+ declaredLength > protection.maxRequestBytes &&
53
+ !isMultipart(req)) {
54
+ respondWithGraphQLError(res, errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
55
+ return;
56
+ }
57
+ next();
58
+ };
59
+ };
@@ -0,0 +1,40 @@
1
+ import '../middleware/types'; // for Request type
2
+ import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
3
+ import { enforceDocumentProtection } from '../protection/document-gate';
4
+ /**
5
+ * Get the Express request from a grafserv request context.
6
+ */
7
+ const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
8
+ /**
9
+ * RequestProtectionPlugin — applies a tenant's document bounds to every
10
+ * operation.
11
+ *
12
+ * The check runs in `prepareArgs` rather than `parseAndValidate` for one
13
+ * reason: `parseAndValidate` sees no request, so it could only enforce the
14
+ * bounds baked into the schema's preset at build time — and a Graphile
15
+ * instance is cached per API for as long as the schema is valid, so a tenant
16
+ * lowering a limit would not take effect until the cache turned over.
17
+ * `prepareArgs` carries both the request (hence the freshly resolved settings)
18
+ * and the coerced variables, which is also what makes `first: $n` enforceable.
19
+ */
20
+ export const RequestProtectionPlugin = {
21
+ name: 'RequestProtectionPlugin',
22
+ version: '0.0.0',
23
+ description: 'Enforces per-request query depth, cost, page size and introspection bounds resolved from database_settings/api_settings.',
24
+ grafast: {
25
+ middleware: {
26
+ prepareArgs(next, event) {
27
+ const { args } = event;
28
+ const req = getExpressRequest(args.requestContext);
29
+ // No resolved settings means the protection middleware did not run for
30
+ // this request (an embedded/test harness mounting grafserv directly);
31
+ // the platform defaults still apply rather than nothing at all.
32
+ const protection = req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION;
33
+ if (args.document && args.schema) {
34
+ enforceDocumentProtection(args.schema, args.document, args.variableValues, protection, args.operationName);
35
+ }
36
+ return next();
37
+ }
38
+ }
39
+ }
40
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Document gate — depth, cost, page size and introspection limits.
3
+ *
4
+ * A statement timeout stops a slow query; it does not stop a cheap-looking one
5
+ * that asks for a million rows across nested connections, because that request
6
+ * spends its budget in output size and memory rather than in a single statement.
7
+ * These bounds are therefore checked against the *document*, before any plan is
8
+ * executed.
9
+ *
10
+ * Cost is measured as the number of rows the operation can pull: a connection
11
+ * contributes its page size multiplied by the page sizes of every connection
12
+ * above it, so `users(first: 100) { posts(first: 100) }` costs 100 + 10,000
13
+ * rather than the 4 fields it looks like. A connection with no `first`/`last`
14
+ * is charged `ASSUMED_PAGE_SIZE`, since it is unbounded in principle.
15
+ *
16
+ * The walk is manual rather than `visitWithTypeInfo` because fragment spreads
17
+ * have to be followed (a document can hide its depth entirely inside
18
+ * fragments) and the visitor does not follow them.
19
+ */
20
+ import { errors } from '@constructive-io/errors';
21
+ import { ASSUMED_PAGE_SIZE } from '@constructive-io/express-context';
22
+ import { SafeError } from 'grafast';
23
+ import { getNamedType, isObjectType, Kind, typeFromAST } from 'graphql';
24
+ /**
25
+ * Reject the request with an error the client can act on.
26
+ *
27
+ * The gate runs inside grafast's `prepareArgs`, before execution, where a
28
+ * plain throw is reported as an unknown handler failure: HTTP 500 with the
29
+ * `extensions` dropped. `SafeError` is grafserv's contract for "this message
30
+ * and these extensions are meant for the client", so the registered code,
31
+ * class and HTTP status survive the trip.
32
+ */
33
+ const reject = (error) => {
34
+ throw new SafeError(error.message, {
35
+ ...error.toExtensions(),
36
+ statusCode: error.http
37
+ });
38
+ };
39
+ /** Arguments a connection field uses to size its page. */
40
+ const PAGE_SIZE_ARGS = ['first', 'last'];
41
+ /** A connection is any object type that carries Relay's `pageInfo`. */
42
+ const isConnectionType = (type) => Boolean(type && isObjectType(type) && 'pageInfo' in type.getFields());
43
+ /**
44
+ * Resolve a `first`/`last` argument to a number, whether it arrived as a
45
+ * literal or through a variable. Returns null when the field does not page.
46
+ */
47
+ const requestedPageSize = (args, variableValues) => {
48
+ if (!args)
49
+ return null;
50
+ let size = null;
51
+ for (const arg of args) {
52
+ if (!PAGE_SIZE_ARGS.includes(arg.name.value))
53
+ continue;
54
+ const value = arg.value;
55
+ const resolved = value.kind === Kind.INT
56
+ ? Number(value.value)
57
+ : value.kind === Kind.VARIABLE
58
+ ? Number(variableValues[value.name.value])
59
+ : null;
60
+ if (resolved !== null && Number.isFinite(resolved)) {
61
+ size = size === null ? resolved : Math.max(size, resolved);
62
+ }
63
+ }
64
+ return size;
65
+ };
66
+ /**
67
+ * Walk one selection set, accumulating depth and cost.
68
+ *
69
+ * @param parentType - the type the selections are read from, or null when the
70
+ * schema cannot resolve it (an invalid document; validation reports that, so
71
+ * the walk only stops charging cost rather than raising its own error)
72
+ * @param depth - nesting level of these selections
73
+ * @param multiplier - rows the enclosing connections can return
74
+ */
75
+ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
76
+ if (depth > walk.maxDepth)
77
+ walk.maxDepth = depth;
78
+ if (depth > walk.protection.maxQueryDepth) {
79
+ reject(errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
80
+ }
81
+ for (const selection of selectionSet.selections) {
82
+ if (selection.kind === Kind.FIELD) {
83
+ if (selection.name.value === '__schema' || selection.name.value === '__type') {
84
+ if (!walk.protection.enableIntrospection) {
85
+ reject(errors.INTROSPECTION_DISABLED());
86
+ }
87
+ }
88
+ const field = parentType && isObjectType(parentType)
89
+ ? parentType.getFields()[selection.name.value]
90
+ : undefined;
91
+ const fieldType = field ? getNamedType(field.type) : null;
92
+ const pageSize = requestedPageSize(selection.arguments, walk.variableValues);
93
+ if (pageSize !== null && pageSize > walk.protection.maxPageSize) {
94
+ reject(errors.PAGE_SIZE_TOO_LARGE({
95
+ requested: pageSize,
96
+ limit: walk.protection.maxPageSize
97
+ }));
98
+ }
99
+ // A connection charges rows; every other field is free, so a wide but
100
+ // flat selection is not penalized for being wide.
101
+ let childMultiplier = multiplier;
102
+ if (isConnectionType(fieldType)) {
103
+ childMultiplier = multiplier * (pageSize ?? ASSUMED_PAGE_SIZE);
104
+ walk.cost += childMultiplier;
105
+ if (walk.cost > walk.protection.maxQueryCost) {
106
+ reject(errors.QUERY_TOO_COSTLY({
107
+ cost: walk.cost,
108
+ limit: walk.protection.maxQueryCost
109
+ }));
110
+ }
111
+ }
112
+ if (selection.selectionSet) {
113
+ walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier);
114
+ }
115
+ continue;
116
+ }
117
+ if (selection.kind === Kind.INLINE_FRAGMENT) {
118
+ const onType = selection.typeCondition
119
+ ? typeFromAST(walk.schema, selection.typeCondition)
120
+ : parentType;
121
+ // An inline fragment is not a level of nesting of its own.
122
+ walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier);
123
+ continue;
124
+ }
125
+ if (selection.kind === Kind.FRAGMENT_SPREAD) {
126
+ const name = selection.name.value;
127
+ // A document may spread the same fragment in sibling positions; only a
128
+ // cycle (a fragment reachable from itself) is refused, and the GraphQL
129
+ // validation rules already reject those with a proper error.
130
+ if (walk.activeFragments.has(name))
131
+ continue;
132
+ const fragment = walk.fragments[name];
133
+ if (!fragment)
134
+ continue;
135
+ const onType = typeFromAST(walk.schema, fragment.typeCondition);
136
+ walk.activeFragments.add(name);
137
+ try {
138
+ walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier);
139
+ }
140
+ finally {
141
+ walk.activeFragments.delete(name);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ /**
147
+ * Enforce the document-level bounds for one operation.
148
+ *
149
+ * Throws the matching public error on the first bound exceeded; returns what it
150
+ * measured otherwise, so a caller can log or expose it.
151
+ */
152
+ export function enforceDocumentProtection(schema, document, variableValues, protection, operationName) {
153
+ const fragments = {};
154
+ const operations = [];
155
+ for (const definition of document.definitions) {
156
+ if (definition.kind === Kind.FRAGMENT_DEFINITION)
157
+ fragments[definition.name.value] = definition;
158
+ else if (definition.kind === Kind.OPERATION_DEFINITION)
159
+ operations.push(definition);
160
+ }
161
+ const operation = operationName
162
+ ? operations.find((op) => op.name?.value === operationName)
163
+ : operations[0];
164
+ if (!operation)
165
+ return { depth: 0, cost: 0 };
166
+ const rootType = operation.operation === 'query'
167
+ ? schema.getQueryType()
168
+ : operation.operation === 'mutation'
169
+ ? schema.getMutationType()
170
+ : schema.getSubscriptionType();
171
+ const walk = {
172
+ schema,
173
+ fragments,
174
+ variableValues: variableValues ?? {},
175
+ protection,
176
+ activeFragments: new Set(),
177
+ maxDepth: 0,
178
+ cost: 0
179
+ };
180
+ walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1);
181
+ return { depth: walk.maxDepth, cost: walk.cost };
182
+ }
package/esm/server.js CHANGED
@@ -30,6 +30,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug-
30
30
  import { debugMemory } from './middleware/observability/debug-memory';
31
31
  import { localObservabilityOnly } from './middleware/observability/guard';
32
32
  import { createRequestLogger } from './middleware/observability/request-logger';
33
+ import { createRequestProtectionMiddleware } from './middleware/request-protection';
33
34
  import { getRoutingSchema } from './middleware/routing';
34
35
  const log = new Logger('server');
35
36
  /**
@@ -142,6 +143,9 @@ class Server {
142
143
  loaders: createDefaultRegistry(),
143
144
  routingSchema: getRoutingSchema(effectiveOpts)
144
145
  }));
146
+ // Resolve the tenant's protection bounds before anything can spend budget
147
+ // on the request (and before the GraphQL handler reads them for pgSettings).
148
+ app.use(createRequestProtectionMiddleware());
145
149
  app.use(createCaptchaMiddleware());
146
150
  // CSRF protection for cookie-authenticated requests
147
151
  // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests