@constructive-io/graphql-server 5.20.7 → 5.21.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.
@@ -1,6 +1,7 @@
1
1
  import v8 from 'node:v8';
2
2
  import { getCacheStats } from 'graphile-cache';
3
3
  import { getGraphileBuildStats } from '../middleware/observability/graphile-build-stats';
4
+ import { getRefusalRecorderStats } from '../refusals/recorder';
4
5
  export interface DebugMemorySnapshot {
5
6
  pid: number;
6
7
  nodeEnv: string | undefined;
@@ -47,6 +48,8 @@ export interface DebugMemorySnapshot {
47
48
  keys: string[];
48
49
  };
49
50
  graphileBuilds: ReturnType<typeof getGraphileBuildStats>;
51
+ /** In-memory refusal counter + flusher state; null when no recorder is installed. */
52
+ refusals: ReturnType<typeof getRefusalRecorderStats>;
50
53
  uptimeMinutes: number;
51
54
  timestamp: string;
52
55
  }
@@ -10,6 +10,7 @@ const server_utils_1 = require("@pgpmjs/server-utils");
10
10
  const graphile_cache_1 = require("graphile-cache");
11
11
  const graphile_1 = require("../middleware/graphile");
12
12
  const graphile_build_stats_1 = require("../middleware/observability/graphile-build-stats");
13
+ const recorder_1 = require("../refusals/recorder");
13
14
  const toMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
14
15
  const getDebugMemorySnapshot = () => {
15
16
  const mem = process.memoryUsage();
@@ -70,6 +71,7 @@ const getDebugMemorySnapshot = () => {
70
71
  keys: (0, graphile_1.getInFlightKeys)(),
71
72
  },
72
73
  graphileBuilds: (0, graphile_build_stats_1.getGraphileBuildStats)(),
74
+ refusals: (0, recorder_1.getRefusalRecorderStats)(),
73
75
  uptimeMinutes: process.uptime() / 60,
74
76
  timestamp: new Date().toISOString(),
75
77
  };
@@ -12,10 +12,24 @@
12
12
  */
13
13
  import type { ConstructiveError } from '@constructive-io/errors';
14
14
  import type { Response } from 'express';
15
+ /** Transport-level overrides for a short-circuited response. */
16
+ export interface GraphQLErrorResponseInit {
17
+ /**
18
+ * HTTP status to answer with, when 200 would hide the refusal from the
19
+ * machinery that has to act on it. Admission control is the case that needs
20
+ * it: the operation never reached the schema, and a client's backoff — and
21
+ * every proxy and load balancer between us — reads the status code, not a
22
+ * GraphQL error body.
23
+ */
24
+ status?: number;
25
+ /** Extra headers, e.g. `Retry-After` on a 429. */
26
+ headers?: Record<string, string>;
27
+ }
15
28
  /**
16
29
  * Send a {@link ConstructiveError} as a GraphQL error response.
17
30
  *
18
- * Uses HTTP 200 per the GraphQL-over-HTTP convention: transport succeeded, the
19
- * operation did not. The error's own `http` hint travels in `extensions`.
31
+ * Defaults to HTTP 200 per the GraphQL-over-HTTP convention: transport
32
+ * succeeded, the operation did not. The error's own `http` hint travels in
33
+ * `extensions`.
20
34
  */
21
- export declare function respondWithGraphQLError(res: Response, error: ConstructiveError): void;
35
+ export declare function respondWithGraphQLError(res: Response, error: ConstructiveError, init?: GraphQLErrorResponseInit): void;
@@ -16,11 +16,14 @@ exports.respondWithGraphQLError = respondWithGraphQLError;
16
16
  /**
17
17
  * Send a {@link ConstructiveError} as a GraphQL error response.
18
18
  *
19
- * Uses HTTP 200 per the GraphQL-over-HTTP convention: transport succeeded, the
20
- * operation did not. The error's own `http` hint travels in `extensions`.
19
+ * Defaults to HTTP 200 per the GraphQL-over-HTTP convention: transport
20
+ * succeeded, the operation did not. The error's own `http` hint travels in
21
+ * `extensions`.
21
22
  */
22
- function respondWithGraphQLError(res, error) {
23
- res.status(200).json({
23
+ function respondWithGraphQLError(res, error, init = {}) {
24
+ if (init.headers)
25
+ res.set(init.headers);
26
+ res.status(init.status ?? 200).json({
24
27
  errors: [{ message: error.message, extensions: error.toExtensions() }],
25
28
  });
26
29
  }
@@ -4,6 +4,7 @@ import { SVC_CACHE_TTL_MS, svcCache } from '@pgpmjs/server-utils';
4
4
  import { getCacheStats } from 'graphile-cache';
5
5
  import { getInFlightCount, getInFlightKeys } from '../middleware/graphile';
6
6
  import { getGraphileBuildStats } from '../middleware/observability/graphile-build-stats';
7
+ import { getRefusalRecorderStats } from '../refusals/recorder';
7
8
  const toMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
8
9
  export const getDebugMemorySnapshot = () => {
9
10
  const mem = process.memoryUsage();
@@ -64,6 +65,7 @@ export const getDebugMemorySnapshot = () => {
64
65
  keys: getInFlightKeys(),
65
66
  },
66
67
  graphileBuilds: getGraphileBuildStats(),
68
+ refusals: getRefusalRecorderStats(),
67
69
  uptimeMinutes: process.uptime() / 60,
68
70
  timestamp: new Date().toISOString(),
69
71
  };
@@ -13,11 +13,14 @@
13
13
  /**
14
14
  * Send a {@link ConstructiveError} as a GraphQL error response.
15
15
  *
16
- * Uses HTTP 200 per the GraphQL-over-HTTP convention: transport succeeded, the
17
- * operation did not. The error's own `http` hint travels in `extensions`.
16
+ * Defaults to HTTP 200 per the GraphQL-over-HTTP convention: transport
17
+ * succeeded, the operation did not. The error's own `http` hint travels in
18
+ * `extensions`.
18
19
  */
19
- export function respondWithGraphQLError(res, error) {
20
- res.status(200).json({
20
+ export function respondWithGraphQLError(res, error, init = {}) {
21
+ if (init.headers)
22
+ res.set(init.headers);
23
+ res.status(init.status ?? 200).json({
21
24
  errors: [{ message: error.message, extensions: error.toExtensions() }],
22
25
  });
23
26
  }
@@ -0,0 +1,132 @@
1
+ import './types'; // for Request type
2
+ import { errors } from '@constructive-io/errors';
3
+ import { clientIpFrom, ConcurrencyLimiter, DEFAULT_REQUEST_PROTECTION, RateWindow, trustedProxyHops } from '@constructive-io/express-context';
4
+ import { Logger } from '@pgpmjs/logger';
5
+ import { respondWithGraphQLError } from '../errors/graphql-response';
6
+ import { recordRefusal } from '../refusals/recorder';
7
+ const log = new Logger('admission');
8
+ /**
9
+ * admission-control — the gate that decides whether a GraphQL request starts.
10
+ *
11
+ * The document gate rejects a *shape* and the timeout GUCs bound a *duration*;
12
+ * neither refuses a request that is individually reasonable. This does, on two
13
+ * axes:
14
+ *
15
+ * 1. **Concurrency**, per database. The thing being protected is this
16
+ * process's PostgreSQL pool, so the counter is in-process and
17
+ * `maxConcurrentRequests` is a per-replica budget — a cluster-wide number
18
+ * would need shared state on the hot path to bound something that is not
19
+ * shared. Over budget, a request waits up to `maxQueueWaitMs` for a slot
20
+ * and is then refused; it never queues unbounded, because a queue that
21
+ * outgrows the timeout is just latency with a memory cost.
22
+ *
23
+ * 2. **Rate**, per caller per route. Keyed on the client address rather than
24
+ * the tenant, because a tenant-wide limit is spent *by* an anonymous
25
+ * flood: exhausting it takes the tenant's own API down on the attacker's
26
+ * behalf. This is abuse protection and fails closed, which is the opposite
27
+ * of a billing quota (that serves and records overage) — the two must not
28
+ * be conflated, and neither is a database write on the request path.
29
+ *
30
+ * @module middleware/admission-control
31
+ */
32
+ /** Window the per-caller rate is counted over — `rateLimitRpm` is per minute. */
33
+ const RATE_WINDOW_MS = 60_000;
34
+ /** What a request is keyed by when it carries no resolved database. */
35
+ const UNKNOWN_DATABASE = 'unknown';
36
+ const protectionOf = (req) => req.requestProtection ?? DEFAULT_REQUEST_PROTECTION;
37
+ /**
38
+ * The route half of the rate key.
39
+ *
40
+ * Per-route rather than per-request-line so a caller cannot spread a flood
41
+ * across query strings, and so a cheap route's traffic does not spend the
42
+ * budget an expensive one needs.
43
+ */
44
+ const routeOf = (req) => `${req.method} ${req.baseUrl}${req.path}`;
45
+ /** Seconds for a `Retry-After` header — the smallest honest whole number. */
46
+ 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
+ /**
67
+ * Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
68
+ * them per database into the handler chain.
69
+ *
70
+ * Mount after `createRequestProtectionMiddleware` (which resolves the bounds
71
+ * this reads) and before the GraphQL handler. Order within the middleware
72
+ * matters: the rate check is O(1) and runs first, so a flood is refused
73
+ * without ever occupying a concurrency slot or waiting in its queue.
74
+ */
75
+ export const createAdmissionControlMiddleware = (options = {}) => {
76
+ const concurrency = new ConcurrencyLimiter();
77
+ const rate = new RateWindow(RATE_WINDOW_MS);
78
+ return async (req, res, next) => {
79
+ const protection = protectionOf(req);
80
+ const databaseId = req.databaseId ?? UNKNOWN_DATABASE;
81
+ // ─── Per-caller rate ────────────────────────────────────────────────────
82
+ const hops = resolveHops(req, options.trustedProxyHops);
83
+ const ip = clientIpFrom(req, hops);
84
+ const rateKey = `${databaseId}\u0000${ip}\u0000${routeOf(req)}`;
85
+ if (!rate.admit(rateKey, protection.rateLimitRpm, protection.rateLimitBurst)) {
86
+ log.warn(`[admission] rate limit: database=${databaseId} ip=${ip} route=${routeOf(req)}`);
87
+ recordRefusal(req, 'rate_limited', { sourceIp: ip });
88
+ respondWithGraphQLError(res, errors.RATE_LIMITED(), {
89
+ status: 429,
90
+ headers: { 'Retry-After': retryAfterSeconds(rate.retryAfterMs(rateKey)) }
91
+ });
92
+ return;
93
+ }
94
+ // ─── Per-database concurrency ───────────────────────────────────────────
95
+ let lease;
96
+ try {
97
+ lease = await concurrency.acquire(databaseId, {
98
+ limit: protection.maxConcurrentRequests,
99
+ queueWaitMs: protection.maxQueueWaitMs
100
+ });
101
+ }
102
+ catch (e) {
103
+ next(e);
104
+ return;
105
+ }
106
+ if (!lease.granted) {
107
+ log.warn(`[admission] concurrency refused: database=${databaseId} ` +
108
+ `limit=${protection.maxConcurrentRequests} reason=${lease.refusal} waited=${lease.queuedMs}ms`);
109
+ recordRefusal(req, lease.refusal === 'queue_timeout' ? 'queue_timeout' : 'concurrency_saturated', {
110
+ sourceIp: ip
111
+ });
112
+ respondWithGraphQLError(res, errors.CONCURRENCY_LIMIT_REACHED({
113
+ limit: protection.maxConcurrentRequests,
114
+ waitedMs: lease.queuedMs
115
+ }), {
116
+ status: 429,
117
+ // The queue is the honest wait estimate: a slot freed sooner than
118
+ // this and the request would already have been admitted.
119
+ headers: { 'Retry-After': retryAfterSeconds(protection.maxQueueWaitMs) }
120
+ });
121
+ return;
122
+ }
123
+ // Release exactly once, whichever ends first. `close` covers the client
124
+ // that hangs up mid-flight — without it an aborted request holds its slot
125
+ // until the process restarts, and a client that retries on abort would
126
+ // drain the tenant's budget one leaked slot at a time. `release` is
127
+ // idempotent, so both listeners can fire.
128
+ res.on('close', () => lease.release());
129
+ res.on('finish', () => lease.release());
130
+ next();
131
+ };
132
+ };
@@ -6,7 +6,7 @@ import { svcCache } from '@pgpmjs/server-utils';
6
6
  import { getPgPool } from 'pg-cache';
7
7
  import errorPage50x from '../errors/50x';
8
8
  import errorPage404Message from '../errors/404-message';
9
- import { getRoutingSchema, isValidSchemaName, resolveRoute, routeToApiStructure } from './routing';
9
+ import { getRoutingSchema, isValidSchemaName, requireApiRole, resolveRoute, routeToApiStructure } from './routing';
10
10
  const log = new Logger('api');
11
11
  // =============================================================================
12
12
  // Module Loader Registry (replaces inline SQL queries for per-db config)
@@ -141,8 +141,8 @@ export const getSvcKey = (opts, req) => {
141
141
  const toApiStructure = (row, opts, settings = {}) => ({
142
142
  apiId: row.api_id,
143
143
  dbname: row.dbname || opts.pg?.database || '',
144
- anonRole: row.anon_role || 'anon',
145
- roleName: row.role_name || 'authenticated',
144
+ anonRole: requireApiRole('anon_role', row.anon_role, row.api_id),
145
+ roleName: requireApiRole('role_name', row.role_name, row.api_id),
146
146
  schema: row.schemas || [],
147
147
  rlsModule: settings.rlsModule,
148
148
  domains: [],
@@ -345,6 +345,11 @@ export const createApiMiddleware = (opts) => {
345
345
  res.status(404).send(errorPage404Message(err.message));
346
346
  return;
347
347
  }
348
+ if (err.code === 'MISSING_API_ROLE') {
349
+ log.error('[api-middleware] resolved API row has no served role:', err.message);
350
+ res.status(500).send(errorPage50x);
351
+ return;
352
+ }
348
353
  if (err.code === 'NO_DATABASE_ID') {
349
354
  log.error('[api-middleware] no database id resolved:', err.message);
350
355
  res.status(500).send(errorPage50x);
@@ -174,10 +174,16 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
174
174
  'jwt.claims.principal_id': headerActorId,
175
175
  ...context
176
176
  };
177
+ // The entity pair travels together: the tenant's writers reject an
178
+ // entity id whose type they cannot interpret (`ENTITY_TYPE_REQUIRED`).
177
179
  const headerEntityId = req.get('X-Entity-Id');
180
+ const headerEntityType = req.get('X-Entity-Type');
178
181
  if (headerEntityId) {
179
182
  pgSettings['jwt.claims.entity_id'] = headerEntityId;
180
183
  }
184
+ if (headerEntityType) {
185
+ pgSettings['jwt.claims.entity_type'] = headerEntityType;
186
+ }
181
187
  const headerOrganizationId = req.get('X-Organization-Id');
182
188
  if (headerOrganizationId) {
183
189
  pgSettings['jwt.claims.organization_id'] = headerOrganizationId;
@@ -188,11 +194,20 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings, apiId,
188
194
  return { pgSettings };
189
195
  }
190
196
  }
197
+ // No actor to name, so the tenant database the request addresses carries
198
+ // the attribution — the same rule the sync gateway applies to a request
199
+ // that arrives without a credential. Without it the tenant's own writers
200
+ // refuse the work an anonymous request legitimately does
201
+ // (`ATTRIBUTION_REQUIRED`), so a public mutation cannot enqueue a job.
191
202
  const anonSettings = {
192
203
  ...timeouts,
193
204
  role: anonRole,
194
205
  ...context
195
206
  };
207
+ if (req?.databaseId) {
208
+ anonSettings['jwt.claims.entity_id'] = req.databaseId;
209
+ anonSettings['jwt.claims.entity_type'] = 'database';
210
+ }
196
211
  if (req?.requestId) {
197
212
  anonSettings['request.id'] = req.requestId;
198
213
  }
@@ -2,6 +2,7 @@ import './types'; // for Request type
2
2
  import { errors } from '@constructive-io/errors';
3
3
  import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
4
4
  import { respondWithGraphQLError } from '../errors/graphql-response';
5
+ import { recordRefusal } from '../refusals/recorder';
5
6
  /**
6
7
  * Resolve the bounds this request runs under and attach them to it.
7
8
  *
@@ -51,6 +52,7 @@ export const createRequestProtectionMiddleware = () => {
51
52
  if (Number.isFinite(declaredLength) &&
52
53
  declaredLength > protection.maxRequestBytes &&
53
54
  !isMultipart(req)) {
55
+ recordRefusal(req, 'request_too_large');
54
56
  respondWithGraphQLError(res, errors.REQUEST_TOO_LARGE({ bytes: declaredLength, limit: protection.maxRequestBytes }));
55
57
  return;
56
58
  }
@@ -1,5 +1,33 @@
1
1
  import { Logger } from '@pgpmjs/logger';
2
2
  const log = new Logger('routing');
3
+ // =============================================================================
4
+ // Scoped routing plane (resolve_route contract)
5
+ // =============================================================================
6
+ //
7
+ // One indexed call resolves an incoming request across scopes. The server's
8
+ // projection is HOST-ONLY: Traefik/Ingress owns L7 path/method routing, so
9
+ // the server always calls the frozen contract with the root path and no
10
+ // method:
11
+ //
12
+ // SELECT * FROM <schema>.resolve_route(request_host, '/', NULL)
13
+ //
14
+ // Contract (constructive-db docs/architecture/scoped-domain-routing.md):
15
+ // a single row is always returned; no match → route_binding_id IS NULL.
16
+ /**
17
+ * The role an API row names is the role the server will SET ROLE to; a row
18
+ * that leaves one blank is a broken surface, not a request for a default.
19
+ * Throws (code MISSING_API_ROLE) rather than substituting a role.
20
+ */
21
+ export const requireApiRole = (column, value, apiId) => {
22
+ if (typeof value !== 'string' || value.trim() === '') {
23
+ const error = new Error(`API ${apiId ?? '<unknown>'} has no ${column}; a served role is required and there is no default.`);
24
+ error.code = 'MISSING_API_ROLE';
25
+ error.column = column;
26
+ error.apiId = apiId;
27
+ throw error;
28
+ }
29
+ return value;
30
+ };
3
31
  const RESOLVER_FUNCTION = 'resolve_route';
4
32
  /** Published logical name of the database-scope routing plane. */
5
33
  export const DEFAULT_ROUTING_SCHEMA = 'routing_public';
@@ -52,13 +80,14 @@ export const routeToApiStructure = (route, opts) => {
52
80
  log.debug('[resolve-route] api target missing schemas in resolved_config; no match');
53
81
  return null;
54
82
  }
83
+ const apiId = config.api_id ?? route.target_source_id ?? undefined;
55
84
  return {
56
- apiId: config.api_id ?? route.target_source_id ?? undefined,
85
+ apiId,
57
86
  // Scoped APIs leave dbname NULL when their schemas live in the serving
58
87
  // database; fall back to the server's own database in that case.
59
88
  dbname: config.dbname || opts.pg?.database || '',
60
- anonRole: config.anon_role || 'anon',
61
- roleName: config.role_name || 'authenticated',
89
+ anonRole: requireApiRole('anon_role', config.anon_role, apiId),
90
+ roleName: requireApiRole('role_name', config.role_name, apiId),
62
91
  schema: config.schemas,
63
92
  domains: [],
64
93
  databaseId: config.database_id,
@@ -1,6 +1,21 @@
1
1
  import '../middleware/types'; // for Request type
2
2
  import { DEFAULT_REQUEST_PROTECTION } from '@constructive-io/express-context';
3
+ import { SafeError } from 'grafast';
3
4
  import { enforceDocumentProtection } from '../protection/document-gate';
5
+ import { recordRefusal } from '../refusals/recorder';
6
+ /** The document-gate error codes, as the refusal taxonomy names them. */
7
+ const DOCUMENT_REFUSALS = {
8
+ QUERY_TOO_DEEP: 'query_too_deep',
9
+ QUERY_TOO_COSTLY: 'query_too_costly',
10
+ PAGE_SIZE_TOO_LARGE: 'page_size_too_large'
11
+ };
12
+ /** The refusal a gate rejection counts as, or undefined for any other error. */
13
+ export const documentRefusalReason = (err) => {
14
+ if (!(err instanceof SafeError))
15
+ return undefined;
16
+ const code = err.extensions?.code;
17
+ return typeof code === 'string' ? DOCUMENT_REFUSALS[code] : undefined;
18
+ };
4
19
  /**
5
20
  * Get the Express request from a grafserv request context.
6
21
  */
@@ -31,7 +46,17 @@ export const RequestProtectionPlugin = {
31
46
  // the platform defaults still apply rather than nothing at all.
32
47
  const protection = req?.requestProtection ?? DEFAULT_REQUEST_PROTECTION;
33
48
  if (args.document && args.schema) {
34
- enforceDocumentProtection(args.schema, args.document, args.variableValues, protection, args.operationName);
49
+ try {
50
+ enforceDocumentProtection(args.schema, args.document, args.variableValues, protection, args.operationName);
51
+ }
52
+ catch (err) {
53
+ // The rejection is still the client's `SafeError`; it is counted on
54
+ // the way out, keyed by the tenant the request resolved.
55
+ const reason = documentRefusalReason(err);
56
+ if (reason && req)
57
+ recordRefusal(req, reason);
58
+ throw err;
59
+ }
35
60
  }
36
61
  return next();
37
62
  }
@@ -0,0 +1,136 @@
1
+ import '../middleware/types'; // for Request type
2
+ import { clientIpFrom, createRecordRefusalsSink, RefusalRecorder, trustedProxyHops } from '@constructive-io/express-context';
3
+ import { Logger } from '@pgpmjs/logger';
4
+ import { getPgPool } from 'pg-cache';
5
+ const log = new Logger('refusals');
6
+ /**
7
+ * refusals/recorder — the GraphQL lane's `RefusalRecorder`.
8
+ *
9
+ * One recorder per process, installed by the server at startup and read by
10
+ * every refusal site through `recordRefusal`. Emitters never see the recorder,
11
+ * the pool or a promise: `recordRefusal` is a synchronous counter bump that
12
+ * cannot fail the response being written around it. A harness that mounts a
13
+ * middleware without a server has no recorder installed and the call is a
14
+ * no-op.
15
+ *
16
+ * ## Flush identity
17
+ *
18
+ * A flush runs outside any tenant request, so it establishes its own identity
19
+ * at the top of its transaction: the `platform-bootstrap` service principal
20
+ * (`jwt.claims.user_id` / `principal_id`) attributed to the platform database
21
+ * (`jwt.claims.database_id`, `entity_id`, `entity_type`) as the platform
22
+ * `system` role type (`jwt.claims.role_type`), which the generated writer's
23
+ * guard and RESTRICTIVE insert policy require. Those are the claims every
24
+ * other unattended platform write carries; resolving them is the
25
+ * claim-establishment step at the entry point, not a lookup inside the
26
+ * function being called — `record_refusals` raises if the claims are missing.
27
+ * Resolution is cached after the first success; a failure is reported by the
28
+ * recorder as a failed flush (loud, every interval) and retried next time.
29
+ *
30
+ * @module refusals/recorder
31
+ */
32
+ /** The principal an unattended platform write acts as. */
33
+ export const PLATFORM_BOOTSTRAP_PRINCIPAL = 'platform-bootstrap';
34
+ let installed = null;
35
+ /** Make `recorder` the process's recorder. Returns the previous one, if any. */
36
+ export const installRefusalRecorder = (recorder) => {
37
+ const previous = installed;
38
+ installed = recorder;
39
+ return previous;
40
+ };
41
+ export const getRefusalRecorder = () => installed;
42
+ export const getRefusalRecorderStats = () => installed?.stats() ?? null;
43
+ /** The route half of a refusal key — the same shape admission control keys on. */
44
+ 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
+ /**
59
+ * Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
60
+ * when no recorder is installed.
61
+ */
62
+ export const recordRefusal = (req, reason, opts = {}) => {
63
+ const recorder = installed;
64
+ if (!recorder)
65
+ return;
66
+ const refusal = {
67
+ databaseId: opts.databaseId !== undefined ? opts.databaseId : req.databaseId ?? null,
68
+ lane: 'graphql',
69
+ reason,
70
+ routeKey: routeKeyOf(req),
71
+ sourceIp: opts.sourceIp !== undefined
72
+ ? opts.sourceIp
73
+ : clientIpFrom(req, resolveHops(req, opts.trustedProxyHops))
74
+ };
75
+ try {
76
+ recorder.record(refusal);
77
+ }
78
+ catch (err) {
79
+ // The refusal response is already being written; a broken recorder is
80
+ // reported here and via the recorder's own stats, never to the client.
81
+ log.error(`refusal not recorded reason=${reason}: ${err instanceof Error ? err.message : String(err)}`);
82
+ }
83
+ };
84
+ /**
85
+ * Resolve the claims a flush runs under: the platform database and the
86
+ * `platform-bootstrap` service principal's user row. Throws — with the row
87
+ * that was missing named — rather than returning a partial identity.
88
+ */
89
+ export const resolvePlatformFlushIdentity = async (pool, principalName = PLATFORM_BOOTSTRAP_PRINCIPAL) => {
90
+ const database = await pool.query(`SELECT id FROM metaschema_public.database WHERE platform IS TRUE`);
91
+ if (database.rowCount !== 1) {
92
+ throw new Error(`refusals: expected exactly one platform database (metaschema_public.database.platform), found ${database.rowCount}`);
93
+ }
94
+ const principal = await pool.query(`SELECT user_id, bypass_step_up FROM constructive_auth_public.principals WHERE name = $1`, [principalName]);
95
+ if (principal.rowCount !== 1) {
96
+ throw new Error(`refusals: no service principal named '${principalName}'`);
97
+ }
98
+ if (principal.rows[0].bypass_step_up !== true) {
99
+ throw new Error(`refusals: principal '${principalName}' is not a service principal (no bypass_step_up)`);
100
+ }
101
+ return {
102
+ databaseId: database.rows[0].id,
103
+ actorId: principal.rows[0].user_id,
104
+ principalId: principal.rows[0].user_id
105
+ };
106
+ };
107
+ export const platformFlushClaims = (identity) => ({
108
+ 'jwt.claims.database_id': identity.databaseId,
109
+ 'jwt.claims.user_id': identity.actorId,
110
+ 'jwt.claims.principal_id': identity.principalId,
111
+ 'jwt.claims.entity_id': identity.databaseId,
112
+ 'jwt.claims.entity_type': 'database',
113
+ 'jwt.claims.role_type': 'system'
114
+ });
115
+ /**
116
+ * The recorder the server runs: counts in memory, flushes into
117
+ * `constructive_usage_private.record_refusals` on the platform pool under the
118
+ * platform flush identity. Not started; the caller owns start/stop.
119
+ */
120
+ export const createPlatformRefusalRecorder = (opts, recorderOpts = {}) => {
121
+ const pool = getPgPool(opts.pg);
122
+ let cached = null;
123
+ const claims = async () => {
124
+ if (cached)
125
+ return cached;
126
+ cached = platformFlushClaims(await resolvePlatformFlushIdentity(pool, recorderOpts.principalName));
127
+ log.info(`[refusals] flushing as '${recorderOpts.principalName ?? PLATFORM_BOOTSTRAP_PRINCIPAL}'`);
128
+ return cached;
129
+ };
130
+ return new RefusalRecorder({
131
+ sink: createRecordRefusalsSink({ pool, claims }),
132
+ intervalMs: recorderOpts.intervalMs,
133
+ jitterMs: recorderOpts.jitterMs,
134
+ maxKeys: recorderOpts.maxKeys
135
+ });
136
+ };
package/esm/server.js CHANGED
@@ -14,6 +14,7 @@ import { createAgenticRouter } from './agentic';
14
14
  import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot';
15
15
  import { startDebugSampler } from './diagnostics/debug-sampler';
16
16
  import { isDevelopmentObservabilityMode, isGraphqlObservabilityEnabled, isGraphqlObservabilityRequested, isLoopbackHost } from './diagnostics/observability';
17
+ import { createAdmissionControlMiddleware } from './middleware/admission-control';
17
18
  import { createApiMiddleware } from './middleware/api';
18
19
  import { createAuthenticateMiddleware } from './middleware/auth';
19
20
  // Auth cookie handling is done via AuthCookiePlugin in grafserv
@@ -32,6 +33,7 @@ import { localObservabilityOnly } from './middleware/observability/guard';
32
33
  import { createRequestLogger } from './middleware/observability/request-logger';
33
34
  import { createRequestProtectionMiddleware } from './middleware/request-protection';
34
35
  import { getRoutingSchema } from './middleware/routing';
36
+ import { createPlatformRefusalRecorder, installRefusalRecorder } from './refusals/recorder';
35
37
  const log = new Logger('server');
36
38
  /**
37
39
  * Creates and starts a GraphQL server instance
@@ -69,6 +71,7 @@ class Server {
69
71
  closed = false;
70
72
  httpServer = null;
71
73
  debugSampler = null;
74
+ refusalRecorder = null;
72
75
  constructor(opts) {
73
76
  this.opts = getEnvOptions(opts);
74
77
  const effectiveOpts = this.opts;
@@ -146,6 +149,13 @@ class Server {
146
149
  // Resolve the tenant's protection bounds before anything can spend budget
147
150
  // on the request (and before the GraphQL handler reads them for pgSettings).
148
151
  app.use(createRequestProtectionMiddleware());
152
+ // Spend the width and rate bounds the line above resolved, before a
153
+ // request can take a pool connection. Scoped to /graphql rather than
154
+ // mounted globally because a concurrency slot is held for as long as the
155
+ // handler runs: the SSE routes below are long-lived by design and would
156
+ // sit in the budget for the life of the stream. The other lanes need
157
+ // their own bound sized for streaming, not this one.
158
+ app.use('/graphql', createAdmissionControlMiddleware());
149
159
  app.use(createCaptchaMiddleware());
150
160
  // CSRF protection for cookie-authenticated requests
151
161
  // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
@@ -187,6 +197,11 @@ class Server {
187
197
  app.use(errorHandler); // Catches all thrown errors
188
198
  this.app = app;
189
199
  this.debugSampler = observabilityEnabled ? startDebugSampler(effectiveOpts) : null;
200
+ // Refusals are counted in memory by the middleware above and flushed to
201
+ // the platform table on a timer; the request path never touches the pool.
202
+ this.refusalRecorder = createPlatformRefusalRecorder(effectiveOpts);
203
+ installRefusalRecorder(this.refusalRecorder);
204
+ this.refusalRecorder.start();
190
205
  }
191
206
  listen() {
192
207
  const { server } = this.opts;
@@ -291,6 +306,11 @@ class Server {
291
306
  this.closed = true;
292
307
  this.shuttingDown = true;
293
308
  await this.removeEventListener();
309
+ if (this.refusalRecorder) {
310
+ installRefusalRecorder(null);
311
+ await this.refusalRecorder.stop();
312
+ this.refusalRecorder = null;
313
+ }
294
314
  if (this.debugSampler) {
295
315
  await this.debugSampler.stop();
296
316
  this.debugSampler = null;
@@ -0,0 +1,20 @@
1
+ import './types';
2
+ import type { RequestHandler } from 'express';
3
+ export interface AdmissionControlOptions {
4
+ /**
5
+ * How many proxies of our own sit in front of the server. Defaults to
6
+ * `TRUSTED_PROXY_HOPS`, then to 1 when Express is configured to trust a
7
+ * proxy at all; see `clientIpFrom` for why guessing higher is unsafe.
8
+ */
9
+ trustedProxyHops?: number;
10
+ }
11
+ /**
12
+ * Rate-limit callers per route, then admit at most `maxConcurrentRequests` of
13
+ * them per database into the handler chain.
14
+ *
15
+ * Mount after `createRequestProtectionMiddleware` (which resolves the bounds
16
+ * this reads) and before the GraphQL handler. Order within the middleware
17
+ * matters: the rate check is O(1) and runs first, so a flood is refused
18
+ * without ever occupying a concurrency slot or waiting in its queue.
19
+ */
20
+ export declare const createAdmissionControlMiddleware: (options?: AdmissionControlOptions) => RequestHandler;