@constructive-io/graphql-server 5.20.8 → 5.22.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "5.20.8",
3
+ "version": "5.22.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -43,19 +43,19 @@
43
43
  "dependencies": {
44
44
  "@agentic-kit/ollama": "2.14.0",
45
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",
46
+ "@constructive-io/errors": "^0.13.0",
47
+ "@constructive-io/express-context": "^0.28.0",
48
+ "@constructive-io/graphql-env": "^3.32.0",
49
+ "@constructive-io/graphql-types": "^3.31.0",
50
50
  "@constructive-io/llm-env": "^0.14.1",
51
- "@constructive-io/query-builder": "^3.13.3",
51
+ "@constructive-io/query-builder": "^3.13.5",
52
52
  "@constructive-io/s3-utils": "^2.33.0",
53
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.2",
55
+ "@pgpmjs/env": "^2.43.4",
56
56
  "@pgpmjs/logger": "^2.25.1",
57
- "@pgpmjs/server-utils": "^3.27.2",
58
- "@pgpmjs/types": "^2.53.0",
57
+ "@pgpmjs/server-utils": "^3.27.4",
58
+ "@pgpmjs/types": "^2.53.2",
59
59
  "cors": "^2.8.6",
60
60
  "deepmerge": "^4.3.1",
61
61
  "express": "^5.2.1",
@@ -64,16 +64,16 @@
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.2",
67
+ "graphile-cache": "^4.12.4",
68
68
  "graphile-config": "1.1.0",
69
- "graphile-function-bindings": "^1.14.3",
70
- "graphile-settings": "^6.21.3",
69
+ "graphile-function-bindings": "^1.14.5",
70
+ "graphile-settings": "^6.21.5",
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.2",
76
+ "pg-cache": "^3.27.4",
77
77
  "pg-env": "^1.31.1",
78
78
  "pg-query-context": "^2.30.1",
79
79
  "pg-sql2": "5.0.1",
@@ -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.3",
93
+ "graphile-test": "5.14.5",
94
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": "4deb5b9a00ead37453857d24274007255092822e"
99
+ "gitHead": "01e073830677c0587bab564b7d575a06b5156f0c"
100
100
  }
@@ -1,5 +1,8 @@
1
1
  import '../middleware/types';
2
+ import type { RefusalReason } from '@constructive-io/express-context';
2
3
  import type { GraphileConfig } from 'graphile-config';
4
+ /** The refusal a gate rejection counts as, or undefined for any other error. */
5
+ export declare const documentRefusalReason: (err: unknown) => RefusalReason | undefined;
3
6
  /**
4
7
  * RequestProtectionPlugin — applies a tenant's document bounds to every
5
8
  * operation.
@@ -1,9 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RequestProtectionPlugin = void 0;
3
+ exports.RequestProtectionPlugin = exports.documentRefusalReason = void 0;
4
4
  require("../middleware/types"); // for Request type
5
5
  const express_context_1 = require("@constructive-io/express-context");
6
+ const grafast_1 = require("grafast");
6
7
  const document_gate_1 = require("../protection/document-gate");
8
+ const recorder_1 = require("../refusals/recorder");
9
+ /** The document-gate error codes, as the refusal taxonomy names them. */
10
+ const DOCUMENT_REFUSALS = {
11
+ QUERY_TOO_DEEP: 'query_too_deep',
12
+ QUERY_TOO_COSTLY: 'query_too_costly',
13
+ PAGE_SIZE_TOO_LARGE: 'page_size_too_large'
14
+ };
15
+ /** The refusal a gate rejection counts as, or undefined for any other error. */
16
+ const documentRefusalReason = (err) => {
17
+ if (!(err instanceof grafast_1.SafeError))
18
+ return undefined;
19
+ const code = err.extensions?.code;
20
+ return typeof code === 'string' ? DOCUMENT_REFUSALS[code] : undefined;
21
+ };
22
+ exports.documentRefusalReason = documentRefusalReason;
7
23
  /**
8
24
  * Get the Express request from a grafserv request context.
9
25
  */
@@ -34,7 +50,17 @@ exports.RequestProtectionPlugin = {
34
50
  // the platform defaults still apply rather than nothing at all.
35
51
  const protection = req?.requestProtection ?? express_context_1.DEFAULT_REQUEST_PROTECTION;
36
52
  if (args.document && args.schema) {
37
- (0, document_gate_1.enforceDocumentProtection)(args.schema, args.document, args.variableValues, protection, args.operationName);
53
+ try {
54
+ (0, document_gate_1.enforceDocumentProtection)(args.schema, args.document, args.variableValues, protection, args.operationName);
55
+ }
56
+ catch (err) {
57
+ // The rejection is still the client's `SafeError`; it is counted on
58
+ // the way out, keyed by the tenant the request resolved.
59
+ const reason = (0, exports.documentRefusalReason)(err);
60
+ if (reason && req)
61
+ (0, recorder_1.recordRefusal)(req, reason);
62
+ throw err;
63
+ }
38
64
  }
39
65
  return next();
40
66
  }
@@ -0,0 +1,78 @@
1
+ import '../middleware/types';
2
+ import type { RefusalReason, RefusalRecorderStats } from '@constructive-io/express-context';
3
+ import { RefusalRecorder } from '@constructive-io/express-context';
4
+ import type { ConstructiveOptions } from '@constructive-io/graphql-types';
5
+ import type { Request } from 'express';
6
+ import type { Pool } from 'pg';
7
+ /**
8
+ * refusals/recorder — the GraphQL lane's `RefusalRecorder`.
9
+ *
10
+ * One recorder per process, installed by the server at startup and read by
11
+ * every refusal site through `recordRefusal`. Emitters never see the recorder,
12
+ * the pool or a promise: `recordRefusal` is a synchronous counter bump that
13
+ * cannot fail the response being written around it. A harness that mounts a
14
+ * middleware without a server has no recorder installed and the call is a
15
+ * no-op.
16
+ *
17
+ * ## Flush identity
18
+ *
19
+ * A flush runs outside any tenant request, so it establishes its own identity
20
+ * at the top of its transaction: the `platform-bootstrap` service principal
21
+ * (`jwt.claims.user_id` / `principal_id`) attributed to the platform database
22
+ * (`jwt.claims.database_id`, `entity_id`, `entity_type`) as the platform
23
+ * `system` role type (`jwt.claims.role_type`), which the generated writer's
24
+ * guard and RESTRICTIVE insert policy require. Those are the claims every
25
+ * other unattended platform write carries; resolving them is the
26
+ * claim-establishment step at the entry point, not a lookup inside the
27
+ * function being called — `record_refusals` raises if the claims are missing.
28
+ * Resolution is cached after the first success; a failure is reported by the
29
+ * recorder as a failed flush (loud, every interval) and retried next time.
30
+ *
31
+ * @module refusals/recorder
32
+ */
33
+ /** The principal an unattended platform write acts as. */
34
+ export declare const PLATFORM_BOOTSTRAP_PRINCIPAL = "platform-bootstrap";
35
+ /** Make `recorder` the process's recorder. Returns the previous one, if any. */
36
+ export declare const installRefusalRecorder: (recorder: RefusalRecorder | null) => RefusalRecorder | null;
37
+ export declare const getRefusalRecorder: () => RefusalRecorder | null;
38
+ export declare const getRefusalRecorderStats: () => RefusalRecorderStats | null;
39
+ /** The route half of a refusal key — the same shape admission control keys on. */
40
+ export declare const routeKeyOf: (req: Request) => string;
41
+ export interface RecordRefusalOptions {
42
+ /** Overrides the address the refusal is attributed to. */
43
+ sourceIp?: string | null;
44
+ /** See `AdmissionControlOptions.trustedProxyHops`. */
45
+ trustedProxyHops?: number;
46
+ /** Overrides `req.databaseId`. */
47
+ databaseId?: string | null;
48
+ }
49
+ /**
50
+ * Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
51
+ * when no recorder is installed.
52
+ */
53
+ export declare const recordRefusal: (req: Request, reason: RefusalReason, opts?: RecordRefusalOptions) => void;
54
+ interface PlatformFlushIdentity {
55
+ databaseId: string;
56
+ actorId: string;
57
+ principalId: string;
58
+ }
59
+ /**
60
+ * Resolve the claims a flush runs under: the platform database and the
61
+ * `platform-bootstrap` service principal's user row. Throws — with the row
62
+ * that was missing named — rather than returning a partial identity.
63
+ */
64
+ export declare const resolvePlatformFlushIdentity: (pool: Pool, principalName?: string) => Promise<PlatformFlushIdentity>;
65
+ export declare const platformFlushClaims: (identity: PlatformFlushIdentity) => Record<string, string>;
66
+ export interface PlatformRefusalRecorderOptions {
67
+ intervalMs?: number;
68
+ jitterMs?: number;
69
+ maxKeys?: number;
70
+ principalName?: string;
71
+ }
72
+ /**
73
+ * The recorder the server runs: counts in memory, flushes into
74
+ * `constructive_usage_private.record_refusals` on the platform pool under the
75
+ * platform flush identity. Not started; the caller owns start/stop.
76
+ */
77
+ export declare const createPlatformRefusalRecorder: (opts: ConstructiveOptions, recorderOpts?: PlatformRefusalRecorderOptions) => RefusalRecorder;
78
+ export {};
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createPlatformRefusalRecorder = exports.platformFlushClaims = exports.resolvePlatformFlushIdentity = exports.recordRefusal = exports.routeKeyOf = exports.getRefusalRecorderStats = exports.getRefusalRecorder = exports.installRefusalRecorder = exports.PLATFORM_BOOTSTRAP_PRINCIPAL = void 0;
4
+ require("../middleware/types"); // for Request type
5
+ const express_context_1 = require("@constructive-io/express-context");
6
+ const logger_1 = require("@pgpmjs/logger");
7
+ const pg_cache_1 = require("pg-cache");
8
+ const log = new logger_1.Logger('refusals');
9
+ /**
10
+ * refusals/recorder — the GraphQL lane's `RefusalRecorder`.
11
+ *
12
+ * One recorder per process, installed by the server at startup and read by
13
+ * every refusal site through `recordRefusal`. Emitters never see the recorder,
14
+ * the pool or a promise: `recordRefusal` is a synchronous counter bump that
15
+ * cannot fail the response being written around it. A harness that mounts a
16
+ * middleware without a server has no recorder installed and the call is a
17
+ * no-op.
18
+ *
19
+ * ## Flush identity
20
+ *
21
+ * A flush runs outside any tenant request, so it establishes its own identity
22
+ * at the top of its transaction: the `platform-bootstrap` service principal
23
+ * (`jwt.claims.user_id` / `principal_id`) attributed to the platform database
24
+ * (`jwt.claims.database_id`, `entity_id`, `entity_type`) as the platform
25
+ * `system` role type (`jwt.claims.role_type`), which the generated writer's
26
+ * guard and RESTRICTIVE insert policy require. Those are the claims every
27
+ * other unattended platform write carries; resolving them is the
28
+ * claim-establishment step at the entry point, not a lookup inside the
29
+ * function being called — `record_refusals` raises if the claims are missing.
30
+ * Resolution is cached after the first success; a failure is reported by the
31
+ * recorder as a failed flush (loud, every interval) and retried next time.
32
+ *
33
+ * @module refusals/recorder
34
+ */
35
+ /** The principal an unattended platform write acts as. */
36
+ exports.PLATFORM_BOOTSTRAP_PRINCIPAL = 'platform-bootstrap';
37
+ let installed = null;
38
+ /** Make `recorder` the process's recorder. Returns the previous one, if any. */
39
+ const installRefusalRecorder = (recorder) => {
40
+ const previous = installed;
41
+ installed = recorder;
42
+ return previous;
43
+ };
44
+ exports.installRefusalRecorder = installRefusalRecorder;
45
+ const getRefusalRecorder = () => installed;
46
+ exports.getRefusalRecorder = getRefusalRecorder;
47
+ const getRefusalRecorderStats = () => installed?.stats() ?? null;
48
+ exports.getRefusalRecorderStats = getRefusalRecorderStats;
49
+ /** The route half of a refusal key — the same shape admission control keys on. */
50
+ const routeKeyOf = (req) => `${req.method} ${req.baseUrl ?? ''}${req.path ?? req.url ?? ''}`;
51
+ 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
+ /**
66
+ * Count one GraphQL-lane refusal. Synchronous; never throws; does nothing
67
+ * when no recorder is installed.
68
+ */
69
+ const recordRefusal = (req, reason, opts = {}) => {
70
+ const recorder = installed;
71
+ if (!recorder)
72
+ return;
73
+ const refusal = {
74
+ databaseId: opts.databaseId !== undefined ? opts.databaseId : req.databaseId ?? null,
75
+ lane: 'graphql',
76
+ reason,
77
+ routeKey: (0, exports.routeKeyOf)(req),
78
+ sourceIp: opts.sourceIp !== undefined
79
+ ? opts.sourceIp
80
+ : (0, express_context_1.clientIpFrom)(req, resolveHops(req, opts.trustedProxyHops))
81
+ };
82
+ try {
83
+ recorder.record(refusal);
84
+ }
85
+ catch (err) {
86
+ // The refusal response is already being written; a broken recorder is
87
+ // reported here and via the recorder's own stats, never to the client.
88
+ log.error(`refusal not recorded reason=${reason}: ${err instanceof Error ? err.message : String(err)}`);
89
+ }
90
+ };
91
+ exports.recordRefusal = recordRefusal;
92
+ /**
93
+ * Resolve the claims a flush runs under: the platform database and the
94
+ * `platform-bootstrap` service principal's user row. Throws — with the row
95
+ * that was missing named — rather than returning a partial identity.
96
+ */
97
+ const resolvePlatformFlushIdentity = async (pool, principalName = exports.PLATFORM_BOOTSTRAP_PRINCIPAL) => {
98
+ const database = await pool.query(`SELECT id FROM metaschema_public.database WHERE platform IS TRUE`);
99
+ if (database.rowCount !== 1) {
100
+ throw new Error(`refusals: expected exactly one platform database (metaschema_public.database.platform), found ${database.rowCount}`);
101
+ }
102
+ const principal = await pool.query(`SELECT user_id, bypass_step_up FROM constructive_auth_public.principals WHERE name = $1`, [principalName]);
103
+ if (principal.rowCount !== 1) {
104
+ throw new Error(`refusals: no service principal named '${principalName}'`);
105
+ }
106
+ if (principal.rows[0].bypass_step_up !== true) {
107
+ throw new Error(`refusals: principal '${principalName}' is not a service principal (no bypass_step_up)`);
108
+ }
109
+ return {
110
+ databaseId: database.rows[0].id,
111
+ actorId: principal.rows[0].user_id,
112
+ principalId: principal.rows[0].user_id
113
+ };
114
+ };
115
+ exports.resolvePlatformFlushIdentity = resolvePlatformFlushIdentity;
116
+ const platformFlushClaims = (identity) => ({
117
+ 'jwt.claims.database_id': identity.databaseId,
118
+ 'jwt.claims.user_id': identity.actorId,
119
+ 'jwt.claims.principal_id': identity.principalId,
120
+ 'jwt.claims.entity_id': identity.databaseId,
121
+ 'jwt.claims.entity_type': 'database',
122
+ 'jwt.claims.role_type': 'system'
123
+ });
124
+ exports.platformFlushClaims = platformFlushClaims;
125
+ /**
126
+ * The recorder the server runs: counts in memory, flushes into
127
+ * `constructive_usage_private.record_refusals` on the platform pool under the
128
+ * platform flush identity. Not started; the caller owns start/stop.
129
+ */
130
+ const createPlatformRefusalRecorder = (opts, recorderOpts = {}) => {
131
+ const pool = (0, pg_cache_1.getPgPool)(opts.pg);
132
+ let cached = null;
133
+ const claims = async () => {
134
+ if (cached)
135
+ return cached;
136
+ cached = (0, exports.platformFlushClaims)(await (0, exports.resolvePlatformFlushIdentity)(pool, recorderOpts.principalName));
137
+ log.info(`[refusals] flushing as '${recorderOpts.principalName ?? exports.PLATFORM_BOOTSTRAP_PRINCIPAL}'`);
138
+ return cached;
139
+ };
140
+ return new express_context_1.RefusalRecorder({
141
+ sink: (0, express_context_1.createRecordRefusalsSink)({ pool, claims }),
142
+ intervalMs: recorderOpts.intervalMs,
143
+ jitterMs: recorderOpts.jitterMs,
144
+ maxKeys: recorderOpts.maxKeys
145
+ });
146
+ };
147
+ exports.createPlatformRefusalRecorder = createPlatformRefusalRecorder;
package/server.d.ts CHANGED
@@ -33,6 +33,7 @@ declare class Server {
33
33
  private closed;
34
34
  private httpServer;
35
35
  private debugSampler;
36
+ private refusalRecorder;
36
37
  constructor(opts: ConstructiveOptions);
37
38
  listen(): HttpServer;
38
39
  flush(databaseId: string): Promise<void>;
package/server.js CHANGED
@@ -20,6 +20,7 @@ const agentic_1 = require("./agentic");
20
20
  const debug_db_snapshot_1 = require("./diagnostics/debug-db-snapshot");
21
21
  const debug_sampler_1 = require("./diagnostics/debug-sampler");
22
22
  const observability_1 = require("./diagnostics/observability");
23
+ const admission_control_1 = require("./middleware/admission-control");
23
24
  const api_1 = require("./middleware/api");
24
25
  const auth_1 = require("./middleware/auth");
25
26
  // Auth cookie handling is done via AuthCookiePlugin in grafserv
@@ -38,6 +39,7 @@ const guard_1 = require("./middleware/observability/guard");
38
39
  const request_logger_1 = require("./middleware/observability/request-logger");
39
40
  const request_protection_1 = require("./middleware/request-protection");
40
41
  const routing_1 = require("./middleware/routing");
42
+ const recorder_1 = require("./refusals/recorder");
41
43
  const log = new logger_1.Logger('server');
42
44
  /**
43
45
  * Creates and starts a GraphQL server instance
@@ -76,6 +78,7 @@ class Server {
76
78
  closed = false;
77
79
  httpServer = null;
78
80
  debugSampler = null;
81
+ refusalRecorder = null;
79
82
  constructor(opts) {
80
83
  this.opts = (0, graphql_env_1.getEnvOptions)(opts);
81
84
  const effectiveOpts = this.opts;
@@ -153,6 +156,13 @@ class Server {
153
156
  // Resolve the tenant's protection bounds before anything can spend budget
154
157
  // on the request (and before the GraphQL handler reads them for pgSettings).
155
158
  app.use((0, request_protection_1.createRequestProtectionMiddleware)());
159
+ // Spend the width and rate bounds the line above resolved, before a
160
+ // request can take a pool connection. Scoped to /graphql rather than
161
+ // mounted globally because a concurrency slot is held for as long as the
162
+ // handler runs: the SSE routes below are long-lived by design and would
163
+ // sit in the budget for the life of the stream. The other lanes need
164
+ // their own bound sized for streaming, not this one.
165
+ app.use('/graphql', (0, admission_control_1.createAdmissionControlMiddleware)());
156
166
  app.use((0, captcha_1.createCaptchaMiddleware)());
157
167
  // CSRF protection for cookie-authenticated requests
158
168
  // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
@@ -188,12 +198,17 @@ class Server {
188
198
  // REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id)
189
199
  app.use((0, fn_1.createFnRouter)());
190
200
  app.use((0, graphile_1.graphile)(effectiveOpts));
191
- app.use(flush_1.flush);
201
+ app.use((0, flush_1.createFlushMiddleware)(effectiveOpts));
192
202
  // Error handling - MUST be LAST
193
203
  app.use(error_handler_1.notFoundHandler); // Catches unmatched routes (404)
194
204
  app.use(error_handler_1.errorHandler); // Catches all thrown errors
195
205
  this.app = app;
196
206
  this.debugSampler = observabilityEnabled ? (0, debug_sampler_1.startDebugSampler)(effectiveOpts) : null;
207
+ // Refusals are counted in memory by the middleware above and flushed to
208
+ // the platform table on a timer; the request path never touches the pool.
209
+ this.refusalRecorder = (0, recorder_1.createPlatformRefusalRecorder)(effectiveOpts);
210
+ (0, recorder_1.installRefusalRecorder)(this.refusalRecorder);
211
+ this.refusalRecorder.start();
197
212
  }
198
213
  listen() {
199
214
  const { server } = this.opts;
@@ -298,6 +313,11 @@ class Server {
298
313
  this.closed = true;
299
314
  this.shuttingDown = true;
300
315
  await this.removeEventListener();
316
+ if (this.refusalRecorder) {
317
+ (0, recorder_1.installRefusalRecorder)(null);
318
+ await this.refusalRecorder.stop();
319
+ this.refusalRecorder = null;
320
+ }
301
321
  if (this.debugSampler) {
302
322
  await this.debugSampler.stop();
303
323
  this.debugSampler = null;