From 636d9542a957a3778f8907818861a00a961f4758 Mon Sep 17 00:00:00 2001 From: dkijania Date: Sun, 28 Jun 2026 12:18:42 +0200 Subject: [PATCH 1/2] feat(server): add GraphQL query-cost protections (graphql-armor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public GraphQL endpoint had no query-cost controls. The list fields (`events`, `actions`, `blocks`) return unbounded lists, so a deeply-nested or heavily-aliased query is a trivial denial-of-service against the backing Postgres — it can be made arbitrarily expensive before any limit applies. Add graphql-armor validation plugins, wired ahead of execution in `buildPlugins`, with conservative, env-tunable limits: - GRAPHQL_MAX_DEPTH (selection-set nesting, default 10) - GRAPHQL_MAX_ALIASES (aliases per operation, default 15) - GRAPHQL_MAX_TOKENS (lexical tokens per document, default 1000) - GRAPHQL_MAX_COST (depth/field cost heuristic, default 5000) Field suggestions are always blocked so error messages don't leak schema shape (complementing `useDisableIntrospection`); introspection is ignored by the depth/cost rules so GraphiQL still works when explicitly enabled. The deepest query this API legitimately serves is ~5 levels, well within the defaults. Malformed env values fall back to the safe default rather than disabling a protection. The individual `@escape.tech/graphql-armor-*` plugins are used (not the meta package) because the meta package requires @envelop/core v5 while this stack is pinned to v4 (Yoga 4); the sub-plugins have no conflicting peer deps. The full graphql-armor meta integration can follow the Yoga 5 upgrade (#176). Unit tests cover config parsing/fallbacks and prove end-to-end through Yoga that an over-depth query is rejected before execution. Docs, env example, and env type declarations updated. Closes #164. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6 --- .env.example.compose | 5 ++ docs/getting-started.md | 4 ++ package-lock.json | 101 ++++++++++++++++++++++++++++++- package.json | 5 ++ src/envionment.d.ts | 4 ++ src/server/graphql-armor.ts | 74 ++++++++++++++++++++++ src/server/plugins.ts | 5 ++ tests/unit/graphql-armor.test.ts | 93 ++++++++++++++++++++++++++++ 8 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 src/server/graphql-armor.ts create mode 100644 tests/unit/graphql-armor.test.ts diff --git a/.env.example.compose b/.env.example.compose index a3db6de4..bfa10d39 100644 --- a/.env.example.compose +++ b/.env.example.compose @@ -40,6 +40,11 @@ RATE_LIMIT_WINDOW_MS=60000 # Behind a GCP external ALB use 2 (client IP + forwarding-rule IP), plus 1 per # additional in-cluster proxy hop. TRUST_PROXY=0 +# GraphQL query-cost limits (optional; conservative defaults shown) +GRAPHQL_MAX_DEPTH=12 +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 1c690186..5421cb4e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -183,6 +183,10 @@ The server reads config from environment variables. `PG_CONN` is the only requir | `RATE_LIMIT_MAX` | `600` | Max requests per client IP per window; `0` disables rate limiting | | `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in milliseconds | | `TRUST_PROXY` | _(unset)_ | Number of trusted proxy hops in front of the API. Required when `RATE_LIMIT_MAX > 0`: the limiter stays disabled until it is set. `0` ignores `X-Forwarded-For` and keys on the socket address | +| `GRAPHQL_MAX_DEPTH` | `12` | Max query selection-set nesting depth. Do not set below `8`: known clients send depth-7 probes, and a depth rejection replaces the `Cannot query field` error those clients rely on for schema-tier fallback | +| `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 19c257ad..452348b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,11 @@ "@envelop/disable-introspection": "^9.1.1", "@envelop/graphql-jit": "^11.1.1", "@envelop/opentelemetry": "^9.1.1", + "@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", @@ -3282,6 +3287,95 @@ "node": ">=18.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-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-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-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-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-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", @@ -14503,9 +14597,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 a064f88d..1292985d 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,11 @@ "@envelop/disable-introspection": "^9.1.1", "@envelop/graphql-jit": "^11.1.1", "@envelop/opentelemetry": "^9.1.1", + "@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 2363573c..e9394c01 100644 --- a/src/envionment.d.ts +++ b/src/envionment.d.ts @@ -10,6 +10,10 @@ declare global { RATE_LIMIT_MAX?: string; RATE_LIMIT_WINDOW_MS?: string; TRUST_PROXY?: string; + GRAPHQL_MAX_DEPTH?: string; + GRAPHQL_MAX_ALIASES?: string; + GRAPHQL_MAX_TOKENS?: string; + GRAPHQL_MAX_COST?: string; ENABLE_LOGGING?: bool; ENABLE_METRICS?: bool; ENABLE_INTROSPECTION?: bool; diff --git a/src/server/graphql-armor.ts b/src/server/graphql-armor.ts new file mode 100644 index 00000000..07c45b77 --- /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 1042da70..d424a87b 100644 --- a/src/server/plugins.ts +++ b/src/server/plugins.ts @@ -8,6 +8,7 @@ import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { initJaegerProvider } from '../tracing/jaeger-tracing.js'; import { useMetrics } from './metrics.js'; import { useRateLimit } from './rate-limit.js'; +import { buildArmorPlugins } from './graphql-armor.js'; export { buildPlugins }; @@ -19,6 +20,10 @@ async function buildPlugins() { // so over-limit traffic is rejected as cheaply as possible. plugins.push(useRateLimit()); + // Query-cost protections (depth / aliases / tokens / cost). These reject + // abusive query shapes before execution. + plugins.push(...buildArmorPlugins()); + if (process.env.ENABLE_METRICS === 'true') { // Prometheus /metrics endpoint + RED metrics for every request. plugins.push(useMetrics()); diff --git a/tests/unit/graphql-armor.test.ts b/tests/unit/graphql-armor.test.ts new file mode 100644 index 00000000..0734fed0 --- /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'); + }); + }); +}); From fd3f32f25ee041319003ae9559b2ffbcdb105da0 Mon Sep 17 00:00:00 2001 From: dkijania Date: Wed, 19 Aug 2026 00:20:17 +0200 Subject: [PATCH 2/2] fix(server): preserve armor depth headroom --- .env.example.lightnet | 4 ++ src/server/graphql-armor.ts | 8 +++- tests/unit/graphql-armor.test.ts | 76 +++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/.env.example.lightnet b/.env.example.lightnet index 7b3665d0..c1483124 100644 --- a/.env.example.lightnet +++ b/.env.example.lightnet @@ -3,6 +3,10 @@ SHUTDOWN_TIMEOUT_MS=20000 LOG_LEVEL="info" CORS_ORIGIN="*" READINESS_PING_TIMEOUT_MS=2000 +GRAPHQL_MAX_DEPTH=12 +GRAPHQL_MAX_ALIASES=15 +GRAPHQL_MAX_TOKENS=1000 +GRAPHQL_MAX_COST=5000 PG_CONN="postgresql://postgres:postgres@localhost:5432/archive" diff --git a/src/server/graphql-armor.ts b/src/server/graphql-armor.ts index 07c45b77..30ef63ba 100644 --- a/src/server/graphql-armor.ts +++ b/src/server/graphql-armor.ts @@ -12,7 +12,7 @@ export type { ArmorConfig }; * 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 + * (the deepest known downstream probe is 7 levels) while rejecting abusive shapes before execution — and * each is tunable via the environment. */ interface ArmorConfig { @@ -27,7 +27,11 @@ interface ArmorConfig { } const ARMOR_DEFAULTS: ArmorConfig = { - maxDepth: 10, + // Deepest query in production use is 7 (mina-explorer's SearchTransaction + // FULL-tier probe). Keep the default comfortably above that: an armor depth + // rejection aborts validation before clients see the `Cannot query field` + // message they use for schema-tier fallback. + maxDepth: 12, maxAliases: 15, maxTokens: 1000, maxCost: 5000, diff --git a/tests/unit/graphql-armor.test.ts b/tests/unit/graphql-armor.test.ts index 0734fed0..6370aae6 100644 --- a/tests/unit/graphql-armor.test.ts +++ b/tests/unit/graphql-armor.test.ts @@ -57,7 +57,7 @@ describe('GraphQL armor configuration', () => { describe('buildArmorPlugins', () => { test('returns the five armor plugins as envelop plugins', () => { const plugins = buildArmorPlugins({}); - assert.strictEqual(plugins.length, 5); + assert.ok(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'); @@ -90,4 +90,78 @@ describe('GraphQL armor configuration', () => { assert.ok(!depthError, 'a within-limit query must not be depth-rejected'); }); }); + + describe('real downstream client queries pass the default limits', () => { + const downstreamQueries: Record = { + // mina-explorer src/services/api/transactions.ts SearchTransaction + searchTransactionFull: ` + query SearchTransaction($limit: Int!) { + blocks(limit: $limit, sortBy: BLOCKHEIGHT_DESC) { + blockHeight stateHash dateTime + transactions { + userCommands { hash kind from to amount fee memo nonce failureReason } + zkappCommands { + hash + failureReasons { failures } + zkappCommand { + memo + feePayer { body { publicKey fee } } + accountUpdates { body { publicKey } } + } + } + } + } + }`, + // mina-explorer src/services/api/analytics.ts, ANALYTICS_BLOCK_LIMIT = 2000 + analytics: ` + query BlocksAnalytics($limit: Int, $dateTime_gte: DateTime) { + blocks(query: { canonical: true, dateTime_gte: $dateTime_gte }, sortBy: BLOCKHEIGHT_DESC, limit: $limit) { + blockHeight dateTime txFees + transactions { userCommands { hash } zkappCommands { hash } } + } + }`, + // mina-explorer-api app/upstream/archive.py FULL tier, paginated best chain + blocksFullPaginatedBestChain: ` + query GetBlocksFULLPaginatedBestChain($limit: Int!, $maxBlockHeight: Int!) { + blocks(query: { blockHeight_lt: $maxBlockHeight, inBestChain: true }, limit: $limit, sortBy: BLOCKHEIGHT_DESC) { + blockHeight stateHash creator dateTime + protocolState { consensusState { epoch slot slotSinceGenesis } } + transactions { coinbase userCommands { hash } zkappCommands { hash } } + } + networkState { maxBlockHeight { canonicalMaxBlockHeight pendingMaxBlockHeight } } + }`, + readiness: '{ __typename }', + }; + + for (const [name, query] of Object.entries(downstreamQueries)) { + test(`${name} is not rejected by any armor limit`, async () => { + const result = await runQuery(query, {}); + const armorError = result.errors?.find((e: { message: string }) => + /Syntax Error: (Query depth limit|Query Cost limit|Token limit|Aliases limit)/.test( + e.message + ) + ); + assert.strictEqual( + armorError, + undefined, + `armor rejected a production query: ${armorError?.message}` + ); + }); + } + + test('field-suggestion blocking keeps "Cannot query field" verbatim', async () => { + const result = await runQuery( + '{ blocks(limit: 1) { protocolState { consensusState { epoch } } } }', + {} + ); + assert.ok( + result.errors?.some((e: { message: string }) => + e.message.startsWith( + 'Cannot query field "protocolState" on type "Block".' + ) + ), + `tier-fallback marker lost: ${JSON.stringify(result.errors)}` + ); + }); + }); });