@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.
@@ -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, makePgService } from 'graphile-settings';
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
- extends: [createConstructivePreset(databaseSettings)],
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
- // Create promise and store in in-flight map BEFORE try block
308
- const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
309
- const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
310
- const creationPromise = observeGraphileBuild({
311
- cacheKey: key,
312
- serviceKey: key,
313
- databaseId: api.databaseId ?? null
314
- }, () => createGraphileInstance({
315
- preset,
316
- cacheKey: key,
317
- enableRealtime: api.databaseSettings?.enableRealtime
318
- }), { enabled: observabilityEnabled });
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 governed only by
21
- * `enableIntrospection`. Its selections are not walked: the introspection types
22
- * carry no connections, so there is nothing to cost, and the standard
23
- * introspection document nests a fixed `ofType` chain deeper than a sensible
24
- * tenant depth budget, so charging depth would make the budget decide whether
25
- * clients can introspect at all — a decision the dedicated switch already owns.
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 (depth > walk.maxDepth)
84
- walk.maxDepth = depth;
85
- if (depth > walk.protection.maxQueryDepth) {
86
- reject(errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
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
- continue;
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;
@@ -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
- extends: [(0, graphile_settings_1.createConstructivePreset)(databaseSettings)],
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
- // Create promise and store in in-flight map BEFORE try block
314
- const compute = api.apiId ? await req.constructive?.useModule('compute') : undefined;
315
- const preset = buildPreset(pool, schema || [], anonRole, roleName, opts.api?.introspectionRole, api.databaseSettings, api.apiId, compute);
316
- const creationPromise = (0, graphile_build_stats_1.observeGraphileBuild)({
317
- cacheKey: key,
318
- serviceKey: key,
319
- databaseId: api.databaseId ?? null
320
- }, () => (0, graphile_cache_1.createGraphileInstance)({
321
- preset,
322
- cacheKey: key,
323
- enableRealtime: api.databaseSettings?.enableRealtime
324
- }), { enabled: observabilityEnabled });
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;
@@ -1,17 +1,5 @@
1
- import type { RequestProtection } from '@constructive-io/express-context';
2
- import type { ApiStructure } from '../types';
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.25.1",
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.14.0",
47
- "@constructive-io/express-context": "^0.31.0",
48
- "@constructive-io/graphql-env": "^3.32.1",
49
- "@constructive-io/graphql-types": "^3.31.1",
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.13.6",
52
- "@constructive-io/s3-utils": "^2.33.0",
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.43.5",
55
+ "@pgpmjs/env": "^2.44.0",
56
56
  "@pgpmjs/logger": "^2.25.1",
57
- "@pgpmjs/server-utils": "^3.27.5",
58
- "@pgpmjs/types": "^2.53.3",
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.12.5",
67
+ "graphile-cache": "^4.13.0",
68
68
  "graphile-config": "1.1.0",
69
- "graphile-function-bindings": "^1.14.6",
70
- "graphile-settings": "^6.21.8",
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.27.5",
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.14.6",
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": "164f6e481961196e681eb3dee9e5f9ebcb8fb834"
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 governed only by
21
- * `enableIntrospection`. Its selections are not walked: the introspection types
22
- * carry no connections, so there is nothing to cost, and the standard
23
- * introspection document nests a fixed `ofType` chain deeper than a sensible
24
- * tenant depth budget, so charging depth would make the budget decide whether
25
- * clients can introspect at all — a decision the dedicated switch already owns.
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 governed only by
22
- * `enableIntrospection`. Its selections are not walked: the introspection types
23
- * carry no connections, so there is nothing to cost, and the standard
24
- * introspection document nests a fixed `ofType` chain deeper than a sensible
25
- * tenant depth budget, so charging depth would make the budget decide whether
26
- * clients can introspect at all — a decision the dedicated switch already owns.
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 (depth > walk.maxDepth)
87
- walk.maxDepth = depth;
88
- if (depth > walk.protection.maxQueryDepth) {
89
- reject(errors_1.errors.QUERY_TOO_DEEP({ depth, limit: walk.protection.maxQueryDepth }));
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
- continue;
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
  }