@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/README.md +3 -1
- package/diagnostics/debug-memory-snapshot.d.ts +3 -0
- package/diagnostics/debug-memory-snapshot.js +2 -0
- package/errors/graphql-response.d.ts +17 -3
- package/errors/graphql-response.js +7 -4
- package/esm/diagnostics/debug-memory-snapshot.js +2 -0
- package/esm/errors/graphql-response.js +7 -4
- package/esm/index.js +1 -1
- package/esm/middleware/admission-control.js +132 -0
- package/esm/middleware/api.js +8 -3
- package/esm/middleware/flush.js +37 -6
- package/esm/middleware/graphile.js +20 -3
- package/esm/middleware/request-protection.js +2 -0
- package/esm/middleware/routing.js +32 -3
- package/esm/plugins/request-protection-plugin.js +26 -1
- package/esm/refusals/recorder.js +136 -0
- package/esm/server.js +22 -2
- package/index.d.ts +1 -1
- package/index.js +2 -2
- package/middleware/admission-control.d.ts +20 -0
- package/middleware/admission-control.js +136 -0
- package/middleware/api.js +7 -2
- package/middleware/flush.d.ts +8 -2
- package/middleware/flush.js +39 -8
- package/middleware/graphile.js +20 -3
- package/middleware/request-protection.js +2 -0
- package/middleware/routing.d.ts +6 -0
- package/middleware/routing.js +34 -4
- package/middleware/types.d.ts +3 -0
- package/package.json +15 -15
- package/plugins/request-protection-plugin.d.ts +3 -0
- package/plugins/request-protection-plugin.js +28 -2
- package/refusals/recorder.d.ts +78 -0
- package/refusals/recorder.js +147 -0
- package/server.d.ts +1 -0
- package/server.js +21 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
47
|
-
"@constructive-io/express-context": "^0.
|
|
48
|
-
"@constructive-io/graphql-env": "^3.
|
|
49
|
-
"@constructive-io/graphql-types": "^3.
|
|
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.
|
|
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.
|
|
55
|
+
"@pgpmjs/env": "^2.43.4",
|
|
56
56
|
"@pgpmjs/logger": "^2.25.1",
|
|
57
|
-
"@pgpmjs/server-utils": "^3.27.
|
|
58
|
-
"@pgpmjs/types": "^2.53.
|
|
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.
|
|
67
|
+
"graphile-cache": "^4.12.4",
|
|
68
68
|
"graphile-config": "1.1.0",
|
|
69
|
-
"graphile-function-bindings": "^1.14.
|
|
70
|
-
"graphile-settings": "^6.21.
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
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
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.
|
|
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;
|