@constructive-io/graphql-server 5.25.1 → 5.26.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/esm/middleware/graphile-introspection.js +65 -0
- package/esm/middleware/graphile.js +29 -29
- package/esm/protection/document-gate.js +35 -16
- package/middleware/graphile-introspection.d.ts +23 -0
- package/middleware/graphile-introspection.js +70 -0
- package/middleware/graphile.js +28 -28
- package/middleware/types.d.ts +2 -14
- package/package.json +17 -16
- package/protection/document-gate.d.ts +17 -6
- package/protection/document-gate.js +36 -16
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { resolvePreset } from 'graphile-config';
|
|
2
|
+
import { makePgService } from 'graphile-settings';
|
|
3
|
+
const SCOPED_INTROSPECTION_PLUGIN = 'ConstructivePgIntrospectionPlugin';
|
|
4
|
+
let scopedIntrospectionPresetPromise;
|
|
5
|
+
const loadScopedIntrospectionPreset = () => {
|
|
6
|
+
scopedIntrospectionPresetPromise ??=
|
|
7
|
+
import('graphile-scoped-introspection').then(({ ScopedIntrospectionPreset }) => ScopedIntrospectionPreset);
|
|
8
|
+
return scopedIntrospectionPresetPromise;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Resolve caller-provided Graphile configuration before deciding whether the
|
|
12
|
+
* optional scoped introspection package is needed. Graphile config applies
|
|
13
|
+
* nested `extends` entries before the containing preset, so this preserves the
|
|
14
|
+
* same precedence used by the eventual schema build.
|
|
15
|
+
*/
|
|
16
|
+
export const resolveCallerPreset = (graphileOptions) => {
|
|
17
|
+
const preset = graphileOptions?.preset;
|
|
18
|
+
const presets = [
|
|
19
|
+
...(graphileOptions?.extends ?? []),
|
|
20
|
+
...(preset ? [preset] : [])
|
|
21
|
+
];
|
|
22
|
+
return presets.length > 0 ? resolvePreset({ extends: presets }) : undefined;
|
|
23
|
+
};
|
|
24
|
+
const hasLocalScopedIntrospectionPreset = (preset) => preset?.plugins.some((plugin) => plugin.name === SCOPED_INTROSPECTION_PLUGIN) ??
|
|
25
|
+
false;
|
|
26
|
+
const hasUpstreamIntrospectionDisabled = (preset) => preset?.disablePlugins.includes('PgIntrospectionPlugin') ?? false;
|
|
27
|
+
/**
|
|
28
|
+
* Select the stock or scoped introspection wiring once, while constructing a
|
|
29
|
+
* server-owned schema handler. The stock branch returns before the scoped
|
|
30
|
+
* package (and its upstream contract sentinel) is loaded.
|
|
31
|
+
*/
|
|
32
|
+
export const makeIntrospectionWiring = async (pool, schemas, graphileOptions, loadScopedPreset = loadScopedIntrospectionPreset, introspectionRole) => {
|
|
33
|
+
const pgSettingsForIntrospection = introspectionRole ? { role: introspectionRole } : undefined;
|
|
34
|
+
const pgService = makePgService({ pool, schemas, pgSettingsForIntrospection });
|
|
35
|
+
const callerPreset = resolveCallerPreset(graphileOptions);
|
|
36
|
+
const scopedConfig = callerPreset?.gather?.pgScopedIntrospection;
|
|
37
|
+
const hasScopedConfiguration = scopedConfig !== undefined;
|
|
38
|
+
// An explicitly supplied local preset already installs the replacement
|
|
39
|
+
// plugin. Keep that path synchronous and avoid importing the optional
|
|
40
|
+
// package a second time. A raw plugin entry may omit the stock plugin's
|
|
41
|
+
// disablement, so add that narrow preset to keep the replacement atomic.
|
|
42
|
+
if (hasLocalScopedIntrospectionPreset(callerPreset)) {
|
|
43
|
+
return {
|
|
44
|
+
presets: hasUpstreamIntrospectionDisabled(callerPreset)
|
|
45
|
+
? []
|
|
46
|
+
: [{ disablePlugins: ['PgIntrospectionPlugin'] }],
|
|
47
|
+
pgService
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (!hasScopedConfiguration) {
|
|
51
|
+
return {
|
|
52
|
+
presets: [],
|
|
53
|
+
pgService
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Load the replacement whenever the map is supplied, including an empty map,
|
|
57
|
+
// entries set to false, or entries keyed by an unknown service. The
|
|
58
|
+
// replacement owns validation; leaving it out would cause Graphile's stock
|
|
59
|
+
// plugin to silently ignore the gather option in those cases.
|
|
60
|
+
const scopedPreset = await loadScopedPreset();
|
|
61
|
+
return {
|
|
62
|
+
presets: [scopedPreset],
|
|
63
|
+
pgService
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -5,7 +5,7 @@ import { getNodeEnv } from '@pgpmjs/env';
|
|
|
5
5
|
import { Logger } from '@pgpmjs/logger';
|
|
6
6
|
import { createGraphileInstance, graphileCache } from 'graphile-cache';
|
|
7
7
|
import { createFunctionBindingsPlugin } from 'graphile-function-bindings';
|
|
8
|
-
import { createConstructivePreset
|
|
8
|
+
import { createConstructivePreset } from 'graphile-settings';
|
|
9
9
|
import { getPgPool } from 'pg-cache';
|
|
10
10
|
import { getPgEnvOptions } from 'pg-env';
|
|
11
11
|
import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
|
|
@@ -14,6 +14,7 @@ import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
|
14
14
|
import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
|
|
15
15
|
import { createErrorEventsPlugin } from '../plugins/error-events-plugin';
|
|
16
16
|
import { RequestProtectionPlugin } from '../plugins/request-protection-plugin';
|
|
17
|
+
import { makeIntrospectionWiring } from './graphile-introspection';
|
|
17
18
|
import { maskError } from './mask-error';
|
|
18
19
|
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
19
20
|
const isDev = () => getNodeEnv() === 'development';
|
|
@@ -56,10 +57,19 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
56
57
|
* plugin preset. Without settings the default preset is used
|
|
57
58
|
* (everything on except aggregates).
|
|
58
59
|
*/
|
|
59
|
-
const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
|
|
60
|
+
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute) => {
|
|
61
|
+
const introspection = await makeIntrospectionWiring(pool, schemas, graphileOptions, undefined, introspectionRole);
|
|
62
|
+
const configuredPreset = graphileOptions?.preset ?? {};
|
|
60
63
|
return {
|
|
61
|
-
|
|
64
|
+
...configuredPreset,
|
|
65
|
+
extends: [
|
|
66
|
+
createConstructivePreset(databaseSettings),
|
|
67
|
+
...(graphileOptions?.extends ?? []),
|
|
68
|
+
...(configuredPreset.extends ?? []),
|
|
69
|
+
...introspection.presets
|
|
70
|
+
],
|
|
62
71
|
plugins: [
|
|
72
|
+
...(configuredPreset.plugins ?? []),
|
|
63
73
|
AuthCookiePlugin,
|
|
64
74
|
RequestProtectionPlugin,
|
|
65
75
|
createErrorEventsPlugin(pool),
|
|
@@ -83,20 +93,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, datab
|
|
|
83
93
|
]
|
|
84
94
|
: [])
|
|
85
95
|
],
|
|
86
|
-
pgServices: [
|
|
87
|
-
makePgService({
|
|
88
|
-
pool,
|
|
89
|
-
schemas,
|
|
90
|
-
// Introspection runs outside any request, so it has no served role to
|
|
91
|
-
// inherit: unset, it reads the catalog as whatever role the pool
|
|
92
|
-
// connected as (a superuser in most deployments) and the schema
|
|
93
|
-
// advertises that role's reach. Naming the role keeps schema shape
|
|
94
|
-
// tied to a bounded role's grants.
|
|
95
|
-
...(introspectionRole && {
|
|
96
|
-
pgSettingsForIntrospection: { role: introspectionRole }
|
|
97
|
-
})
|
|
98
|
-
})
|
|
99
|
-
],
|
|
96
|
+
pgServices: [introspection.pgService],
|
|
100
97
|
grafserv: {
|
|
101
98
|
graphqlPath: '/graphql',
|
|
102
99
|
graphiqlPath: '/graphiql',
|
|
@@ -304,18 +301,21 @@ export const graphile = (opts) => {
|
|
|
304
301
|
// Route through pg-cache so the pool is tracked and can be cleaned up
|
|
305
302
|
// properly, preventing leaked connections during database teardown.
|
|
306
303
|
const pool = getPgPool(pgConfig);
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
304
|
+
// Register the creation promise before any asynchronous preset work so
|
|
305
|
+
// concurrent requests coalesce while optional plugin modules load.
|
|
306
|
+
const creationPromise = (async () => {
|
|
307
|
+
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
308
|
+
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute);
|
|
309
|
+
return observeGraphileBuild({
|
|
310
|
+
cacheKey: key,
|
|
311
|
+
serviceKey: key,
|
|
312
|
+
databaseId: api.databaseId ?? null
|
|
313
|
+
}, () => createGraphileInstance({
|
|
314
|
+
preset,
|
|
315
|
+
cacheKey: key,
|
|
316
|
+
enableRealtime: api.databaseSettings?.enableRealtime
|
|
317
|
+
}), { enabled: observabilityEnabled });
|
|
318
|
+
})();
|
|
319
319
|
creating.set(key, creationPromise);
|
|
320
320
|
try {
|
|
321
321
|
const instance = await creationPromise;
|
|
@@ -17,12 +17,15 @@
|
|
|
17
17
|
* have to be followed (a document can hide its depth entirely inside
|
|
18
18
|
* fragments) and the visitor does not follow them.
|
|
19
19
|
*
|
|
20
|
-
* Schema introspection (`__schema` / `__type`) is
|
|
21
|
-
* `enableIntrospection
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* Schema introspection (`__schema` / `__type`) is switched by
|
|
21
|
+
* `enableIntrospection` and bounded by its own fixed depth ceiling rather than
|
|
22
|
+
* the tenant's `maxQueryDepth`. The standard introspection document nests a
|
|
23
|
+
* fixed `ofType` chain deeper than a sensible tenant depth budget, so charging
|
|
24
|
+
* it against that budget would let the budget decide whether clients can
|
|
25
|
+
* introspect at all. It still needs a bound of its own, though: the
|
|
26
|
+
* introspection schema is recursive (`__Type.fields → __Field.type → __Type`),
|
|
27
|
+
* so an unbounded walk would let a client nest it arbitrarily. Introspection
|
|
28
|
+
* types carry no connections, so cost is naturally zero.
|
|
26
29
|
*/
|
|
27
30
|
import { errors } from '@constructive-io/errors';
|
|
28
31
|
import { ASSUMED_PAGE_SIZE } from '@constructive-io/express-context';
|
|
@@ -43,6 +46,14 @@ const reject = (error) => {
|
|
|
43
46
|
statusCode: error.http
|
|
44
47
|
});
|
|
45
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* Depth allowed inside a `__schema` / `__type` subtree, counted from the
|
|
51
|
+
* introspection field itself. graphql-js's `getIntrospectionQuery` with every
|
|
52
|
+
* option enabled reaches 13 (`types → fields → args → type → ofType×9`), so
|
|
53
|
+
* this leaves headroom for tooling without permitting the recursive
|
|
54
|
+
* `fields { type { fields { type … } } }` shape to run away.
|
|
55
|
+
*/
|
|
56
|
+
export const INTROSPECTION_MAX_DEPTH = 16;
|
|
46
57
|
/** Arguments a connection field uses to size its page. */
|
|
47
58
|
const PAGE_SIZE_ARGS = ['first', 'last'];
|
|
48
59
|
/** A connection is any object type that carries Relay's `pageInfo`. */
|
|
@@ -78,20 +89,28 @@ const requestedPageSize = (args, variableValues) => {
|
|
|
78
89
|
* the walk only stops charging cost rather than raising its own error)
|
|
79
90
|
* @param depth - nesting level of these selections
|
|
80
91
|
* @param multiplier - rows the enclosing connections can return
|
|
92
|
+
* @param bound - which depth limit applies to these selections
|
|
81
93
|
*/
|
|
82
|
-
function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
83
|
-
if (
|
|
84
|
-
walk.maxDepth
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier, bound) {
|
|
95
|
+
if (bound.kind === 'query') {
|
|
96
|
+
if (depth > walk.maxDepth)
|
|
97
|
+
walk.maxDepth = depth;
|
|
98
|
+
if (depth > walk.protection.maxQueryDepth) {
|
|
99
|
+
reject(errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (depth - bound.base > INTROSPECTION_MAX_DEPTH) {
|
|
103
|
+
reject(errors.QUERY_TOO_DEEP({ depth: depth - bound.base, limit: INTROSPECTION_MAX_DEPTH }));
|
|
87
104
|
}
|
|
88
105
|
for (const selection of selectionSet.selections) {
|
|
89
106
|
if (selection.kind === Kind.FIELD) {
|
|
107
|
+
let childBound = bound;
|
|
90
108
|
if (selection.name.value === '__schema' || selection.name.value === '__type') {
|
|
91
109
|
if (!walk.protection.enableIntrospection) {
|
|
92
110
|
reject(errors.INTROSPECTION_DISABLED());
|
|
93
111
|
}
|
|
94
|
-
|
|
112
|
+
if (bound.kind === 'query')
|
|
113
|
+
childBound = { kind: 'introspection', base: depth };
|
|
95
114
|
}
|
|
96
115
|
const field = parentType && isObjectType(parentType)
|
|
97
116
|
? parentType.getFields()[selection.name.value]
|
|
@@ -118,7 +137,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
if (selection.selectionSet) {
|
|
121
|
-
walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier);
|
|
140
|
+
walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier, childBound);
|
|
122
141
|
}
|
|
123
142
|
continue;
|
|
124
143
|
}
|
|
@@ -127,7 +146,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
|
127
146
|
? typeFromAST(walk.schema, selection.typeCondition)
|
|
128
147
|
: parentType;
|
|
129
148
|
// An inline fragment is not a level of nesting of its own.
|
|
130
|
-
walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier);
|
|
149
|
+
walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier, bound);
|
|
131
150
|
continue;
|
|
132
151
|
}
|
|
133
152
|
if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
@@ -143,7 +162,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
|
143
162
|
const onType = typeFromAST(walk.schema, fragment.typeCondition);
|
|
144
163
|
walk.activeFragments.add(name);
|
|
145
164
|
try {
|
|
146
|
-
walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier);
|
|
165
|
+
walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier, bound);
|
|
147
166
|
}
|
|
148
167
|
finally {
|
|
149
168
|
walk.activeFragments.delete(name);
|
|
@@ -185,6 +204,6 @@ export function enforceDocumentProtection(schema, document, variableValues, prot
|
|
|
185
204
|
maxDepth: 0,
|
|
186
205
|
cost: 0
|
|
187
206
|
};
|
|
188
|
-
walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1);
|
|
207
|
+
walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1, { kind: 'query' });
|
|
189
208
|
return { depth: walk.maxDepth, cost: walk.cost };
|
|
190
209
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { GraphileOptions } from '@constructive-io/graphql-types';
|
|
2
|
+
import { type GraphileConfig } from 'graphile-config';
|
|
3
|
+
import type { Pool } from 'pg';
|
|
4
|
+
export interface IntrospectionWiring {
|
|
5
|
+
presets: GraphileConfig.Preset[];
|
|
6
|
+
pgService: GraphileConfig.PgServiceConfiguration;
|
|
7
|
+
}
|
|
8
|
+
export type ScopedIntrospectionPresetLoader = () => Promise<GraphileConfig.Preset>;
|
|
9
|
+
type ResolvedCallerPreset = GraphileConfig.ResolvedPreset | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve caller-provided Graphile configuration before deciding whether the
|
|
12
|
+
* optional scoped introspection package is needed. Graphile config applies
|
|
13
|
+
* nested `extends` entries before the containing preset, so this preserves the
|
|
14
|
+
* same precedence used by the eventual schema build.
|
|
15
|
+
*/
|
|
16
|
+
export declare const resolveCallerPreset: (graphileOptions: GraphileOptions | undefined) => ResolvedCallerPreset;
|
|
17
|
+
/**
|
|
18
|
+
* Select the stock or scoped introspection wiring once, while constructing a
|
|
19
|
+
* server-owned schema handler. The stock branch returns before the scoped
|
|
20
|
+
* package (and its upstream contract sentinel) is loaded.
|
|
21
|
+
*/
|
|
22
|
+
export declare const makeIntrospectionWiring: (pool: Pool, schemas: string[], graphileOptions: GraphileOptions | undefined, loadScopedPreset?: ScopedIntrospectionPresetLoader, introspectionRole?: string) => Promise<IntrospectionWiring>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.makeIntrospectionWiring = exports.resolveCallerPreset = void 0;
|
|
4
|
+
const graphile_config_1 = require("graphile-config");
|
|
5
|
+
const graphile_settings_1 = require("graphile-settings");
|
|
6
|
+
const SCOPED_INTROSPECTION_PLUGIN = 'ConstructivePgIntrospectionPlugin';
|
|
7
|
+
let scopedIntrospectionPresetPromise;
|
|
8
|
+
const loadScopedIntrospectionPreset = () => {
|
|
9
|
+
scopedIntrospectionPresetPromise ??=
|
|
10
|
+
import('graphile-scoped-introspection').then(({ ScopedIntrospectionPreset }) => ScopedIntrospectionPreset);
|
|
11
|
+
return scopedIntrospectionPresetPromise;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Resolve caller-provided Graphile configuration before deciding whether the
|
|
15
|
+
* optional scoped introspection package is needed. Graphile config applies
|
|
16
|
+
* nested `extends` entries before the containing preset, so this preserves the
|
|
17
|
+
* same precedence used by the eventual schema build.
|
|
18
|
+
*/
|
|
19
|
+
const resolveCallerPreset = (graphileOptions) => {
|
|
20
|
+
const preset = graphileOptions?.preset;
|
|
21
|
+
const presets = [
|
|
22
|
+
...(graphileOptions?.extends ?? []),
|
|
23
|
+
...(preset ? [preset] : [])
|
|
24
|
+
];
|
|
25
|
+
return presets.length > 0 ? (0, graphile_config_1.resolvePreset)({ extends: presets }) : undefined;
|
|
26
|
+
};
|
|
27
|
+
exports.resolveCallerPreset = resolveCallerPreset;
|
|
28
|
+
const hasLocalScopedIntrospectionPreset = (preset) => preset?.plugins.some((plugin) => plugin.name === SCOPED_INTROSPECTION_PLUGIN) ??
|
|
29
|
+
false;
|
|
30
|
+
const hasUpstreamIntrospectionDisabled = (preset) => preset?.disablePlugins.includes('PgIntrospectionPlugin') ?? false;
|
|
31
|
+
/**
|
|
32
|
+
* Select the stock or scoped introspection wiring once, while constructing a
|
|
33
|
+
* server-owned schema handler. The stock branch returns before the scoped
|
|
34
|
+
* package (and its upstream contract sentinel) is loaded.
|
|
35
|
+
*/
|
|
36
|
+
const makeIntrospectionWiring = async (pool, schemas, graphileOptions, loadScopedPreset = loadScopedIntrospectionPreset, introspectionRole) => {
|
|
37
|
+
const pgSettingsForIntrospection = introspectionRole ? { role: introspectionRole } : undefined;
|
|
38
|
+
const pgService = (0, graphile_settings_1.makePgService)({ pool, schemas, pgSettingsForIntrospection });
|
|
39
|
+
const callerPreset = (0, exports.resolveCallerPreset)(graphileOptions);
|
|
40
|
+
const scopedConfig = callerPreset?.gather?.pgScopedIntrospection;
|
|
41
|
+
const hasScopedConfiguration = scopedConfig !== undefined;
|
|
42
|
+
// An explicitly supplied local preset already installs the replacement
|
|
43
|
+
// plugin. Keep that path synchronous and avoid importing the optional
|
|
44
|
+
// package a second time. A raw plugin entry may omit the stock plugin's
|
|
45
|
+
// disablement, so add that narrow preset to keep the replacement atomic.
|
|
46
|
+
if (hasLocalScopedIntrospectionPreset(callerPreset)) {
|
|
47
|
+
return {
|
|
48
|
+
presets: hasUpstreamIntrospectionDisabled(callerPreset)
|
|
49
|
+
? []
|
|
50
|
+
: [{ disablePlugins: ['PgIntrospectionPlugin'] }],
|
|
51
|
+
pgService
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (!hasScopedConfiguration) {
|
|
55
|
+
return {
|
|
56
|
+
presets: [],
|
|
57
|
+
pgService
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Load the replacement whenever the map is supplied, including an empty map,
|
|
61
|
+
// entries set to false, or entries keyed by an unknown service. The
|
|
62
|
+
// replacement owns validation; leaving it out would cause Graphile's stock
|
|
63
|
+
// plugin to silently ignore the gather option in those cases.
|
|
64
|
+
const scopedPreset = await loadScopedPreset();
|
|
65
|
+
return {
|
|
66
|
+
presets: [scopedPreset],
|
|
67
|
+
pgService
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
exports.makeIntrospectionWiring = makeIntrospectionWiring;
|
package/middleware/graphile.js
CHANGED
|
@@ -20,6 +20,7 @@ const graphql_response_1 = require("../errors/graphql-response");
|
|
|
20
20
|
const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
|
|
21
21
|
const error_events_plugin_1 = require("../plugins/error-events-plugin");
|
|
22
22
|
const request_protection_plugin_1 = require("../plugins/request-protection-plugin");
|
|
23
|
+
const graphile_introspection_1 = require("./graphile-introspection");
|
|
23
24
|
const mask_error_1 = require("./mask-error");
|
|
24
25
|
const graphile_build_stats_1 = require("./observability/graphile-build-stats");
|
|
25
26
|
const isDev = () => (0, env_1.getNodeEnv)() === 'development';
|
|
@@ -62,10 +63,19 @@ const reqLabel = (req) => (req.requestId ? `[${req.requestId}]` : '[req]');
|
|
|
62
63
|
* plugin preset. Without settings the default preset is used
|
|
63
64
|
* (everything on except aggregates).
|
|
64
65
|
*/
|
|
65
|
-
const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, databaseSettings, apiId, compute) => {
|
|
66
|
+
const buildPreset = async (pool, schemas, anonRole, roleName, introspectionRole, graphileOptions, databaseSettings, apiId, compute) => {
|
|
67
|
+
const introspection = await (0, graphile_introspection_1.makeIntrospectionWiring)(pool, schemas, graphileOptions, undefined, introspectionRole);
|
|
68
|
+
const configuredPreset = graphileOptions?.preset ?? {};
|
|
66
69
|
return {
|
|
67
|
-
|
|
70
|
+
...configuredPreset,
|
|
71
|
+
extends: [
|
|
72
|
+
(0, graphile_settings_1.createConstructivePreset)(databaseSettings),
|
|
73
|
+
...(graphileOptions?.extends ?? []),
|
|
74
|
+
...(configuredPreset.extends ?? []),
|
|
75
|
+
...introspection.presets
|
|
76
|
+
],
|
|
68
77
|
plugins: [
|
|
78
|
+
...(configuredPreset.plugins ?? []),
|
|
69
79
|
auth_cookie_plugin_1.AuthCookiePlugin,
|
|
70
80
|
request_protection_plugin_1.RequestProtectionPlugin,
|
|
71
81
|
(0, error_events_plugin_1.createErrorEventsPlugin)(pool),
|
|
@@ -89,20 +99,7 @@ const buildPreset = (pool, schemas, anonRole, roleName, introspectionRole, datab
|
|
|
89
99
|
]
|
|
90
100
|
: [])
|
|
91
101
|
],
|
|
92
|
-
pgServices: [
|
|
93
|
-
(0, graphile_settings_1.makePgService)({
|
|
94
|
-
pool,
|
|
95
|
-
schemas,
|
|
96
|
-
// Introspection runs outside any request, so it has no served role to
|
|
97
|
-
// inherit: unset, it reads the catalog as whatever role the pool
|
|
98
|
-
// connected as (a superuser in most deployments) and the schema
|
|
99
|
-
// advertises that role's reach. Naming the role keeps schema shape
|
|
100
|
-
// tied to a bounded role's grants.
|
|
101
|
-
...(introspectionRole && {
|
|
102
|
-
pgSettingsForIntrospection: { role: introspectionRole }
|
|
103
|
-
})
|
|
104
|
-
})
|
|
105
|
-
],
|
|
102
|
+
pgServices: [introspection.pgService],
|
|
106
103
|
grafserv: {
|
|
107
104
|
graphqlPath: '/graphql',
|
|
108
105
|
graphiqlPath: '/graphiql',
|
|
@@ -310,18 +307,21 @@ const graphile = (opts) => {
|
|
|
310
307
|
// Route through pg-cache so the pool is tracked and can be cleaned up
|
|
311
308
|
// properly, preventing leaked connections during database teardown.
|
|
312
309
|
const pool = (0, pg_cache_1.getPgPool)(pgConfig);
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
310
|
+
// Register the creation promise before any asynchronous preset work so
|
|
311
|
+
// concurrent requests coalesce while optional plugin modules load.
|
|
312
|
+
const creationPromise = (async () => {
|
|
313
|
+
const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
|
|
314
|
+
const preset = await buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, opts.graphile, api.databaseSettings, api.apiId, compute);
|
|
315
|
+
return (0, graphile_build_stats_1.observeGraphileBuild)({
|
|
316
|
+
cacheKey: key,
|
|
317
|
+
serviceKey: key,
|
|
318
|
+
databaseId: api.databaseId ?? null
|
|
319
|
+
}, () => (0, graphile_cache_1.createGraphileInstance)({
|
|
320
|
+
preset,
|
|
321
|
+
cacheKey: key,
|
|
322
|
+
enableRealtime: api.databaseSettings?.enableRealtime
|
|
323
|
+
}), { enabled: observabilityEnabled });
|
|
324
|
+
})();
|
|
325
325
|
creating.set(key, creationPromise);
|
|
326
326
|
try {
|
|
327
327
|
const instance = await creationPromise;
|
package/middleware/types.d.ts
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import type { RequestProtection } from '@constructive-io/express-context';
|
|
2
|
-
|
|
3
|
-
export type ConstructiveAPIToken = {
|
|
4
|
-
id?: string;
|
|
5
|
-
user_id?: string;
|
|
6
|
-
principal_id?: string;
|
|
7
|
-
session_id?: string;
|
|
8
|
-
access_level?: string;
|
|
9
|
-
kind?: string;
|
|
10
|
-
root_session_id?: string;
|
|
11
|
-
parent_session_id?: string;
|
|
12
|
-
intent?: string;
|
|
13
|
-
[key: string]: unknown;
|
|
14
|
-
};
|
|
1
|
+
import type { ApiStructure, ConstructiveAPIToken, RequestProtection } from '@constructive-io/express-context';
|
|
2
|
+
export type { ConstructiveAPIToken } from '@constructive-io/express-context';
|
|
15
3
|
declare global {
|
|
16
4
|
namespace Express {
|
|
17
5
|
interface Request {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.26.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.17.0",
|
|
47
|
+
"@constructive-io/express-context": "^0.32.0",
|
|
48
|
+
"@constructive-io/graphql-env": "^3.33.0",
|
|
49
|
+
"@constructive-io/graphql-types": "^3.32.0",
|
|
50
50
|
"@constructive-io/llm-env": "^0.14.1",
|
|
51
|
-
"@constructive-io/query-builder": "^3.
|
|
52
|
-
"@constructive-io/s3-utils": "^2.33.
|
|
51
|
+
"@constructive-io/query-builder": "^3.14.0",
|
|
52
|
+
"@constructive-io/s3-utils": "^2.33.1",
|
|
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.
|
|
55
|
+
"@pgpmjs/env": "^2.44.0",
|
|
56
56
|
"@pgpmjs/logger": "^2.25.1",
|
|
57
|
-
"@pgpmjs/server-utils": "^3.
|
|
58
|
-
"@pgpmjs/types": "^2.
|
|
57
|
+
"@pgpmjs/server-utils": "^3.28.0",
|
|
58
|
+
"@pgpmjs/types": "^2.54.0",
|
|
59
59
|
"cors": "^2.8.6",
|
|
60
60
|
"deepmerge": "^4.3.1",
|
|
61
61
|
"express": "^5.2.1",
|
|
@@ -64,16 +64,17 @@
|
|
|
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.
|
|
67
|
+
"graphile-cache": "^4.13.0",
|
|
68
68
|
"graphile-config": "1.1.0",
|
|
69
|
-
"graphile-function-bindings": "^1.
|
|
70
|
-
"graphile-
|
|
69
|
+
"graphile-function-bindings": "^1.15.0",
|
|
70
|
+
"graphile-scoped-introspection": "^0.2.0",
|
|
71
|
+
"graphile-settings": "^6.22.0",
|
|
71
72
|
"graphile-utils": "5.0.3",
|
|
72
73
|
"graphql": "16.13.0",
|
|
73
74
|
"graphql-upload": "^13.0.0",
|
|
74
75
|
"lru-cache": "^11.2.7",
|
|
75
76
|
"pg": "^8.21.0",
|
|
76
|
-
"pg-cache": "^3.
|
|
77
|
+
"pg-cache": "^3.28.0",
|
|
77
78
|
"pg-env": "^1.31.1",
|
|
78
79
|
"pg-query-context": "^2.30.1",
|
|
79
80
|
"pg-sql2": "5.0.1",
|
|
@@ -90,11 +91,11 @@
|
|
|
90
91
|
"@types/request-ip": "^0.0.41",
|
|
91
92
|
"@types/supertest": "^7.2.1",
|
|
92
93
|
"cookie-parser": "^1.4.7",
|
|
93
|
-
"graphile-test": "5.
|
|
94
|
+
"graphile-test": "5.15.0",
|
|
94
95
|
"makage": "^0.8.0",
|
|
95
96
|
"nodemon": "^3.1.14",
|
|
96
97
|
"supertest": "^7.2.2",
|
|
97
98
|
"ts-node": "^10.9.2"
|
|
98
99
|
},
|
|
99
|
-
"gitHead": "
|
|
100
|
+
"gitHead": "399437801c27b070312125f3f0ee821ce16e183e"
|
|
100
101
|
}
|
|
@@ -17,15 +17,26 @@
|
|
|
17
17
|
* have to be followed (a document can hide its depth entirely inside
|
|
18
18
|
* fragments) and the visitor does not follow them.
|
|
19
19
|
*
|
|
20
|
-
* Schema introspection (`__schema` / `__type`) is
|
|
21
|
-
* `enableIntrospection
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* Schema introspection (`__schema` / `__type`) is switched by
|
|
21
|
+
* `enableIntrospection` and bounded by its own fixed depth ceiling rather than
|
|
22
|
+
* the tenant's `maxQueryDepth`. The standard introspection document nests a
|
|
23
|
+
* fixed `ofType` chain deeper than a sensible tenant depth budget, so charging
|
|
24
|
+
* it against that budget would let the budget decide whether clients can
|
|
25
|
+
* introspect at all. It still needs a bound of its own, though: the
|
|
26
|
+
* introspection schema is recursive (`__Type.fields → __Field.type → __Type`),
|
|
27
|
+
* so an unbounded walk would let a client nest it arbitrarily. Introspection
|
|
28
|
+
* types carry no connections, so cost is naturally zero.
|
|
26
29
|
*/
|
|
27
30
|
import { type RequestProtection } from '@constructive-io/express-context';
|
|
28
31
|
import type { DocumentNode, GraphQLSchema } from 'graphql';
|
|
32
|
+
/**
|
|
33
|
+
* Depth allowed inside a `__schema` / `__type` subtree, counted from the
|
|
34
|
+
* introspection field itself. graphql-js's `getIntrospectionQuery` with every
|
|
35
|
+
* option enabled reaches 13 (`types → fields → args → type → ofType×9`), so
|
|
36
|
+
* this leaves headroom for tooling without permitting the recursive
|
|
37
|
+
* `fields { type { fields { type … } } }` shape to run away.
|
|
38
|
+
*/
|
|
39
|
+
export declare const INTROSPECTION_MAX_DEPTH = 16;
|
|
29
40
|
export interface DocumentAnalysis {
|
|
30
41
|
/** Deepest field nesting in the operation. */
|
|
31
42
|
depth: number;
|
|
@@ -18,14 +18,18 @@
|
|
|
18
18
|
* have to be followed (a document can hide its depth entirely inside
|
|
19
19
|
* fragments) and the visitor does not follow them.
|
|
20
20
|
*
|
|
21
|
-
* Schema introspection (`__schema` / `__type`) is
|
|
22
|
-
* `enableIntrospection
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
21
|
+
* Schema introspection (`__schema` / `__type`) is switched by
|
|
22
|
+
* `enableIntrospection` and bounded by its own fixed depth ceiling rather than
|
|
23
|
+
* the tenant's `maxQueryDepth`. The standard introspection document nests a
|
|
24
|
+
* fixed `ofType` chain deeper than a sensible tenant depth budget, so charging
|
|
25
|
+
* it against that budget would let the budget decide whether clients can
|
|
26
|
+
* introspect at all. It still needs a bound of its own, though: the
|
|
27
|
+
* introspection schema is recursive (`__Type.fields → __Field.type → __Type`),
|
|
28
|
+
* so an unbounded walk would let a client nest it arbitrarily. Introspection
|
|
29
|
+
* types carry no connections, so cost is naturally zero.
|
|
27
30
|
*/
|
|
28
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.INTROSPECTION_MAX_DEPTH = void 0;
|
|
29
33
|
exports.enforceDocumentProtection = enforceDocumentProtection;
|
|
30
34
|
const errors_1 = require("@constructive-io/errors");
|
|
31
35
|
const express_context_1 = require("@constructive-io/express-context");
|
|
@@ -46,6 +50,14 @@ const reject = (error) => {
|
|
|
46
50
|
statusCode: error.http
|
|
47
51
|
});
|
|
48
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* Depth allowed inside a `__schema` / `__type` subtree, counted from the
|
|
55
|
+
* introspection field itself. graphql-js's `getIntrospectionQuery` with every
|
|
56
|
+
* option enabled reaches 13 (`types → fields → args → type → ofType×9`), so
|
|
57
|
+
* this leaves headroom for tooling without permitting the recursive
|
|
58
|
+
* `fields { type { fields { type … } } }` shape to run away.
|
|
59
|
+
*/
|
|
60
|
+
exports.INTROSPECTION_MAX_DEPTH = 16;
|
|
49
61
|
/** Arguments a connection field uses to size its page. */
|
|
50
62
|
const PAGE_SIZE_ARGS = ['first', 'last'];
|
|
51
63
|
/** A connection is any object type that carries Relay's `pageInfo`. */
|
|
@@ -81,20 +93,28 @@ const requestedPageSize = (args, variableValues) => {
|
|
|
81
93
|
* the walk only stops charging cost rather than raising its own error)
|
|
82
94
|
* @param depth - nesting level of these selections
|
|
83
95
|
* @param multiplier - rows the enclosing connections can return
|
|
96
|
+
* @param bound - which depth limit applies to these selections
|
|
84
97
|
*/
|
|
85
|
-
function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
86
|
-
if (
|
|
87
|
-
walk.maxDepth
|
|
88
|
-
|
|
89
|
-
|
|
98
|
+
function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier, bound) {
|
|
99
|
+
if (bound.kind === 'query') {
|
|
100
|
+
if (depth > walk.maxDepth)
|
|
101
|
+
walk.maxDepth = depth;
|
|
102
|
+
if (depth > walk.protection.maxQueryDepth) {
|
|
103
|
+
reject(errors_1.errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (depth - bound.base > exports.INTROSPECTION_MAX_DEPTH) {
|
|
107
|
+
reject(errors_1.errors.QUERY_TOO_DEEP({ depth: depth - bound.base, limit: exports.INTROSPECTION_MAX_DEPTH }));
|
|
90
108
|
}
|
|
91
109
|
for (const selection of selectionSet.selections) {
|
|
92
110
|
if (selection.kind === graphql_1.Kind.FIELD) {
|
|
111
|
+
let childBound = bound;
|
|
93
112
|
if (selection.name.value === '__schema' || selection.name.value === '__type') {
|
|
94
113
|
if (!walk.protection.enableIntrospection) {
|
|
95
114
|
reject(errors_1.errors.INTROSPECTION_DISABLED());
|
|
96
115
|
}
|
|
97
|
-
|
|
116
|
+
if (bound.kind === 'query')
|
|
117
|
+
childBound = { kind: 'introspection', base: depth };
|
|
98
118
|
}
|
|
99
119
|
const field = parentType && (0, graphql_1.isObjectType)(parentType)
|
|
100
120
|
? parentType.getFields()[selection.name.value]
|
|
@@ -121,7 +141,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
|
121
141
|
}
|
|
122
142
|
}
|
|
123
143
|
if (selection.selectionSet) {
|
|
124
|
-
walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier);
|
|
144
|
+
walkSelectionSet(walk, selection.selectionSet, fieldType, depth + 1, childMultiplier, childBound);
|
|
125
145
|
}
|
|
126
146
|
continue;
|
|
127
147
|
}
|
|
@@ -130,7 +150,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
|
130
150
|
? (0, graphql_1.typeFromAST)(walk.schema, selection.typeCondition)
|
|
131
151
|
: parentType;
|
|
132
152
|
// An inline fragment is not a level of nesting of its own.
|
|
133
|
-
walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier);
|
|
153
|
+
walkSelectionSet(walk, selection.selectionSet, onType ?? null, depth, multiplier, bound);
|
|
134
154
|
continue;
|
|
135
155
|
}
|
|
136
156
|
if (selection.kind === graphql_1.Kind.FRAGMENT_SPREAD) {
|
|
@@ -146,7 +166,7 @@ function walkSelectionSet(walk, selectionSet, parentType, depth, multiplier) {
|
|
|
146
166
|
const onType = (0, graphql_1.typeFromAST)(walk.schema, fragment.typeCondition);
|
|
147
167
|
walk.activeFragments.add(name);
|
|
148
168
|
try {
|
|
149
|
-
walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier);
|
|
169
|
+
walkSelectionSet(walk, fragment.selectionSet, onType ?? null, depth, multiplier, bound);
|
|
150
170
|
}
|
|
151
171
|
finally {
|
|
152
172
|
walk.activeFragments.delete(name);
|
|
@@ -188,6 +208,6 @@ function enforceDocumentProtection(schema, document, variableValues, protection,
|
|
|
188
208
|
maxDepth: 0,
|
|
189
209
|
cost: 0
|
|
190
210
|
};
|
|
191
|
-
walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1);
|
|
211
|
+
walkSelectionSet(walk, operation.selectionSet, rootType ?? null, 1, 1, { kind: 'query' });
|
|
192
212
|
return { depth: walk.maxDepth, cost: walk.cost };
|
|
193
213
|
}
|