@constructive-io/graphql-server 5.20.6 → 5.20.8

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,15 +1,12 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.graphile = void 0;
7
4
  exports.getInFlightCount = getInFlightCount;
8
5
  exports.getInFlightKeys = getInFlightKeys;
9
6
  exports.clearInFlightMap = clearInFlightMap;
10
7
  require("./types"); // for Request type
11
- const node_crypto_1 = __importDefault(require("node:crypto"));
12
8
  const errors_1 = require("@constructive-io/errors");
9
+ const express_context_1 = require("@constructive-io/express-context");
13
10
  const env_1 = require("@pgpmjs/env");
14
11
  const logger_1 = require("@pgpmjs/logger");
15
12
  const graphile_cache_1 = require("graphile-cache");
@@ -21,86 +18,10 @@ const observability_1 = require("../diagnostics/observability");
21
18
  const api_errors_1 = require("../errors/api-errors");
22
19
  const graphql_response_1 = require("../errors/graphql-response");
23
20
  const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
21
+ const request_protection_plugin_1 = require("../plugins/request-protection-plugin");
22
+ const mask_error_1 = require("./mask-error");
24
23
  const graphile_build_stats_1 = require("./observability/graphile-build-stats");
25
- const maskErrorLog = new logger_1.Logger('graphile:maskError');
26
24
  const isDev = () => (0, env_1.getNodeEnv)() === 'development';
27
- /**
28
- * GraphQL framework protocol codes. These originate in the GraphQL/grafast
29
- * transport layer (not in constructive-db), so they are not Constructive domain
30
- * codes in the `@constructive-io/errors` registry. They are always safe to
31
- * surface — they carry no sensitive detail. Everything else (auth, account,
32
- * resource, constraint, and every constructive-db code) is classified by the
33
- * registry, which is the single source of truth for public vs. internal.
34
- */
35
- const GRAPHQL_PROTOCOL_CODES = new Set([
36
- 'GRAPHQL_VALIDATION_FAILED',
37
- 'GRAPHQL_PARSE_FAILED',
38
- 'PERSISTED_QUERY_NOT_FOUND',
39
- 'PERSISTED_QUERY_NOT_SUPPORTED'
40
- ]);
41
- /** A code is safe to surface when the registry classifies it public, or it is a
42
- * GraphQL framework protocol code. */
43
- const isPublicCode = (code) => Boolean(code) && ((0, errors_1.classify)(code) === 'public' || GRAPHQL_PROTOCOL_CODES.has(code));
44
- /**
45
- * Normalize any GraphQL/database error into a canonical Constructive shape.
46
- *
47
- * Database errors surface through Grafast without a populated `extensions.code`
48
- * (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
49
- * underlying pg error at `originalError`). We parse `originalError` first so we
50
- * can recover the structured code, then fall back to the GraphQL error itself.
51
- */
52
- const normalizeError = (error) => {
53
- const original = error.originalError;
54
- const fromOriginal = original ? (0, errors_1.parse)(original) : null;
55
- const parsed = fromOriginal?.code ? fromOriginal : (0, errors_1.parse)(error);
56
- return { code: parsed.code, context: parsed.context, class: parsed.class };
57
- };
58
- /**
59
- * Production-aware error handling backed by `@constructive-io/errors`.
60
- *
61
- * 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
62
- * always receive a machine-readable code (fixing the gap where database
63
- * errors reached clients as a bare message with empty `extensions`).
64
- * 2. Surface public (registered/allowlisted) errors as-is.
65
- * 3. In development, pass everything through (enriched) for debugging.
66
- * 4. In production, mask internal/unknown errors behind a reference ID and log
67
- * the original.
68
- */
69
- const maskError = (error) => {
70
- const { code, context, class: errorClass } = normalizeError(error);
71
- // Lift the structured code onto extensions for every recognized error so
72
- // clients always receive a machine-readable code (`extensions` is read-only
73
- // on GraphQLError, so we build a formatted error rather than mutating it).
74
- const extensions = { ...error.extensions };
75
- if (code) {
76
- extensions.code = code;
77
- extensions.class = errorClass;
78
- if (Object.keys(context).length > 0) {
79
- extensions.context = context;
80
- }
81
- }
82
- const effectiveCode = code ?? error.extensions?.code;
83
- if (isPublicCode(effectiveCode) || (0, env_1.getNodeEnv)() === 'development') {
84
- // Note: grafserv strips originalError and internal extensions before
85
- // serializing to the client, so returning the enriched error is safe.
86
- return {
87
- message: error.message,
88
- ...(error.locations ? { locations: error.locations } : {}),
89
- ...(error.path ? { path: error.path } : {}),
90
- extensions,
91
- };
92
- }
93
- // Mask internal/unknown errors with a reference ID.
94
- const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
95
- maskErrorLog.error(`[masked-error:${errorId}]`, error);
96
- return {
97
- message: `An unexpected error occurred. Reference: ${errorId}`,
98
- extensions: {
99
- code: 'INTERNAL_SERVER_ERROR',
100
- errorId
101
- }
102
- };
103
- };
104
25
  // =============================================================================
105
26
  // Single-Flight Pattern: In-Flight Tracking
106
27
  // =============================================================================
@@ -145,6 +66,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
145
66
  extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
146
67
  plugins: [
147
68
  auth_cookie_plugin_1.AuthCookiePlugin,
69
+ request_protection_plugin_1.RequestProtectionPlugin,
148
70
  // Only registered when the compute module is provisioned for this
149
71
  // database — all schema/table names come from the constructive
150
72
  // metaschema (express-context compute module loader); the plugin has
@@ -176,7 +98,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
176
98
  graphiqlPath: '/graphiql',
177
99
  graphiql: true,
178
100
  graphiqlOnGraphQLGET: false,
179
- maskError
101
+ maskError: mask_error_1.maskError
180
102
  },
181
103
  grafast: {
182
104
  explain: process.env.NODE_ENV === 'development',
@@ -184,6 +106,11 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
184
106
  // In grafserv/express/v4, the request is available at requestContext.expressv4.req
185
107
  const req = requestContext?.expressv4?.req;
186
108
  const context = {};
109
+ // Timeouts travel with the transaction as GUCs, so they bound the work
110
+ // this request can do inside PostgreSQL whatever the plan turns out to
111
+ // be. Resolved per request (not baked into the cached preset) so a
112
+ // tenant lowering a timeout takes effect on the next request.
113
+ const timeouts = (0, express_context_1.protectionPgSettings)(req?.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION);
187
114
  if (req) {
188
115
  if (req.databaseId) {
189
116
  context['jwt.claims.database_id'] = req.databaseId;
@@ -209,6 +136,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
209
136
  }
210
137
  if (req.token?.user_id) {
211
138
  const pgSettings = {
139
+ ...timeouts,
212
140
  role: roleName,
213
141
  'jwt.claims.token_id': req.token.id,
214
142
  'jwt.claims.user_id': req.token.user_id,
@@ -246,15 +174,22 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
246
174
  const headerActorId = req.get('X-Actor-Id');
247
175
  if (req.api?.isPublic === false && headerActorId) {
248
176
  const pgSettings = {
177
+ ...timeouts,
249
178
  role: roleName,
250
179
  'jwt.claims.user_id': headerActorId,
251
180
  'jwt.claims.principal_id': headerActorId,
252
181
  ...context
253
182
  };
183
+ // The entity pair travels together: the tenant's writers reject an
184
+ // entity id whose type they cannot interpret (`ENTITY_TYPE_REQUIRED`).
254
185
  const headerEntityId = req.get('X-Entity-Id');
186
+ const headerEntityType = req.get('X-Entity-Type');
255
187
  if (headerEntityId) {
256
188
  pgSettings['jwt.claims.entity_id'] = headerEntityId;
257
189
  }
190
+ if (headerEntityType) {
191
+ pgSettings['jwt.claims.entity_type'] = headerEntityType;
192
+ }
258
193
  const headerOrganizationId = req.get('X-Organization-Id');
259
194
  if (headerOrganizationId) {
260
195
  pgSettings['jwt.claims.organization_id'] = headerOrganizationId;
@@ -265,10 +200,20 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
265
200
  return { pgSettings };
266
201
  }
267
202
  }
203
+ // No actor to name, so the tenant database the request addresses carries
204
+ // the attribution — the same rule the sync gateway applies to a request
205
+ // that arrives without a credential. Without it the tenant's own writers
206
+ // refuse the work an anonymous request legitimately does
207
+ // (`ATTRIBUTION_REQUIRED`), so a public mutation cannot enqueue a job.
268
208
  const anonSettings = {
209
+ ...timeouts,
269
210
  role: anonRole,
270
211
  ...context
271
212
  };
213
+ if (req?.databaseId) {
214
+ anonSettings['jwt.claims.entity_id'] = req.databaseId;
215
+ anonSettings['jwt.claims.entity_type'] = 'database';
216
+ }
272
217
  if (req?.requestId) {
273
218
  anonSettings['request.id'] = req.requestId;
274
219
  }
@@ -0,0 +1,13 @@
1
+ import { type GraphQLError, type GraphQLFormattedError } from 'graphql';
2
+ /**
3
+ * Production-aware error handling backed by `@constructive-io/errors`.
4
+ *
5
+ * 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
6
+ * always receive a machine-readable code (fixing the gap where database
7
+ * errors reached clients as a bare message with empty `extensions`).
8
+ * 2. Surface public (registered/allowlisted) errors as-is.
9
+ * 3. In development, pass everything through (enriched) for debugging.
10
+ * 4. In production, mask internal/unknown errors behind a reference ID and log
11
+ * the original.
12
+ */
13
+ export declare const maskError: (error: GraphQLError) => GraphQLError | GraphQLFormattedError;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.maskError = void 0;
7
+ const node_crypto_1 = __importDefault(require("node:crypto"));
8
+ const errors_1 = require("@constructive-io/errors");
9
+ const env_1 = require("@pgpmjs/env");
10
+ 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));
29
+ /**
30
+ * An error the GraphQL layer raised about the *request*, before any resolver
31
+ * ran: an unknown input field, a value of the wrong type, a missing required
32
+ * variable. graphql-js reports variable coercion without an `extensions.code`
33
+ * (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.
37
+ *
38
+ * A request error is answered before a field is resolved, so it carries no
39
+ * response `path` — every execution error has one. Coercion wraps the inner
40
+ * complaint about the value, so `originalError` may be set, but only ever to
41
+ * 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
43
+ * is recognized by name rather than by `instanceof`, because the error is raised
44
+ * by whichever copy of graphql-js grafast resolved, not by this package's.
45
+ */
46
+ const isGraphQLLayerError = (value) => value == null ||
47
+ (value.name === 'GraphQLError' &&
48
+ isGraphQLLayerError(value.originalError));
49
+ const isRequestError = (error) => error.path == null && isGraphQLLayerError(error.originalError);
50
+ /** The code a surfaced request error carries when graphql-js supplied none. */
51
+ const BAD_USER_INPUT = 'BAD_USER_INPUT';
52
+ /**
53
+ * Normalize any GraphQL/database error into a canonical Constructive shape.
54
+ *
55
+ * Database errors surface through Grafast without a populated `extensions.code`
56
+ * (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the
57
+ * underlying pg error at `originalError`). We parse `originalError` first so we
58
+ * can recover the structured code, then fall back to the GraphQL error itself.
59
+ */
60
+ const normalizeError = (error) => {
61
+ const original = error.originalError;
62
+ const fromOriginal = original ? (0, errors_1.parse)(original) : null;
63
+ const parsed = fromOriginal?.code ? fromOriginal : (0, errors_1.parse)(error);
64
+ return { code: parsed.code, context: parsed.context, class: parsed.class };
65
+ };
66
+ /**
67
+ * Production-aware error handling backed by `@constructive-io/errors`.
68
+ *
69
+ * 1. Enrich `extensions.code`/`class`/`context` from the parsed error so clients
70
+ * always receive a machine-readable code (fixing the gap where database
71
+ * errors reached clients as a bare message with empty `extensions`).
72
+ * 2. Surface public (registered/allowlisted) errors as-is.
73
+ * 3. In development, pass everything through (enriched) for debugging.
74
+ * 4. In production, mask internal/unknown errors behind a reference ID and log
75
+ * the original.
76
+ */
77
+ const maskError = (error) => {
78
+ const { code, context, class: errorClass } = normalizeError(error);
79
+ // Lift the structured code onto extensions for every recognized error so
80
+ // clients always receive a machine-readable code (`extensions` is read-only
81
+ // on GraphQLError, so we build a formatted error rather than mutating it).
82
+ const extensions = { ...error.extensions };
83
+ if (code) {
84
+ extensions.code = code;
85
+ extensions.class = errorClass;
86
+ if (Object.keys(context).length > 0) {
87
+ extensions.context = context;
88
+ }
89
+ }
90
+ const effectiveCode = code ?? error.extensions?.code;
91
+ if (!effectiveCode && isRequestError(error)) {
92
+ extensions.code = BAD_USER_INPUT;
93
+ return {
94
+ message: error.message,
95
+ ...(error.locations ? { locations: error.locations } : {}),
96
+ extensions,
97
+ };
98
+ }
99
+ if (isPublicCode(effectiveCode) || (0, env_1.getNodeEnv)() === 'development') {
100
+ // Note: grafserv strips originalError and internal extensions before
101
+ // serializing to the client, so returning the enriched error is safe.
102
+ return {
103
+ message: error.message,
104
+ ...(error.locations ? { locations: error.locations } : {}),
105
+ ...(error.path ? { path: error.path } : {}),
106
+ extensions,
107
+ };
108
+ }
109
+ // Mask internal/unknown errors with a reference ID.
110
+ const errorId = node_crypto_1.default.randomBytes(8).toString('hex');
111
+ maskErrorLog.error(`[masked-error:${errorId}]`, error);
112
+ return {
113
+ message: `An unexpected error occurred. Reference: ${errorId}`,
114
+ extensions: {
115
+ code: 'INTERNAL_SERVER_ERROR',
116
+ errorId
117
+ }
118
+ };
119
+ };
120
+ exports.maskError = maskError;
@@ -0,0 +1,15 @@
1
+ import './types';
2
+ import type { RequestHandler } from 'express';
3
+ /**
4
+ * Express middleware that resolves request protection and enforces the one
5
+ * bound that has to be checked before the body is read.
6
+ *
7
+ * The document bounds (depth, cost, page size, introspection) are enforced by
8
+ * `RequestProtectionPlugin` inside grafast, which is where the parsed document
9
+ * and coerced variables exist; this middleware exists so the resolved values
10
+ * are on the request by the time either that plugin or the pgSettings builder
11
+ * asks for them.
12
+ *
13
+ * Mount after the context middleware and before the GraphQL handler.
14
+ */
15
+ export declare const createRequestProtectionMiddleware: () => RequestHandler;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRequestProtectionMiddleware = void 0;
4
+ require("./types"); // for Request type
5
+ const errors_1 = require("@constructive-io/errors");
6
+ const express_context_1 = require("@constructive-io/express-context");
7
+ const graphql_response_1 = require("../errors/graphql-response");
8
+ /**
9
+ * Resolve the bounds this request runs under and attach them to it.
10
+ *
11
+ * The values come from the tenant's own `database_settings`/`api_settings`
12
+ * (clamped by the platform), read through the cached loader, so the cost is one
13
+ * routing-plane query per database/API per TTL window. A database with no
14
+ * settings row — or a request that arrived before the context middleware could
15
+ * resolve one — still gets the platform defaults, never "unlimited".
16
+ */
17
+ const resolveProtection = async (req) => {
18
+ const resolved = await req.constructive?.useModule('requestProtection');
19
+ return resolved ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
20
+ };
21
+ /**
22
+ * Multipart requests carry file bodies, which are streamed by the upload
23
+ * plugin and bounded by the upload limits rather than by the GraphQL request
24
+ * size: applying a JSON-sized cap to them would reject every upload.
25
+ */
26
+ const isMultipart = (req) => (req.get('content-type') ?? '').toLowerCase().startsWith('multipart/form-data');
27
+ /**
28
+ * Express middleware that resolves request protection and enforces the one
29
+ * bound that has to be checked before the body is read.
30
+ *
31
+ * The document bounds (depth, cost, page size, introspection) are enforced by
32
+ * `RequestProtectionPlugin` inside grafast, which is where the parsed document
33
+ * and coerced variables exist; this middleware exists so the resolved values
34
+ * are on the request by the time either that plugin or the pgSettings builder
35
+ * asks for them.
36
+ *
37
+ * Mount after the context middleware and before the GraphQL handler.
38
+ */
39
+ const createRequestProtectionMiddleware = () => {
40
+ return async (req, res, next) => {
41
+ // A lookup failure is neither "unlimited" nor "the defaults": the request
42
+ // has no known bounds, so it is handed to the error handler rather than
43
+ // served under numbers nobody chose.
44
+ let protection;
45
+ try {
46
+ protection = await resolveProtection(req);
47
+ }
48
+ catch (e) {
49
+ next(e);
50
+ return;
51
+ }
52
+ req.requestProtection = protection;
53
+ const declaredLength = Number(req.get('content-length') ?? '');
54
+ if (Number.isFinite(declaredLength) &&
55
+ declaredLength > protection.maxRequestBytes &&
56
+ !isMultipart(req)) {
57
+ (0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
58
+ return;
59
+ }
60
+ next();
61
+ };
62
+ };
63
+ exports.createRequestProtectionMiddleware = createRequestProtectionMiddleware;
@@ -1,3 +1,4 @@
1
+ import type { RequestProtection } from '@constructive-io/express-context';
1
2
  import type { ApiStructure } from '../types';
2
3
  export type ConstructiveAPIToken = {
3
4
  id?: string;
@@ -19,6 +20,12 @@ declare global {
19
20
  token?: ConstructiveAPIToken;
20
21
  /** Device token from constructive_device_token cookie for trusted device tracking */
21
22
  deviceToken?: string;
23
+ /**
24
+ * Per-request protection bounds, resolved from the tenant's
25
+ * database/API settings and clamped by the platform. Set by
26
+ * `createRequestProtectionMiddleware`.
27
+ */
28
+ requestProtection?: RequestProtection;
22
29
  }
23
30
  }
24
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.20.6",
3
+ "version": "5.20.8",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -42,40 +42,40 @@
42
42
  ],
43
43
  "dependencies": {
44
44
  "@agentic-kit/ollama": "2.14.0",
45
- "@constructive-io/csrf": "^0.29.0",
46
- "@constructive-io/errors": "^0.11.1",
47
- "@constructive-io/express-context": "^0.26.1",
48
- "@constructive-io/graphql-env": "^3.31.1",
49
- "@constructive-io/graphql-types": "^3.30.1",
50
- "@constructive-io/llm-env": "^0.14.0",
51
- "@constructive-io/query-builder": "^3.13.2",
52
- "@constructive-io/s3-utils": "^2.32.0",
53
- "@constructive-io/url-domains": "^2.30.0",
45
+ "@constructive-io/csrf": "^0.29.1",
46
+ "@constructive-io/errors": "^0.11.2",
47
+ "@constructive-io/express-context": "^0.26.2",
48
+ "@constructive-io/graphql-env": "^3.31.2",
49
+ "@constructive-io/graphql-types": "^3.30.2",
50
+ "@constructive-io/llm-env": "^0.14.1",
51
+ "@constructive-io/query-builder": "^3.13.3",
52
+ "@constructive-io/s3-utils": "^2.33.0",
53
+ "@constructive-io/url-domains": "^2.30.1",
54
54
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
55
- "@pgpmjs/env": "^2.43.1",
56
- "@pgpmjs/logger": "^2.25.0",
57
- "@pgpmjs/server-utils": "^3.27.1",
58
- "@pgpmjs/types": "^2.52.1",
55
+ "@pgpmjs/env": "^2.43.2",
56
+ "@pgpmjs/logger": "^2.25.1",
57
+ "@pgpmjs/server-utils": "^3.27.2",
58
+ "@pgpmjs/types": "^2.53.0",
59
59
  "cors": "^2.8.6",
60
60
  "deepmerge": "^4.3.1",
61
61
  "express": "^5.2.1",
62
- "gql-ast": "^3.24.0",
62
+ "gql-ast": "^3.24.1",
63
63
  "grafast": "1.1.2",
64
64
  "grafserv": "1.0.1",
65
65
  "graphile-build": "5.1.1",
66
66
  "graphile-build-pg": "5.1.3",
67
- "graphile-cache": "^4.12.1",
67
+ "graphile-cache": "^4.12.2",
68
68
  "graphile-config": "1.1.0",
69
- "graphile-function-bindings": "^1.14.2",
70
- "graphile-settings": "^6.21.2",
69
+ "graphile-function-bindings": "^1.14.3",
70
+ "graphile-settings": "^6.21.3",
71
71
  "graphile-utils": "5.0.3",
72
72
  "graphql": "16.13.0",
73
73
  "graphql-upload": "^13.0.0",
74
74
  "lru-cache": "^11.2.7",
75
75
  "pg": "^8.21.0",
76
- "pg-cache": "^3.27.1",
77
- "pg-env": "^1.31.0",
78
- "pg-query-context": "^2.30.0",
76
+ "pg-cache": "^3.27.2",
77
+ "pg-env": "^1.31.1",
78
+ "pg-query-context": "^2.30.1",
79
79
  "pg-sql2": "5.0.1",
80
80
  "postgraphile": "5.1.4",
81
81
  "request-ip": "^3.3.0"
@@ -90,11 +90,11 @@
90
90
  "@types/request-ip": "^0.0.41",
91
91
  "@types/supertest": "^7.2.1",
92
92
  "cookie-parser": "^1.4.7",
93
- "graphile-test": "5.14.2",
94
- "makage": "^0.3.0",
93
+ "graphile-test": "5.14.3",
94
+ "makage": "^0.8.0",
95
95
  "nodemon": "^3.1.14",
96
96
  "supertest": "^7.2.2",
97
97
  "ts-node": "^10.9.2"
98
98
  },
99
- "gitHead": "608acc891de5197ad3cb4bfd0babe1050dbec24a"
99
+ "gitHead": "4deb5b9a00ead37453857d24274007255092822e"
100
100
  }
@@ -0,0 +1,15 @@
1
+ import '../middleware/types';
2
+ import type { GraphileConfig } from 'graphile-config';
3
+ /**
4
+ * RequestProtectionPlugin — applies a tenant's document bounds to every
5
+ * operation.
6
+ *
7
+ * The check runs in `prepareArgs` rather than `parseAndValidate` for one
8
+ * reason: `parseAndValidate` sees no request, so it could only enforce the
9
+ * bounds baked into the schema's preset at build time — and a Graphile
10
+ * instance is cached per API for as long as the schema is valid, so a tenant
11
+ * lowering a limit would not take effect until the cache turned over.
12
+ * `prepareArgs` carries both the request (hence the freshly resolved settings)
13
+ * and the coerced variables, which is also what makes `first: $n` enforceable.
14
+ */
15
+ export declare const RequestProtectionPlugin: GraphileConfig.Plugin;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RequestProtectionPlugin = void 0;
4
+ require("../middleware/types"); // for Request type
5
+ const express_context_1 = require("@constructive-io/express-context");
6
+ const document_gate_1 = require("../protection/document-gate");
7
+ /**
8
+ * Get the Express request from a grafserv request context.
9
+ */
10
+ const getExpressRequest = (requestContext) => requestContext?.expressv4?.req;
11
+ /**
12
+ * RequestProtectionPlugin — applies a tenant's document bounds to every
13
+ * operation.
14
+ *
15
+ * The check runs in `prepareArgs` rather than `parseAndValidate` for one
16
+ * reason: `parseAndValidate` sees no request, so it could only enforce the
17
+ * bounds baked into the schema's preset at build time — and a Graphile
18
+ * instance is cached per API for as long as the schema is valid, so a tenant
19
+ * lowering a limit would not take effect until the cache turned over.
20
+ * `prepareArgs` carries both the request (hence the freshly resolved settings)
21
+ * and the coerced variables, which is also what makes `first: $n` enforceable.
22
+ */
23
+ exports.RequestProtectionPlugin = {
24
+ name: 'RequestProtectionPlugin',
25
+ version: '0.0.0',
26
+ description: 'Enforces per-request query depth, cost, page size and introspection bounds resolved from database_settings/api_settings.',
27
+ grafast: {
28
+ middleware: {
29
+ prepareArgs(next, event) {
30
+ const { args } = event;
31
+ const req = getExpressRequest(args.requestContext);
32
+ // No resolved settings means the protection middleware did not run for
33
+ // this request (an embedded/test harness mounting grafserv directly);
34
+ // the platform defaults still apply rather than nothing at all.
35
+ const protection = req?.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
36
+ if (args.document && args.schema) {
37
+ (0, document_gate_1.enforceDocumentProtection)(args.schema, args.document, args.variableValues, protection, args.operationName);
38
+ }
39
+ return next();
40
+ }
41
+ }
42
+ }
43
+ };
@@ -0,0 +1,34 @@
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 { type RequestProtection } from '@constructive-io/express-context';
21
+ import type { DocumentNode, GraphQLSchema } from 'graphql';
22
+ export interface DocumentAnalysis {
23
+ /** Deepest field nesting in the operation. */
24
+ depth: number;
25
+ /** Rows the operation can pull across all of its connections. */
26
+ cost: number;
27
+ }
28
+ /**
29
+ * Enforce the document-level bounds for one operation.
30
+ *
31
+ * Throws the matching public error on the first bound exceeded; returns what it
32
+ * measured otherwise, so a caller can log or expose it.
33
+ */
34
+ export declare function enforceDocumentProtection(schema: GraphQLSchema, document: DocumentNode, variableValues: Record<string, unknown> | null | undefined, protection: RequestProtection, operationName?: string | null): DocumentAnalysis;