@constructive-io/graphql-server 5.33.2 → 5.33.4

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,9 +1,10 @@
1
1
  import './types'; // for Request type
2
2
  import { errors } from '@constructive-io/errors';
3
- import { clientIpFrom, ConcurrencyLimiter, DEFAULT_REQUEST_PROTECTION, RateWindow, trustedProxyHops } from '@constructive-io/express-context';
3
+ import { clientIpFrom, ConcurrencyLimiter, DEFAULT_REQUEST_PROTECTION, RateWindow } from '@constructive-io/express-context';
4
4
  import { Logger } from '@pgpmjs/logger';
5
5
  import { respondWithGraphQLError } from '../errors/graphql-response';
6
6
  import { recordRefusal } from '../refusals/recorder';
7
+ import { resolveProxyHops } from './client-ip';
7
8
  const log = new Logger('admission');
8
9
  /**
9
10
  * admission-control — the gate that decides whether a GraphQL request starts.
@@ -44,25 +45,6 @@ const protectionOf = (req) => req.requestProtection ?? DEFAULT_REQUEST_PROTECTIO
44
45
  const routeOf = (req) => `${req.method} ${req.baseUrl}${req.path}`;
45
46
  /** Seconds for a `Retry-After` header — the smallest honest whole number. */
46
47
  const retryAfterSeconds = (ms) => String(Math.max(Math.ceil(ms / 1000), 1));
47
- /**
48
- * How far back through `X-Forwarded-For` to believe.
49
- *
50
- * `req.clientIp` and `req.ip` are both unusable as a limiter key here: this
51
- * server sets `trust proxy` to a predicate that returns true unconditionally,
52
- * and `request-ip` reads the *leftmost* forwarded entry regardless — so either
53
- * one hands a caller a fresh key per request for the cost of a header. The
54
- * fallback to 1 hop exists because the opposite failure is just as bad: behind
55
- * an ingress with no hop count configured, every caller resolves to the
56
- * ingress's address and one abuser throttles the whole tenant.
57
- */
58
- const resolveHops = (req, configured) => {
59
- if (typeof configured === 'number')
60
- return configured;
61
- const fromEnv = trustedProxyHops();
62
- if (fromEnv > 0)
63
- return fromEnv;
64
- return req.app?.get('trust proxy') ? 1 : 0;
65
- };
66
48
  /**
67
49
  * Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
68
50
  * them per database into the handler chain.
@@ -79,7 +61,7 @@ export const createAdmissionControlMiddleware = (options = {}) => {
79
61
  const protection = protectionOf(req);
80
62
  const databaseId = req.databaseId ?? UNKNOWN_DATABASE;
81
63
  // ─── Per-caller rate ────────────────────────────────────────────────────
82
- const hops = resolveHops(req, options.trustedProxyHops);
64
+ const hops = resolveProxyHops(req, options.trustedProxyHops);
83
65
  const ip = clientIpFrom(req, hops);
84
66
  const rateKey = `${databaseId}\u0000${ip}\u0000${routeOf(req)}`;
85
67
  if (!rate.admit(rateKey, protection.rateLimitRpm, protection.rateLimitBurst)) {
@@ -92,17 +74,31 @@ export const createAdmissionControlMiddleware = (options = {}) => {
92
74
  return;
93
75
  }
94
76
  // ─── Per-database concurrency ───────────────────────────────────────────
77
+ // A client that hangs up while queued must neither keep its place in the
78
+ // queue nor be handed the slot when its turn comes: nothing would ever
79
+ // release it. `close` is watched from before the wait, since a listener
80
+ // attached only after `acquire` resolves misses a hang-up during it.
81
+ const gone = new AbortController();
82
+ const onClose = () => gone.abort();
83
+ res.once('close', onClose);
95
84
  let lease;
96
85
  try {
97
86
  lease = await concurrency.acquire(databaseId, {
98
87
  limit: protection.maxConcurrentRequests,
99
- queueWaitMs: protection.maxQueueWaitMs
88
+ queueWaitMs: protection.maxQueueWaitMs,
89
+ signal: gone.signal
100
90
  });
101
91
  }
102
92
  catch (e) {
93
+ res.off('close', onClose);
103
94
  next(e);
104
95
  return;
105
96
  }
97
+ res.off('close', onClose);
98
+ if (gone.signal.aborted) {
99
+ lease.release();
100
+ return;
101
+ }
106
102
  if (!lease.granted) {
107
103
  log.warn(`[admission] concurrency refused: database=${databaseId} ` +
108
104
  `limit=${protection.maxConcurrentRequests} reason=${lease.refusal} waited=${lease.queuedMs}ms`);
@@ -0,0 +1,27 @@
1
+ import './types'; // for Request type
2
+ import { clientIpFrom, trustedProxyHops } from '@constructive-io/express-context';
3
+ /**
4
+ * How far back through `X-Forwarded-For` to believe: an explicit count, then
5
+ * `TRUSTED_PROXY_HOPS` (an explicit 0 included), then 1 when `SERVER_TRUST_PROXY`
6
+ * puts a proxy in front.
7
+ *
8
+ * Express's `trust proxy` predicate and `req.ip` are not used as the answer:
9
+ * the predicate trusts every hop, which would hand back the leftmost entry —
10
+ * the one a caller writes. One ingress means one hop is believed.
11
+ */
12
+ export const resolveProxyHops = (req, configured) => {
13
+ if (typeof configured === 'number')
14
+ return configured;
15
+ const fromEnv = trustedProxyHops();
16
+ if (fromEnv !== null)
17
+ return fromEnv;
18
+ return req.app?.get('trust proxy') ? 1 : 0;
19
+ };
20
+ /**
21
+ * Sets `req.clientIp` — the address `jwt.claims.ip_address`, logs and per-IP
22
+ * limits are keyed on — to the entry our own proxy appended, or the socket peer.
23
+ */
24
+ export const clientIpMiddleware = (req, _res, next) => {
25
+ req.clientIp = clientIpFrom(req, resolveProxyHops(req));
26
+ next();
27
+ };
@@ -1,7 +1,8 @@
1
1
  import '../middleware/types'; // for Request type
2
- import { clientIpFrom, createRecordRefusalsSink, RefusalRecorder, trustedProxyHops } from '@constructive-io/express-context';
2
+ import { clientIpFrom, createRecordRefusalsSink, RefusalRecorder } from '@constructive-io/express-context';
3
3
  import { Logger } from '@pgpmjs/logger';
4
4
  import { getPgPool } from 'pg-cache';
5
+ import { resolveProxyHops } from '../middleware/client-ip';
5
6
  const log = new Logger('refusals');
6
7
  /**
7
8
  * refusals/recorder — the GraphQL lane's `RefusalRecorder`.
@@ -42,19 +43,6 @@ export const getRefusalRecorder = () => installed;
42
43
  export const getRefusalRecorderStats = () => installed?.stats() ?? null;
43
44
  /** The route half of a refusal key — the same shape admission control keys on. */
44
45
  export const routeKeyOf = (req) => `${req.method} ${req.baseUrl ?? ''}${req.path ?? req.url ?? ''}`;
45
- /**
46
- * How far back through `X-Forwarded-For` to believe; mirrors admission
47
- * control's resolution so the refusal source is the same address the limiter
48
- * keyed on.
49
- */
50
- const resolveHops = (req, configured) => {
51
- if (typeof configured === 'number')
52
- return configured;
53
- const fromEnv = trustedProxyHops();
54
- if (fromEnv > 0)
55
- return fromEnv;
56
- return req.app?.get('trust proxy') ? 1 : 0;
57
- };
58
46
  /**
59
47
  * Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
60
48
  * when no recorder is installed.
@@ -70,7 +58,7 @@ export const recordRefusal = (req, reason, opts = {}) => {
70
58
  routeKey: routeKeyOf(req),
71
59
  sourceIp: opts.sourceIp !== undefined
72
60
  ? opts.sourceIp
73
- : clientIpFrom(req, resolveHops(req, opts.trustedProxyHops))
61
+ : clientIpFrom(req, resolveProxyHops(req, opts.trustedProxyHops))
74
62
  };
75
63
  try {
76
64
  recorder.record(refusal);
package/esm/server.js CHANGED
@@ -9,7 +9,6 @@ import express from 'express';
9
9
  import { closeAllCaches, graphileCache } from 'graphile-cache';
10
10
  import graphqlUpload from 'graphql-upload';
11
11
  import { getPgPool } from 'pg-cache';
12
- import requestIp from 'request-ip';
13
12
  import { createAgenticRouter } from './agentic';
14
13
  import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot';
15
14
  import { startDebugSampler } from './diagnostics/debug-sampler';
@@ -19,6 +18,7 @@ import { createApiMiddleware } from './middleware/api';
19
18
  import { createAuthenticateMiddleware, getBearerToken } from './middleware/auth';
20
19
  // Auth cookie handling is done via AuthCookiePlugin in grafserv
21
20
  import { createCaptchaMiddleware } from './middleware/captcha';
21
+ import { clientIpMiddleware } from './middleware/client-ip';
22
22
  import { parseCookieValue, rejectInvalidSessionCookieHeader, SESSION_COOKIE_NAME } from './middleware/cookie';
23
23
  import { cors } from './middleware/cors';
24
24
  import { errorHandler, notFoundHandler } from './middleware/error-handler';
@@ -135,7 +135,7 @@ class Server {
135
135
  // Rewrite Content-Type after graphql-upload so grafserv accepts the request
136
136
  app.use('/graphql', multipartBridge);
137
137
  app.use(parseDomains());
138
- app.use(requestIp.mw());
138
+ app.use(clientIpMiddleware);
139
139
  app.use(requestIdMiddleware());
140
140
  app.use(requestLogger);
141
141
  app.use(api);
@@ -7,6 +7,7 @@ const express_context_1 = require("@constructive-io/express-context");
7
7
  const logger_1 = require("@pgpmjs/logger");
8
8
  const graphql_response_1 = require("../errors/graphql-response");
9
9
  const recorder_1 = require("../refusals/recorder");
10
+ const client_ip_1 = require("./client-ip");
10
11
  const log = new logger_1.Logger('admission');
11
12
  /**
12
13
  * admission-control — the gate that decides whether a GraphQL request starts.
@@ -47,25 +48,6 @@ const protectionOf = (req) => req.requestProtection ?? express_context_1.DEFAULT
47
48
  const routeOf = (req) => `${req.method} ${req.baseUrl}${req.path}`;
48
49
  /** Seconds for a `Retry-After` header — the smallest honest whole number. */
49
50
  const retryAfterSeconds = (ms) => String(Math.max(Math.ceil(ms / 1000), 1));
50
- /**
51
- * How far back through `X-Forwarded-For` to believe.
52
- *
53
- * `req.clientIp` and `req.ip` are both unusable as a limiter key here: this
54
- * server sets `trust proxy` to a predicate that returns true unconditionally,
55
- * and `request-ip` reads the *leftmost* forwarded entry regardless — so either
56
- * one hands a caller a fresh key per request for the cost of a header. The
57
- * fallback to 1 hop exists because the opposite failure is just as bad: behind
58
- * an ingress with no hop count configured, every caller resolves to the
59
- * ingress's address and one abuser throttles the whole tenant.
60
- */
61
- const resolveHops = (req, configured) => {
62
- if (typeof configured === 'number')
63
- return configured;
64
- const fromEnv = (0, express_context_1.trustedProxyHops)();
65
- if (fromEnv > 0)
66
- return fromEnv;
67
- return req.app?.get('trust proxy') ? 1 : 0;
68
- };
69
51
  /**
70
52
  * Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
71
53
  * them per database into the handler chain.
@@ -82,7 +64,7 @@ const createAdmissionControlMiddleware = (options = {}) => {
82
64
  const protection = protectionOf(req);
83
65
  const databaseId = req.databaseId ?? UNKNOWN_DATABASE;
84
66
  // ─── Per-caller rate ────────────────────────────────────────────────────
85
- const hops = resolveHops(req, options.trustedProxyHops);
67
+ const hops = (0, client_ip_1.resolveProxyHops)(req, options.trustedProxyHops);
86
68
  const ip = (0, express_context_1.clientIpFrom)(req, hops);
87
69
  const rateKey = `${databaseId}\u0000${ip}\u0000${routeOf(req)}`;
88
70
  if (!rate.admit(rateKey, protection.rateLimitRpm, protection.rateLimitBurst)) {
@@ -95,17 +77,31 @@ const createAdmissionControlMiddleware = (options = {}) => {
95
77
  return;
96
78
  }
97
79
  // ─── Per-database concurrency ───────────────────────────────────────────
80
+ // A client that hangs up while queued must neither keep its place in the
81
+ // queue nor be handed the slot when its turn comes: nothing would ever
82
+ // release it. `close` is watched from before the wait, since a listener
83
+ // attached only after `acquire` resolves misses a hang-up during it.
84
+ const gone = new AbortController();
85
+ const onClose = () => gone.abort();
86
+ res.once('close', onClose);
98
87
  let lease;
99
88
  try {
100
89
  lease = await concurrency.acquire(databaseId, {
101
90
  limit: protection.maxConcurrentRequests,
102
- queueWaitMs: protection.maxQueueWaitMs
91
+ queueWaitMs: protection.maxQueueWaitMs,
92
+ signal: gone.signal
103
93
  });
104
94
  }
105
95
  catch (e) {
96
+ res.off('close', onClose);
106
97
  next(e);
107
98
  return;
108
99
  }
100
+ res.off('close', onClose);
101
+ if (gone.signal.aborted) {
102
+ lease.release();
103
+ return;
104
+ }
109
105
  if (!lease.granted) {
110
106
  log.warn(`[admission] concurrency refused: database=${databaseId} ` +
111
107
  `limit=${protection.maxConcurrentRequests} reason=${lease.refusal} waited=${lease.queuedMs}ms`);
@@ -0,0 +1,17 @@
1
+ import './types';
2
+ import type { NextFunction, Request, Response } from 'express';
3
+ /**
4
+ * How far back through `X-Forwarded-For` to believe: an explicit count, then
5
+ * `TRUSTED_PROXY_HOPS` (an explicit 0 included), then 1 when `SERVER_TRUST_PROXY`
6
+ * puts a proxy in front.
7
+ *
8
+ * Express's `trust proxy` predicate and `req.ip` are not used as the answer:
9
+ * the predicate trusts every hop, which would hand back the leftmost entry —
10
+ * the one a caller writes. One ingress means one hop is believed.
11
+ */
12
+ export declare const resolveProxyHops: (req: Request, configured?: number) => number;
13
+ /**
14
+ * Sets `req.clientIp` — the address `jwt.claims.ip_address`, logs and per-IP
15
+ * limits are keyed on — to the entry our own proxy appended, or the socket peer.
16
+ */
17
+ export declare const clientIpMiddleware: (req: Request, _res: Response, next: NextFunction) => void;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clientIpMiddleware = exports.resolveProxyHops = void 0;
4
+ require("./types"); // for Request type
5
+ const express_context_1 = require("@constructive-io/express-context");
6
+ /**
7
+ * How far back through `X-Forwarded-For` to believe: an explicit count, then
8
+ * `TRUSTED_PROXY_HOPS` (an explicit 0 included), then 1 when `SERVER_TRUST_PROXY`
9
+ * puts a proxy in front.
10
+ *
11
+ * Express's `trust proxy` predicate and `req.ip` are not used as the answer:
12
+ * the predicate trusts every hop, which would hand back the leftmost entry —
13
+ * the one a caller writes. One ingress means one hop is believed.
14
+ */
15
+ const resolveProxyHops = (req, configured) => {
16
+ if (typeof configured === 'number')
17
+ return configured;
18
+ const fromEnv = (0, express_context_1.trustedProxyHops)();
19
+ if (fromEnv !== null)
20
+ return fromEnv;
21
+ return req.app?.get('trust proxy') ? 1 : 0;
22
+ };
23
+ exports.resolveProxyHops = resolveProxyHops;
24
+ /**
25
+ * Sets `req.clientIp` — the address `jwt.claims.ip_address`, logs and per-IP
26
+ * limits are keyed on — to the entry our own proxy appended, or the socket peer.
27
+ */
28
+ const clientIpMiddleware = (req, _res, next) => {
29
+ req.clientIp = (0, express_context_1.clientIpFrom)(req, (0, exports.resolveProxyHops)(req));
30
+ next();
31
+ };
32
+ exports.clientIpMiddleware = clientIpMiddleware;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.33.2",
3
+ "version": "5.33.4",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -43,7 +43,7 @@
43
43
  "@agentic-kit/ollama": "2.16.0",
44
44
  "@constructive-io/csrf": "^0.31.0",
45
45
  "@constructive-io/errors": "^0.25.0",
46
- "@constructive-io/express-context": "^0.35.1",
46
+ "@constructive-io/express-context": "^0.35.3",
47
47
  "@constructive-io/graphql-env": "^3.37.2",
48
48
  "@constructive-io/graphql-types": "^3.36.1",
49
49
  "@constructive-io/llm-env": "^0.16.0",
@@ -72,7 +72,7 @@
72
72
  "graphile-plugin-utils": "^1.14.1",
73
73
  "graphile-presigned-url-plugin": "^1.23.4",
74
74
  "graphile-scoped-introspection": "^0.6.0",
75
- "graphile-settings": "^6.28.1",
75
+ "graphile-settings": "^6.28.3",
76
76
  "graphile-utils": "5.0.3",
77
77
  "graphql": "16.13.0",
78
78
  "graphql-upload": "^13.0.0",
@@ -82,8 +82,7 @@
82
82
  "pg-env": "^1.33.0",
83
83
  "pg-query-context": "^2.33.0",
84
84
  "pg-sql2": "5.0.1",
85
- "postgraphile": "5.1.4",
86
- "request-ip": "^3.3.0"
85
+ "postgraphile": "5.1.4"
87
86
  },
88
87
  "devDependencies": {
89
88
  "@aws-sdk/client-s3": "^3.1052.0",
@@ -92,7 +91,6 @@
92
91
  "@types/express": "^5.0.6",
93
92
  "@types/graphql-upload": "^8.0.12",
94
93
  "@types/pg": "^8.20.4",
95
- "@types/request-ip": "^0.0.41",
96
94
  "@types/supertest": "^7.2.1",
97
95
  "graphile-test": "5.19.0",
98
96
  "makage": "^0.8.0",
@@ -100,5 +98,5 @@
100
98
  "supertest": "^7.2.2",
101
99
  "ts-node": "^10.9.2"
102
100
  },
103
- "gitHead": "0e90e47d0abcc37fb74ae98ed9cff5ada43c68e2"
101
+ "gitHead": "7ce27b6ff591192fe5eb87e26bb3fb1e538d6505"
104
102
  }
@@ -5,6 +5,7 @@ require("../middleware/types"); // for Request type
5
5
  const express_context_1 = require("@constructive-io/express-context");
6
6
  const logger_1 = require("@pgpmjs/logger");
7
7
  const pg_cache_1 = require("pg-cache");
8
+ const client_ip_1 = require("../middleware/client-ip");
8
9
  const log = new logger_1.Logger('refusals');
9
10
  /**
10
11
  * refusals/recorder — the GraphQL lane's `RefusalRecorder`.
@@ -49,19 +50,6 @@ exports.getRefusalRecorderStats = getRefusalRecorderStats;
49
50
  /** The route half of a refusal key — the same shape admission control keys on. */
50
51
  const routeKeyOf = (req) => `${req.method} ${req.baseUrl ?? ''}${req.path ?? req.url ?? ''}`;
51
52
  exports.routeKeyOf = routeKeyOf;
52
- /**
53
- * How far back through `X-Forwarded-For` to believe; mirrors admission
54
- * control's resolution so the refusal source is the same address the limiter
55
- * keyed on.
56
- */
57
- const resolveHops = (req, configured) => {
58
- if (typeof configured === 'number')
59
- return configured;
60
- const fromEnv = (0, express_context_1.trustedProxyHops)();
61
- if (fromEnv > 0)
62
- return fromEnv;
63
- return req.app?.get('trust proxy') ? 1 : 0;
64
- };
65
53
  /**
66
54
  * Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
67
55
  * when no recorder is installed.
@@ -77,7 +65,7 @@ const recordRefusal = (req, reason, opts = {}) => {
77
65
  routeKey: (0, exports.routeKeyOf)(req),
78
66
  sourceIp: opts.sourceIp !== undefined
79
67
  ? opts.sourceIp
80
- : (0, express_context_1.clientIpFrom)(req, resolveHops(req, opts.trustedProxyHops))
68
+ : (0, express_context_1.clientIpFrom)(req, (0, client_ip_1.resolveProxyHops)(req, opts.trustedProxyHops))
81
69
  };
82
70
  try {
83
71
  recorder.record(refusal);
package/server.js CHANGED
@@ -15,7 +15,6 @@ const express_1 = __importDefault(require("express"));
15
15
  const graphile_cache_1 = require("graphile-cache");
16
16
  const graphql_upload_1 = __importDefault(require("graphql-upload"));
17
17
  const pg_cache_1 = require("pg-cache");
18
- const request_ip_1 = __importDefault(require("request-ip"));
19
18
  const agentic_1 = require("./agentic");
20
19
  const debug_db_snapshot_1 = require("./diagnostics/debug-db-snapshot");
21
20
  const debug_sampler_1 = require("./diagnostics/debug-sampler");
@@ -25,6 +24,7 @@ const api_1 = require("./middleware/api");
25
24
  const auth_1 = require("./middleware/auth");
26
25
  // Auth cookie handling is done via AuthCookiePlugin in grafserv
27
26
  const captcha_1 = require("./middleware/captcha");
27
+ const client_ip_1 = require("./middleware/client-ip");
28
28
  const cookie_1 = require("./middleware/cookie");
29
29
  const cors_1 = require("./middleware/cors");
30
30
  const error_handler_1 = require("./middleware/error-handler");
@@ -142,7 +142,7 @@ class Server {
142
142
  // Rewrite Content-Type after graphql-upload so grafserv accepts the request
143
143
  app.use('/graphql', multipart_bridge_1.multipartBridge);
144
144
  app.use((0, url_domains_1.middleware)());
145
- app.use(request_ip_1.default.mw());
145
+ app.use(client_ip_1.clientIpMiddleware);
146
146
  app.use((0, express_context_1.requestIdMiddleware)());
147
147
  app.use(requestLogger);
148
148
  app.use(api);