diff --git a/.env.example.compose b/.env.example.compose index 12bb81d..b84c941 100644 --- a/.env.example.compose +++ b/.env.example.compose @@ -30,6 +30,11 @@ APP_COMMAND="npm run start" PORT=8080 LOG_LEVEL="info" CORS_ORIGIN="*" +# GraphQL query-cost limits (optional; conservative defaults shown) +GRAPHQL_MAX_DEPTH=10 +GRAPHQL_MAX_ALIASES=15 +GRAPHQL_MAX_TOKENS=1000 +GRAPHQL_MAX_COST=5000 ENABLE_GRAPHIQL="true" ENABLE_INTROSPECTION="true" ENABLE_LOGGING="true" diff --git a/docs/getting-started.md b/docs/getting-started.md index 67a52be..eecc545 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -178,6 +178,10 @@ The server reads config from environment variables. `PG_CONN` is the only requir | `PORT` | `8080` | Port the GraphQL server listens on | | `LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error` | | `CORS_ORIGIN` | `*` | CORS allowed origin | +| `GRAPHQL_MAX_DEPTH` | `10` | Max query selection-set nesting depth | +| `GRAPHQL_MAX_ALIASES` | `15` | Max aliases allowed in a single operation | +| `GRAPHQL_MAX_TOKENS` | `1000` | Max lexical tokens allowed in a query document | +| `GRAPHQL_MAX_COST` | `5000` | Max estimated query cost (depth/field heuristic) | | `ENABLE_GRAPHIQL` | `false` | If `true`, serves the GraphiQL playground at `/` | | `ENABLE_INTROSPECTION` | `false` | If `true`, allows GraphQL schema introspection | | `ENABLE_LOGGING` | `false` | Enable request logging | diff --git a/package-lock.json b/package-lock.json index 27e256c..74b6b3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,23 @@ { "name": "@o1-labs/mina-archive-node-graphql", - "version": "0.0.5", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@o1-labs/mina-archive-node-graphql", - "version": "0.0.5", + "version": "0.0.6", "license": "ISC", "dependencies": { "@envelop/core": "^4.0.0", "@envelop/disable-introspection": "^5.0.0", "@envelop/graphql-jit": "^6.0.1", "@envelop/opentelemetry": "^5.0.0", + "@escape.tech/graphql-armor-block-field-suggestions": "^3.0.1", + "@escape.tech/graphql-armor-cost-limit": "^2.4.3", + "@escape.tech/graphql-armor-max-aliases": "^2.6.2", + "@escape.tech/graphql-armor-max-depth": "^2.4.2", + "@escape.tech/graphql-armor-max-tokens": "^2.5.1", "@graphql-tools/executor-http": "^3.0.4", "@graphql-tools/graphql-file-loader": "^8.1.2", "@graphql-tools/load": "^8.1.2", @@ -3257,6 +3262,245 @@ "node": ">=16.0.0" } }, + "node_modules/@escape.tech/graphql-armor-block-field-suggestions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@escape.tech/graphql-armor-block-field-suggestions/-/graphql-armor-block-field-suggestions-3.0.1.tgz", + "integrity": "sha512-pZ+5aFgGW/pUul7nDOZ3PoeWAd9kLDspQ0R+fpz2aTjdIT0yI+f+ZbAGeTVmr5RypRDwjwokG/HjWoxTYTcRwQ==", + "license": "MIT", + "dependencies": { + "graphql": "^16.10.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@envelop/core": "^5.2.3" + } + }, + "node_modules/@escape.tech/graphql-armor-block-field-suggestions/node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-block-field-suggestions/node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-cost-limit": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@escape.tech/graphql-armor-cost-limit/-/graphql-armor-cost-limit-2.4.3.tgz", + "integrity": "sha512-fLZTlJjrjinpNhbv5VP6f8Ce4MiQzbcHtCNaCPVaHqArTEbN7vDnlVLiH+VcmZBmOwU6Si97lKdlOXWNgB5ytw==", + "license": "MIT", + "dependencies": { + "graphql": "^16.10.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@envelop/core": "^5.2.3", + "@escape.tech/graphql-armor-types": "0.7.0" + } + }, + "node_modules/@escape.tech/graphql-armor-cost-limit/node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-cost-limit/node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-aliases": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@escape.tech/graphql-armor-max-aliases/-/graphql-armor-max-aliases-2.6.2.tgz", + "integrity": "sha512-SDk7pAzY6gutsdZ3NlyY55RrytrCPxJJxSN/DBfIGKphTrfBvKQWTnioQ9OlLP9kPjCE6XM5UWwGt7uqbpKSYA==", + "license": "MIT", + "dependencies": { + "graphql": "^16.10.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@envelop/core": "^5.2.3", + "@escape.tech/graphql-armor-types": "0.7.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-aliases/node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-aliases/node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-depth": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@escape.tech/graphql-armor-max-depth/-/graphql-armor-max-depth-2.4.2.tgz", + "integrity": "sha512-J9fbW1+W4u3GAcf19wwS0zrNGICCbWn/glvopCoC11Ga0reXvGwgr8EcyuHjTFLL7+pPvWAeVhP4qo6hybcB9w==", + "license": "MIT", + "dependencies": { + "graphql": "^16.10.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@envelop/core": "^5.2.3", + "@escape.tech/graphql-armor-types": "0.7.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-depth/node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-depth/node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-tokens": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@escape.tech/graphql-armor-max-tokens/-/graphql-armor-max-tokens-2.5.1.tgz", + "integrity": "sha512-XHui2npOz7Jn8shBZqfyeocWhdl0pUbKiaWmvbF+5rvNoRIGMgwMtaVhmf9ia8oGGbd+cx5EYo1v+oKHzIm79w==", + "license": "MIT", + "dependencies": { + "graphql": "^16.10.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@envelop/core": "^5.2.3", + "@escape.tech/graphql-armor-types": "0.7.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-tokens/node_modules/@envelop/core": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.5.1.tgz", + "integrity": "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@envelop/types": "^5.2.1", + "@whatwg-node/promise-helpers": "^1.2.4", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-max-tokens/node_modules/@envelop/types": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.1.tgz", + "integrity": "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@escape.tech/graphql-armor-types": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@escape.tech/graphql-armor-types/-/graphql-armor-types-0.7.0.tgz", + "integrity": "sha512-RHxyyp6PDgS6NAPnnmB6JdmUJ6oqhpSHFbsglGWeCcnNzceA5AkQFpir7VIDbVyS8LNC1xhipOtk7f9ycrIemQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "graphql": "^16.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -14500,9 +14744,10 @@ "dev": true }, "node_modules/graphql": { - "version": "16.8.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", - "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } diff --git a/package.json b/package.json index 3c71d02..e6560d1 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,11 @@ "@envelop/disable-introspection": "^5.0.0", "@envelop/graphql-jit": "^6.0.1", "@envelop/opentelemetry": "^5.0.0", + "@escape.tech/graphql-armor-block-field-suggestions": "^3.0.1", + "@escape.tech/graphql-armor-cost-limit": "^2.4.3", + "@escape.tech/graphql-armor-max-aliases": "^2.6.2", + "@escape.tech/graphql-armor-max-depth": "^2.4.2", + "@escape.tech/graphql-armor-max-tokens": "^2.5.1", "@graphql-tools/executor-http": "^3.0.4", "@graphql-tools/graphql-file-loader": "^8.1.2", "@graphql-tools/load": "^8.1.2", diff --git a/src/envionment.d.ts b/src/envionment.d.ts index 95cf7a6..a67e3d0 100644 --- a/src/envionment.d.ts +++ b/src/envionment.d.ts @@ -5,6 +5,10 @@ declare global { PORT?: string; PG_CONN: string; CORS_ORIGIN?: string; + GRAPHQL_MAX_DEPTH?: string; + GRAPHQL_MAX_ALIASES?: string; + GRAPHQL_MAX_TOKENS?: string; + GRAPHQL_MAX_COST?: string; ENABLE_LOGGING?: bool; ENABLE_INTROSPECTION?: bool; ENABLE_GRAPHIQL?: bool; diff --git a/src/server/graphql-armor.ts b/src/server/graphql-armor.ts new file mode 100644 index 0000000..07c45b7 --- /dev/null +++ b/src/server/graphql-armor.ts @@ -0,0 +1,74 @@ +import { maxDepthPlugin } from '@escape.tech/graphql-armor-max-depth'; +import { maxAliasesPlugin } from '@escape.tech/graphql-armor-max-aliases'; +import { maxTokensPlugin } from '@escape.tech/graphql-armor-max-tokens'; +import { costLimitPlugin } from '@escape.tech/graphql-armor-cost-limit'; +import { blockFieldSuggestionsPlugin } from '@escape.tech/graphql-armor-block-field-suggestions'; + +export { buildArmorPlugins, resolveArmorConfig, ARMOR_DEFAULTS }; +export type { ArmorConfig }; + +/** + * Query-cost protections for the public GraphQL endpoint. Without these a single + * deeply-nested, heavily-aliased, or otherwise expensive query can be turned into + * a denial-of-service against the backing Postgres. The limits are deliberately + * conservative — they comfortably allow every query this API legitimately serves + * (the deepest is ~5 levels) while rejecting abusive shapes before execution — and + * each is tunable via the environment. + */ +interface ArmorConfig { + /** Max selection-set nesting depth. */ + maxDepth: number; + /** Max number of aliases in a single operation. */ + maxAliases: number; + /** Max number of lexical tokens in a document. */ + maxTokens: number; + /** Max estimated query cost (graphql-armor's depth/field heuristic). */ + maxCost: number; +} + +const ARMOR_DEFAULTS: ArmorConfig = { + maxDepth: 10, + maxAliases: 15, + maxTokens: 1000, + maxCost: 5000, +}; + +type EnvSource = Record; + +/** + * Parse a positive integer from an env value, falling back to `fallback` when it + * is missing or malformed. We never throw, so a stray typo can't silently remove + * a protection — it just reverts to the safe default. + */ +function intFromEnv(value: string | undefined, fallback: number): number { + if (value === undefined || value.trim() === '') return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) return fallback; + return parsed; +} + +function resolveArmorConfig(env: EnvSource = process.env): ArmorConfig { + return { + maxDepth: intFromEnv(env.GRAPHQL_MAX_DEPTH, ARMOR_DEFAULTS.maxDepth), + maxAliases: intFromEnv(env.GRAPHQL_MAX_ALIASES, ARMOR_DEFAULTS.maxAliases), + maxTokens: intFromEnv(env.GRAPHQL_MAX_TOKENS, ARMOR_DEFAULTS.maxTokens), + maxCost: intFromEnv(env.GRAPHQL_MAX_COST, ARMOR_DEFAULTS.maxCost), + }; +} + +/** + * Build the graphql-armor envelop plugins that enforce the configured limits. + * Introspection is ignored by the depth/cost rules so the GraphiQL explorer keeps + * working when it is explicitly enabled; field suggestions are always blocked so + * error messages don't leak schema shape (complementing `useDisableIntrospection`). + */ +function buildArmorPlugins(env: EnvSource = process.env) { + const config = resolveArmorConfig(env); + return [ + maxDepthPlugin({ n: config.maxDepth, ignoreIntrospection: true }), + maxAliasesPlugin({ n: config.maxAliases }), + maxTokensPlugin({ n: config.maxTokens }), + costLimitPlugin({ maxCost: config.maxCost, ignoreIntrospection: true }), + blockFieldSuggestionsPlugin(), + ]; +} diff --git a/src/server/plugins.ts b/src/server/plugins.ts index 43313dd..853851e 100644 --- a/src/server/plugins.ts +++ b/src/server/plugins.ts @@ -5,12 +5,18 @@ import { useOpenTelemetry } from '@envelop/opentelemetry'; import { inspect } from 'node:util'; import { initJaegerProvider } from '../tracing/jaeger-tracing.js'; +import { buildArmorPlugins } from './graphql-armor.js'; export { buildPlugins }; async function buildPlugins() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const plugins: any[] = []; + + // Query-cost protections (depth / aliases / tokens / cost). These reject + // abusive query shapes before execution, so they run ahead of everything else. + plugins.push(...buildArmorPlugins()); + plugins.push(useGraphQlJit()); if (process.env.ENABLE_LOGGING) { diff --git a/tests/unit/graphql-armor.test.ts b/tests/unit/graphql-armor.test.ts new file mode 100644 index 0000000..0734fed --- /dev/null +++ b/tests/unit/graphql-armor.test.ts @@ -0,0 +1,93 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { createYoga } from 'graphql-yoga'; +import { + buildArmorPlugins, + resolveArmorConfig, + ARMOR_DEFAULTS, +} from '../../src/server/graphql-armor.js'; +import { schema } from '../../src/resolvers.js'; + +async function runQuery(query: string, env: Record) { + const yoga = createYoga({ + schema, + plugins: buildArmorPlugins(env), + graphqlEndpoint: '/graphql', + }); + const response = await yoga.fetch('http://localhost/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query }), + }); + return response.json(); +} + +describe('GraphQL armor configuration', () => { + describe('resolveArmorConfig', () => { + test('uses conservative defaults when no env vars are set', () => { + assert.deepStrictEqual(resolveArmorConfig({}), ARMOR_DEFAULTS); + }); + + test('reads valid overrides from the environment', () => { + const config = resolveArmorConfig({ + GRAPHQL_MAX_DEPTH: '8', + GRAPHQL_MAX_ALIASES: '20', + GRAPHQL_MAX_TOKENS: '2000', + GRAPHQL_MAX_COST: '8000', + }); + assert.deepStrictEqual(config, { + maxDepth: 8, + maxAliases: 20, + maxTokens: 2000, + maxCost: 8000, + }); + }); + + test('falls back to defaults on malformed or non-positive values', () => { + const config = resolveArmorConfig({ + GRAPHQL_MAX_DEPTH: '0', + GRAPHQL_MAX_ALIASES: '-5', + GRAPHQL_MAX_TOKENS: 'abc', + GRAPHQL_MAX_COST: '', + }); + assert.deepStrictEqual(config, ARMOR_DEFAULTS); + }); + }); + + describe('buildArmorPlugins', () => { + test('returns the five armor plugins as envelop plugins', () => { + const plugins = buildArmorPlugins({}); + assert.strictEqual(plugins.length, 5); + // Each entry must be a usable envelop plugin (hooks into the lifecycle). + for (const plugin of plugins) { + assert.strictEqual(typeof plugin, 'object'); + assert.ok(plugin !== null); + } + }); + }); + + describe('enforcement (end-to-end through Yoga)', () => { + const deepQuery = + '{ networkState { maxBlockHeight { canonicalMaxBlockHeight } } }'; + + test('rejects a query that exceeds the depth limit before execution', async () => { + const result = await runQuery(deepQuery, { GRAPHQL_MAX_DEPTH: '1' }); + assert.ok( + result.errors?.some((e: { message: string }) => + /depth/i.test(e.message) + ), + `expected a max-depth error, got: ${JSON.stringify(result.errors)}` + ); + }); + + test('does not raise a depth error when within the limit', async () => { + // A generous limit must not produce a depth error. (Execution itself is + // not exercised here — there is no DB — but the query passes validation.) + const result = await runQuery(deepQuery, { GRAPHQL_MAX_DEPTH: '10' }); + const depthError = result.errors?.some((e: { message: string }) => + /depth/i.test(e.message) + ); + assert.ok(!depthError, 'a within-limit query must not be depth-rejected'); + }); + }); +});