@o1-labs/mina-archive-node-graphql 0.0.6 → 2.0.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/LICENSE +201 -0
- package/README.md +92 -268
- package/build/src/blockchain/utils.js.map +1 -1
- package/build/src/config.js +86 -0
- package/build/src/config.js.map +1 -0
- package/build/src/db/archive-node-adapter/archive-node-adapter.js +29 -2
- package/build/src/db/archive-node-adapter/archive-node-adapter.js.map +1 -1
- package/build/src/db/archive-node-adapter/postgres-options.js +35 -0
- package/build/src/db/archive-node-adapter/postgres-options.js.map +1 -0
- package/build/src/db/sql/best-chain.js +5 -0
- package/build/src/db/sql/best-chain.js.map +1 -0
- package/build/src/db/sql/events-actions/queries.js +102 -21
- package/build/src/db/sql/events-actions/queries.js.map +1 -1
- package/build/src/db/sql/verification-key-updates/queries.js +100 -0
- package/build/src/db/sql/verification-key-updates/queries.js.map +1 -0
- package/build/src/db/sql/zkapp-commands/queries.js +363 -0
- package/build/src/db/sql/zkapp-commands/queries.js.map +1 -0
- package/build/src/db/sql/zkapp-commands/types.js +2 -0
- package/build/src/db/sql/zkapp-commands/types.js.map +1 -0
- package/build/src/errors/error.js +4 -1
- package/build/src/errors/error.js.map +1 -1
- package/build/src/index.js +44 -7
- package/build/src/index.js.map +1 -1
- package/build/src/resolvers.js +42 -23
- package/build/src/resolvers.js.map +1 -1
- package/build/src/schema-version.js +2 -0
- package/build/src/schema-version.js.map +1 -0
- package/build/src/server/cors.js +41 -0
- package/build/src/server/cors.js.map +1 -0
- package/build/src/server/graceful-shutdown.js +42 -0
- package/build/src/server/graceful-shutdown.js.map +1 -0
- package/build/src/server/graphql-armor.js +39 -0
- package/build/src/server/graphql-armor.js.map +1 -0
- package/build/src/server/logger.js +45 -0
- package/build/src/server/logger.js.map +1 -0
- package/build/src/server/metrics.js +72 -0
- package/build/src/server/metrics.js.map +1 -0
- package/build/src/server/plugins.js +31 -15
- package/build/src/server/plugins.js.map +1 -1
- package/build/src/server/rate-limit.js +143 -0
- package/build/src/server/rate-limit.js.map +1 -0
- package/build/src/server/readiness.js +45 -0
- package/build/src/server/readiness.js.map +1 -0
- package/build/src/server/request-logging.js +58 -0
- package/build/src/server/request-logging.js.map +1 -0
- package/build/src/server/server.js +38 -13
- package/build/src/server/server.js.map +1 -1
- package/build/src/services/actions-service/actions-service.js +26 -15
- package/build/src/services/actions-service/actions-service.js.map +1 -1
- package/build/src/services/blocks-service/blocks-service.js +10 -9
- package/build/src/services/blocks-service/blocks-service.js.map +1 -1
- package/build/src/services/network-service/network-service.js +17 -9
- package/build/src/services/network-service/network-service.js.map +1 -1
- package/build/src/services/verification-key-updates-service/verification-key-updates-service.interface.js +2 -0
- package/build/src/services/verification-key-updates-service/verification-key-updates-service.interface.js.map +1 -0
- package/build/src/services/verification-key-updates-service/verification-key-updates-service.js +41 -0
- package/build/src/services/verification-key-updates-service/verification-key-updates-service.js.map +1 -0
- package/build/src/services/zkapp-commands-service/zkapp-commands-service.interface.js +2 -0
- package/build/src/services/zkapp-commands-service/zkapp-commands-service.interface.js.map +1 -0
- package/build/src/services/zkapp-commands-service/zkapp-commands-service.js +115 -0
- package/build/src/services/zkapp-commands-service/zkapp-commands-service.js.map +1 -0
- package/build/src/tracing/jaeger-tracing.js +2 -1
- package/build/src/tracing/jaeger-tracing.js.map +1 -1
- package/build/tests/devnet-dump/action-state-invariants.js +165 -0
- package/build/tests/devnet-dump/action-state-invariants.js.map +1 -0
- package/build/tests/devnet-dump/devnet-dump.test.js +24 -10
- package/build/tests/devnet-dump/devnet-dump.test.js.map +1 -1
- package/build/tests/devnet-dump/setup.js +63 -17
- package/build/tests/devnet-dump/setup.js.map +1 -1
- package/build/tests/integration/action-state-ordering.test.js +139 -0
- package/build/tests/integration/action-state-ordering.test.js.map +1 -0
- package/build/tests/integration/action-state-setup.js +48 -0
- package/build/tests/integration/action-state-setup.js.map +1 -0
- package/build/tests/integration/consensus-vs-chain.test.js +81 -0
- package/build/tests/integration/consensus-vs-chain.test.js.map +1 -0
- package/build/tests/integration/integration.test.js +166 -2
- package/build/tests/integration/integration.test.js.map +1 -1
- package/build/tests/integration/postgres-timeout.test.js +92 -0
- package/build/tests/integration/postgres-timeout.test.js.map +1 -0
- package/build/tests/integration/setup.js +58 -1
- package/build/tests/integration/setup.js.map +1 -1
- package/build/tests/integration/verification-key-setup.js +49 -0
- package/build/tests/integration/verification-key-setup.js.map +1 -0
- package/build/tests/integration/verification-key-updates.test.js +159 -0
- package/build/tests/integration/verification-key-updates.test.js.map +1 -0
- package/build/tests/live-api/events.test.js +11 -7
- package/build/tests/live-api/events.test.js.map +1 -1
- package/build/tests/live-api/networkState.test.js +1 -0
- package/build/tests/live-api/networkState.test.js.map +1 -1
- package/build/tests/live-network/live-network.test.js +1 -0
- package/build/tests/live-network/live-network.test.js.map +1 -1
- package/build/tests/resolvers.test.js +65 -37
- package/build/tests/resolvers.test.js.map +1 -1
- package/build/tests/services/verification-key-updates-service/verification-key-updates-service.test.js +35 -0
- package/build/tests/services/verification-key-updates-service/verification-key-updates-service.test.js.map +1 -0
- package/build/tests/services/zkapp-commands-service/zkapp-commands-service.test.js +159 -0
- package/build/tests/services/zkapp-commands-service/zkapp-commands-service.test.js.map +1 -0
- package/build/tests/test-helpers.js +15 -0
- package/build/tests/test-helpers.js.map +1 -1
- package/build/tests/unit/best-chain-anchor.test.js +70 -0
- package/build/tests/unit/best-chain-anchor.test.js.map +1 -0
- package/build/tests/unit/config.test.js +106 -0
- package/build/tests/unit/config.test.js.map +1 -0
- package/build/tests/unit/cors.test.js +95 -0
- package/build/tests/unit/cors.test.js.map +1 -0
- package/build/tests/unit/error-masking.test.js +119 -0
- package/build/tests/unit/error-masking.test.js.map +1 -0
- package/build/tests/unit/graceful-shutdown.test.js +99 -0
- package/build/tests/unit/graceful-shutdown.test.js.map +1 -0
- package/build/tests/unit/graphql-armor.test.js +121 -0
- package/build/tests/unit/graphql-armor.test.js.map +1 -0
- package/build/tests/unit/logging.test.js +120 -0
- package/build/tests/unit/logging.test.js.map +1 -0
- package/build/tests/unit/metrics.test.js +114 -0
- package/build/tests/unit/metrics.test.js.map +1 -0
- package/build/tests/unit/network-service.test.js +66 -0
- package/build/tests/unit/network-service.test.js.map +1 -0
- package/build/tests/unit/postgres-options.test.js +56 -0
- package/build/tests/unit/postgres-options.test.js.map +1 -0
- package/build/tests/unit/rate-limit.test.js +271 -0
- package/build/tests/unit/rate-limit.test.js.map +1 -0
- package/build/tests/unit/readiness.test.js +75 -0
- package/build/tests/unit/readiness.test.js.map +1 -0
- package/build/tests/unit/schema-nullability.test.js +40 -0
- package/build/tests/unit/schema-nullability.test.js.map +1 -0
- package/build/tests/unit/schema-version.test.js +48 -0
- package/build/tests/unit/schema-version.test.js.map +1 -0
- package/build/tests/unit/yoga-http-contract.test.js +30 -0
- package/build/tests/unit/yoga-http-contract.test.js.map +1 -0
- package/build/tests/unit/zkapp-commands-gating.test.js +38 -0
- package/build/tests/unit/zkapp-commands-gating.test.js.map +1 -0
- package/build/zkapp/utils.js +2 -0
- package/build/zkapp/utils.js.map +1 -1
- package/package.json +33 -14
- package/schema.graphql +126 -8
- package/src/blockchain/types.ts +65 -1
- package/src/blockchain/utils.ts +26 -2
- package/src/config.ts +133 -0
- package/src/db/archive-node-adapter/archive-node-adapter.interface.ts +26 -3
- package/src/db/archive-node-adapter/archive-node-adapter.ts +65 -2
- package/src/db/archive-node-adapter/postgres-options.ts +90 -0
- package/src/db/sql/best-chain.ts +34 -0
- package/src/db/sql/events-actions/queries.ts +168 -24
- package/src/db/sql/verification-key-updates/queries.ts +132 -0
- package/src/db/sql/zkapp-commands/queries.ts +406 -0
- package/src/db/sql/zkapp-commands/types.ts +43 -0
- package/src/envionment.d.ts +20 -0
- package/src/errors/error.ts +17 -1
- package/src/index.ts +64 -7
- package/src/resolvers-types.ts +311 -24
- package/src/resolvers.ts +70 -34
- package/src/schema-version.ts +24 -0
- package/src/server/cors.ts +97 -0
- package/src/server/graceful-shutdown.ts +76 -0
- package/src/server/graphql-armor.ts +78 -0
- package/src/server/logger.ts +72 -0
- package/src/server/metrics.ts +104 -0
- package/src/server/plugins.ts +53 -19
- package/src/server/rate-limit.ts +274 -0
- package/src/server/readiness.ts +78 -0
- package/src/server/request-logging.ts +83 -0
- package/src/server/server.ts +70 -15
- package/src/services/actions-service/actions-service.ts +87 -24
- package/src/services/blocks-service/blocks-service.ts +16 -14
- package/src/services/network-service/network-service.ts +30 -12
- package/src/services/verification-key-updates-service/verification-key-updates-service.interface.ts +9 -0
- package/src/services/verification-key-updates-service/verification-key-updates-service.ts +72 -0
- package/src/services/zkapp-commands-service/zkapp-commands-service.interface.ts +9 -0
- package/src/services/zkapp-commands-service/zkapp-commands-service.ts +181 -0
- package/src/tracing/jaeger-tracing.ts +2 -1
- package/build/tests/consensus/run-compare.js +0 -40
- package/build/tests/consensus/run-compare.js.map +0 -1
- package/build/tests/consensus/select-consensus-precomputed.js +0 -80
- package/build/tests/consensus/select-consensus-precomputed.js.map +0 -1
- package/build/tests/consensus/types.js +0 -15
- package/build/tests/consensus/types.js.map +0 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export { resolveCorsOptions, warnIfCorsDisabled };
|
|
2
|
+
export type { CorsResult };
|
|
3
|
+
|
|
4
|
+
const CORS_METHODS = ['GET', 'POST'];
|
|
5
|
+
// Pinned rather than echoed from Access-Control-Request-Headers: echoing makes
|
|
6
|
+
// the adapter drop Vary: Origin on preflight responses while ACAO is per-origin.
|
|
7
|
+
const CORS_HEADERS = ['content-type'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Either a Yoga CORS configuration object or `false` to disable CORS entirely
|
|
11
|
+
* (no `Access-Control-Allow-Origin` header → same-origin only).
|
|
12
|
+
*/
|
|
13
|
+
type CorsResult =
|
|
14
|
+
| false
|
|
15
|
+
| {
|
|
16
|
+
origin: string | string[];
|
|
17
|
+
methods: string[];
|
|
18
|
+
allowedHeaders: string[];
|
|
19
|
+
// This API takes no cookies or Authorization header. Leaving this
|
|
20
|
+
// undefined makes the adapter send Access-Control-Allow-Credentials: true
|
|
21
|
+
// alongside an echoed origin.
|
|
22
|
+
credentials: false;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const corsBase = {
|
|
26
|
+
methods: CORS_METHODS,
|
|
27
|
+
allowedHeaders: CORS_HEADERS,
|
|
28
|
+
credentials: false,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the Yoga `cors` option from `CORS_ORIGIN`.
|
|
33
|
+
*
|
|
34
|
+
* Secure by default: when `CORS_ORIGIN` is unset, cross-origin browser access is
|
|
35
|
+
* disabled rather than allowed from anywhere. Wide-open access (`*`) is still
|
|
36
|
+
* available, but only as a deliberate opt-in. Server-to-server clients and curl
|
|
37
|
+
* are unaffected by CORS; this only governs browsers calling from another origin.
|
|
38
|
+
*
|
|
39
|
+
* Accepted values:
|
|
40
|
+
* - unset / empty → `false` (same-origin only)
|
|
41
|
+
* - `*` → allow any origin (explicit opt-in)
|
|
42
|
+
* - one or more comma-separated origins → allowlist
|
|
43
|
+
*/
|
|
44
|
+
function resolveCorsOptions(
|
|
45
|
+
env: Record<string, string | undefined> = process.env
|
|
46
|
+
): CorsResult {
|
|
47
|
+
const raw = env.CORS_ORIGIN?.trim();
|
|
48
|
+
if (!raw) return false;
|
|
49
|
+
if (raw === '*') return { origin: '*', ...corsBase };
|
|
50
|
+
|
|
51
|
+
const origins = raw
|
|
52
|
+
.split(',')
|
|
53
|
+
.map((origin) => origin.trim())
|
|
54
|
+
.filter((origin) => origin.length > 0);
|
|
55
|
+
|
|
56
|
+
// A value that was non-empty but parsed to nothing (e.g. just commas) falls
|
|
57
|
+
// back to the secure default rather than an empty, surprising allowlist.
|
|
58
|
+
if (origins.length === 0) return false;
|
|
59
|
+
|
|
60
|
+
for (const origin of origins) {
|
|
61
|
+
if (
|
|
62
|
+
origin.endsWith('/') ||
|
|
63
|
+
origin !== origin.toLowerCase() ||
|
|
64
|
+
origin.includes('*')
|
|
65
|
+
) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[cors] CORS_ORIGIN entry "${origin}" is unlikely to ever match: ` +
|
|
68
|
+
"origins are compared byte-for-byte against the browser's Origin " +
|
|
69
|
+
'header, which is lowercase scheme://host[:port] with no trailing ' +
|
|
70
|
+
'slash. Wildcard subdomains are not supported.'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { origin: origins, ...corsBase };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Announce the secure default at startup.
|
|
80
|
+
*
|
|
81
|
+
* When CORS is disabled, a browser client's requests fail in the browser — the
|
|
82
|
+
* server answers normally and logs nothing, so the only symptom is a blank page
|
|
83
|
+
* on someone else's screen. Saying so once at boot turns that into a glance at
|
|
84
|
+
* the logs.
|
|
85
|
+
*/
|
|
86
|
+
function warnIfCorsDisabled(
|
|
87
|
+
cors: CorsResult,
|
|
88
|
+
warn: (message: string) => void = console.warn
|
|
89
|
+
): void {
|
|
90
|
+
if (cors !== false) return;
|
|
91
|
+
warn(
|
|
92
|
+
'[cors] CORS_ORIGIN is unset — cross-origin browser requests are blocked ' +
|
|
93
|
+
'(same-origin only), and they will fail silently from this server\'s side. ' +
|
|
94
|
+
'Set CORS_ORIGIN to your frontend origin(s), or to "*" for a public API ' +
|
|
95
|
+
'any browser may call.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export { createGracefulShutdown };
|
|
2
|
+
export type { GracefulShutdownOptions };
|
|
3
|
+
|
|
4
|
+
interface GracefulShutdownOptions {
|
|
5
|
+
/** Stop accepting connections and resolve once in-flight requests drain. */
|
|
6
|
+
closeServer: () => Promise<void>;
|
|
7
|
+
/** Resource teardown to run after the server closes (flush traces, close DB). */
|
|
8
|
+
closers?: Array<() => Promise<void>>;
|
|
9
|
+
/** Hard deadline; if draining/teardown exceeds it, force exit. */
|
|
10
|
+
timeoutMs: number;
|
|
11
|
+
/** Process exit hook — injectable for tests. */
|
|
12
|
+
onExit?: (code: number) => void;
|
|
13
|
+
/** Log sink — injectable for tests. */
|
|
14
|
+
log?: (message: string, error?: unknown) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build an idempotent shutdown handler. On the first invocation it drains the
|
|
19
|
+
* server, runs each closer (a failing closer is logged but doesn't abort the
|
|
20
|
+
* rest), then exits with the caller's code. A hard timeout guarantees the
|
|
21
|
+
* process exits even if a connection or teardown step hangs, and `onExit` is
|
|
22
|
+
* invoked at most once.
|
|
23
|
+
*
|
|
24
|
+
* Subsequent invocations (e.g. a second signal) are ignored.
|
|
25
|
+
*/
|
|
26
|
+
function createGracefulShutdown(options: GracefulShutdownOptions) {
|
|
27
|
+
const {
|
|
28
|
+
closeServer,
|
|
29
|
+
closers = [],
|
|
30
|
+
timeoutMs,
|
|
31
|
+
onExit = (code) => process.exit(code),
|
|
32
|
+
log = (message, error) =>
|
|
33
|
+
error !== undefined ? console.error(message, error) : console.info(message),
|
|
34
|
+
} = options;
|
|
35
|
+
|
|
36
|
+
let started = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `exitCode` is the code used when the drain succeeds; a crash-initiated
|
|
40
|
+
* shutdown must pass non-zero so supervisors still see a failed exit.
|
|
41
|
+
*/
|
|
42
|
+
return async function shutdown(reason: string, exitCode = 0): Promise<void> {
|
|
43
|
+
if (started) return;
|
|
44
|
+
started = true;
|
|
45
|
+
log(`Shutting down (${reason})…`);
|
|
46
|
+
|
|
47
|
+
let exited = false;
|
|
48
|
+
const exitOnce = (code: number) => {
|
|
49
|
+
if (exited) return;
|
|
50
|
+
exited = true;
|
|
51
|
+
onExit(code);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const forceTimer = setTimeout(() => {
|
|
55
|
+
log('Graceful shutdown timed out; forcing exit.');
|
|
56
|
+
exitOnce(1);
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await closeServer();
|
|
61
|
+
for (const close of closers) {
|
|
62
|
+
try {
|
|
63
|
+
await close();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
log('Error during shutdown step', error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
exitOnce(exitCode);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
log('Error closing server', error);
|
|
71
|
+
exitOnce(1);
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(forceTimer);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { maxDepthPlugin } from '@escape.tech/graphql-armor-max-depth';
|
|
2
|
+
import { maxAliasesPlugin } from '@escape.tech/graphql-armor-max-aliases';
|
|
3
|
+
import { maxTokensPlugin } from '@escape.tech/graphql-armor-max-tokens';
|
|
4
|
+
import { costLimitPlugin } from '@escape.tech/graphql-armor-cost-limit';
|
|
5
|
+
import { blockFieldSuggestionsPlugin } from '@escape.tech/graphql-armor-block-field-suggestions';
|
|
6
|
+
|
|
7
|
+
export { buildArmorPlugins, resolveArmorConfig, ARMOR_DEFAULTS };
|
|
8
|
+
export type { ArmorConfig };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Query-cost protections for the public GraphQL endpoint. Without these a single
|
|
12
|
+
* deeply-nested, heavily-aliased, or otherwise expensive query can be turned into
|
|
13
|
+
* a denial-of-service against the backing Postgres. The limits are deliberately
|
|
14
|
+
* conservative — they comfortably allow every query this API legitimately serves
|
|
15
|
+
* (the deepest known downstream probe is 7 levels) while rejecting abusive shapes before execution — and
|
|
16
|
+
* each is tunable via the environment.
|
|
17
|
+
*/
|
|
18
|
+
interface ArmorConfig {
|
|
19
|
+
/** Max selection-set nesting depth. */
|
|
20
|
+
maxDepth: number;
|
|
21
|
+
/** Max number of aliases in a single operation. */
|
|
22
|
+
maxAliases: number;
|
|
23
|
+
/** Max number of lexical tokens in a document. */
|
|
24
|
+
maxTokens: number;
|
|
25
|
+
/** Max estimated query cost (graphql-armor's depth/field heuristic). */
|
|
26
|
+
maxCost: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ARMOR_DEFAULTS: ArmorConfig = {
|
|
30
|
+
// Deepest query in production use is 7 (mina-explorer's SearchTransaction
|
|
31
|
+
// FULL-tier probe). Keep the default comfortably above that: an armor depth
|
|
32
|
+
// rejection aborts validation before clients see the `Cannot query field`
|
|
33
|
+
// message they use for schema-tier fallback.
|
|
34
|
+
maxDepth: 12,
|
|
35
|
+
maxAliases: 15,
|
|
36
|
+
maxTokens: 1000,
|
|
37
|
+
maxCost: 5000,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type EnvSource = Record<string, string | undefined>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a positive integer from an env value, falling back to `fallback` when it
|
|
44
|
+
* is missing or malformed. We never throw, so a stray typo can't silently remove
|
|
45
|
+
* a protection — it just reverts to the safe default.
|
|
46
|
+
*/
|
|
47
|
+
function intFromEnv(value: string | undefined, fallback: number): number {
|
|
48
|
+
if (value === undefined || value.trim() === '') return fallback;
|
|
49
|
+
const parsed = Number(value);
|
|
50
|
+
if (!Number.isInteger(parsed) || parsed < 1) return fallback;
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveArmorConfig(env: EnvSource = process.env): ArmorConfig {
|
|
55
|
+
return {
|
|
56
|
+
maxDepth: intFromEnv(env.GRAPHQL_MAX_DEPTH, ARMOR_DEFAULTS.maxDepth),
|
|
57
|
+
maxAliases: intFromEnv(env.GRAPHQL_MAX_ALIASES, ARMOR_DEFAULTS.maxAliases),
|
|
58
|
+
maxTokens: intFromEnv(env.GRAPHQL_MAX_TOKENS, ARMOR_DEFAULTS.maxTokens),
|
|
59
|
+
maxCost: intFromEnv(env.GRAPHQL_MAX_COST, ARMOR_DEFAULTS.maxCost),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the graphql-armor envelop plugins that enforce the configured limits.
|
|
65
|
+
* Introspection is ignored by the depth/cost rules so the GraphiQL explorer keeps
|
|
66
|
+
* working when it is explicitly enabled; field suggestions are always blocked so
|
|
67
|
+
* error messages don't leak schema shape (complementing `useDisableIntrospection`).
|
|
68
|
+
*/
|
|
69
|
+
function buildArmorPlugins(env: EnvSource = process.env) {
|
|
70
|
+
const config = resolveArmorConfig(env);
|
|
71
|
+
return [
|
|
72
|
+
maxDepthPlugin({ n: config.maxDepth, ignoreIntrospection: true }),
|
|
73
|
+
maxAliasesPlugin({ n: config.maxAliases }),
|
|
74
|
+
maxTokensPlugin({ n: config.maxTokens }),
|
|
75
|
+
costLimitPlugin({ maxCost: config.maxCost, ignoreIntrospection: true }),
|
|
76
|
+
blockFieldSuggestionsPlugin(),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import pino from 'pino';
|
|
3
|
+
import type { LogLevel } from 'graphql-yoga';
|
|
4
|
+
|
|
5
|
+
export { createLogger, logger, resolveLogLevel, resolveYogaLogLevel };
|
|
6
|
+
export type { Logger };
|
|
7
|
+
|
|
8
|
+
type Logger = pino.Logger;
|
|
9
|
+
type PinoLogLevel =
|
|
10
|
+
| 'trace'
|
|
11
|
+
| 'debug'
|
|
12
|
+
| 'info'
|
|
13
|
+
| 'warn'
|
|
14
|
+
| 'error'
|
|
15
|
+
| 'fatal'
|
|
16
|
+
| 'silent';
|
|
17
|
+
|
|
18
|
+
const VALID_LEVELS = new Set<PinoLogLevel>([
|
|
19
|
+
'trace',
|
|
20
|
+
'debug',
|
|
21
|
+
'info',
|
|
22
|
+
'warn',
|
|
23
|
+
'error',
|
|
24
|
+
'fatal',
|
|
25
|
+
'silent',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function resolveLogLevel(
|
|
29
|
+
env: Record<string, string | undefined> = process.env
|
|
30
|
+
): PinoLogLevel {
|
|
31
|
+
const requested = (env.LOG_LEVEL ?? 'info').toLowerCase();
|
|
32
|
+
return VALID_LEVELS.has(requested as PinoLogLevel)
|
|
33
|
+
? (requested as PinoLogLevel)
|
|
34
|
+
: 'info';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveYogaLogLevel(
|
|
38
|
+
env: Record<string, string | undefined> = process.env
|
|
39
|
+
): LogLevel | false {
|
|
40
|
+
const level = resolveLogLevel(env);
|
|
41
|
+
if (level === 'trace') return 'debug';
|
|
42
|
+
if (level === 'fatal') return 'error';
|
|
43
|
+
if (level === 'silent') return false;
|
|
44
|
+
return level;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the application logger. Emits structured JSON (one object per line) with
|
|
49
|
+
* ISO timestamps, suitable for log aggregation, and is independent of the
|
|
50
|
+
* optional Jaeger tracing. The level comes from `LOG_LEVEL`; an unrecognised
|
|
51
|
+
* value falls back to `info` rather than throwing at startup.
|
|
52
|
+
*/
|
|
53
|
+
function createLogger(
|
|
54
|
+
env: Record<string, string | undefined> = process.env
|
|
55
|
+
): Logger {
|
|
56
|
+
const level = resolveLogLevel(env);
|
|
57
|
+
return pino({
|
|
58
|
+
level,
|
|
59
|
+
base: {
|
|
60
|
+
pid: process.pid,
|
|
61
|
+
hostname: hostname(),
|
|
62
|
+
service: env.JAEGER_SERVICE_NAME ?? 'archive-api',
|
|
63
|
+
},
|
|
64
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
65
|
+
formatters: {
|
|
66
|
+
// Log the level name ("info") instead of its numeric code.
|
|
67
|
+
level: (label) => ({ level: label }),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const logger = createLogger();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Plugin } from 'graphql-yoga';
|
|
2
|
+
import {
|
|
3
|
+
Registry,
|
|
4
|
+
collectDefaultMetrics,
|
|
5
|
+
Counter,
|
|
6
|
+
Histogram,
|
|
7
|
+
Gauge,
|
|
8
|
+
} from 'prom-client';
|
|
9
|
+
|
|
10
|
+
export { createMetrics, useMetrics, METRICS_PATH };
|
|
11
|
+
export type { Metrics };
|
|
12
|
+
|
|
13
|
+
const METRICS_PATH = '/metrics';
|
|
14
|
+
|
|
15
|
+
/** Known routes — anything else is bucketed as `other` to bound label cardinality. */
|
|
16
|
+
const KNOWN_ROUTES = new Set(['/', '/healthcheck', '/readiness', METRICS_PATH]);
|
|
17
|
+
|
|
18
|
+
interface Metrics {
|
|
19
|
+
registry: Registry;
|
|
20
|
+
requestsTotal: Counter<'method' | 'route' | 'status'>;
|
|
21
|
+
requestDuration: Histogram<'method' | 'route' | 'status'>;
|
|
22
|
+
inFlight: Gauge;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a Prometheus registry with RED HTTP metrics (rate, errors, duration) and,
|
|
27
|
+
* unless disabled, the standard Node process metrics (CPU, memory, event loop,
|
|
28
|
+
* GC). `collectDefault` is off in tests to avoid leaving background collectors.
|
|
29
|
+
*/
|
|
30
|
+
function createMetrics({ collectDefault = true } = {}): Metrics {
|
|
31
|
+
const registry = new Registry();
|
|
32
|
+
if (collectDefault) collectDefaultMetrics({ register: registry });
|
|
33
|
+
|
|
34
|
+
const requestsTotal = new Counter({
|
|
35
|
+
name: 'http_requests_total',
|
|
36
|
+
help: 'Total HTTP requests',
|
|
37
|
+
labelNames: ['method', 'route', 'status'] as const,
|
|
38
|
+
registers: [registry],
|
|
39
|
+
});
|
|
40
|
+
const requestDuration = new Histogram({
|
|
41
|
+
name: 'http_request_duration_seconds',
|
|
42
|
+
help: 'HTTP request duration in seconds',
|
|
43
|
+
labelNames: ['method', 'route', 'status'] as const,
|
|
44
|
+
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
|
45
|
+
registers: [registry],
|
|
46
|
+
});
|
|
47
|
+
const inFlight = new Gauge({
|
|
48
|
+
name: 'http_requests_in_flight',
|
|
49
|
+
help: 'HTTP requests currently being processed',
|
|
50
|
+
registers: [registry],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return { registry, requestsTotal, requestDuration, inFlight };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function routeOf(rawUrl: string): string {
|
|
57
|
+
let path: string;
|
|
58
|
+
try {
|
|
59
|
+
path = new URL(rawUrl).pathname;
|
|
60
|
+
} catch {
|
|
61
|
+
return 'other';
|
|
62
|
+
}
|
|
63
|
+
return KNOWN_ROUTES.has(path) ? path : 'other';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Yoga plugin that serves the Prometheus exposition at `/metrics` and records RED
|
|
68
|
+
* metrics for every other request. The `/metrics` scrape itself is not counted.
|
|
69
|
+
*/
|
|
70
|
+
function useMetrics(metrics: Metrics = createMetrics()): Plugin {
|
|
71
|
+
const startTimes = new WeakMap<Request, number>();
|
|
72
|
+
return {
|
|
73
|
+
async onRequest({ request, url, endResponse, fetchAPI }) {
|
|
74
|
+
if (url.pathname === METRICS_PATH) {
|
|
75
|
+
endResponse(
|
|
76
|
+
new fetchAPI.Response(await metrics.registry.metrics(), {
|
|
77
|
+
status: 200,
|
|
78
|
+
headers: { 'content-type': metrics.registry.contentType },
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
metrics.inFlight.inc();
|
|
84
|
+
startTimes.set(request, Date.now());
|
|
85
|
+
},
|
|
86
|
+
onResponse({ request, response }) {
|
|
87
|
+
const route = routeOf(request.url);
|
|
88
|
+
if (route === METRICS_PATH) return;
|
|
89
|
+
|
|
90
|
+
const labels = {
|
|
91
|
+
method: request.method,
|
|
92
|
+
route,
|
|
93
|
+
status: String(response.status),
|
|
94
|
+
};
|
|
95
|
+
metrics.requestsTotal.inc(labels);
|
|
96
|
+
const start = startTimes.get(request);
|
|
97
|
+
if (start === undefined) return;
|
|
98
|
+
|
|
99
|
+
startTimes.delete(request);
|
|
100
|
+
metrics.inFlight.dec();
|
|
101
|
+
metrics.requestDuration.observe(labels, (Date.now() - start) / 1000);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/server/plugins.ts
CHANGED
|
@@ -2,19 +2,45 @@ import { useLogger } from '@envelop/core';
|
|
|
2
2
|
import { useGraphQlJit } from '@envelop/graphql-jit';
|
|
3
3
|
import { useDisableIntrospection } from '@envelop/disable-introspection';
|
|
4
4
|
import { useOpenTelemetry } from '@envelop/opentelemetry';
|
|
5
|
-
import {
|
|
5
|
+
import type { GraphQLError } from 'graphql';
|
|
6
6
|
|
|
7
|
+
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
|
|
7
8
|
import { initJaegerProvider } from '../tracing/jaeger-tracing.js';
|
|
9
|
+
import { parseBoolean } from '../config.js';
|
|
10
|
+
import { useMetrics } from './metrics.js';
|
|
11
|
+
import { useRateLimit } from './rate-limit.js';
|
|
12
|
+
import { buildArmorPlugins } from './graphql-armor.js';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
import { useRequestLogging, requestIdFor } from './request-logging.js';
|
|
8
15
|
|
|
9
16
|
export { buildPlugins };
|
|
10
17
|
|
|
11
18
|
async function buildPlugins() {
|
|
12
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
20
|
const plugins: any[] = [];
|
|
21
|
+
|
|
22
|
+
// Structured per-request access logging with correlation ids.
|
|
23
|
+
plugins.push(useRequestLogging());
|
|
24
|
+
|
|
25
|
+
// Per-IP request rate limiting. Runs on every request before GraphQL parsing,
|
|
26
|
+
// so over-limit traffic is rejected as cheaply as possible.
|
|
27
|
+
plugins.push(useRateLimit());
|
|
28
|
+
|
|
29
|
+
// Query-cost protections (depth / aliases / tokens / cost). These reject
|
|
30
|
+
// abusive query shapes before execution.
|
|
31
|
+
plugins.push(...buildArmorPlugins());
|
|
32
|
+
|
|
33
|
+
if (parseBoolean(process.env.ENABLE_METRICS)) {
|
|
34
|
+
// Prometheus /metrics endpoint + RED metrics for every request.
|
|
35
|
+
plugins.push(useMetrics());
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
plugins.push(useGraphQlJit());
|
|
15
39
|
|
|
16
|
-
|
|
17
|
-
|
|
40
|
+
// Returned so the entry point can flush spans on shutdown.
|
|
41
|
+
let provider: BasicTracerProvider | undefined;
|
|
42
|
+
if (parseBoolean(process.env.ENABLE_LOGGING)) {
|
|
43
|
+
provider = await initJaegerProvider();
|
|
18
44
|
plugins.push(
|
|
19
45
|
useOpenTelemetry(
|
|
20
46
|
{
|
|
@@ -22,35 +48,43 @@ async function buildPlugins() {
|
|
|
22
48
|
variables: true, // Includes the operation variables values as part of the metadata collected
|
|
23
49
|
result: true, // Includes execution result object as part of the metadata collected
|
|
24
50
|
},
|
|
25
|
-
|
|
51
|
+
// BasicTracerProvider satisfies the TracerProvider interface; the cast
|
|
52
|
+
// bridges duplicate @opentelemetry/api copies across packages by
|
|
53
|
+
// targeting the exact parameter type useOpenTelemetry expects.
|
|
54
|
+
provider as unknown as Parameters<typeof useOpenTelemetry>[1]
|
|
26
55
|
)
|
|
27
56
|
);
|
|
28
57
|
}
|
|
29
58
|
|
|
30
|
-
if (!process.env.ENABLE_INTROSPECTION) {
|
|
59
|
+
if (!parseBoolean(process.env.ENABLE_INTROSPECTION)) {
|
|
31
60
|
plugins.push(useDisableIntrospection());
|
|
32
61
|
}
|
|
33
62
|
|
|
34
63
|
plugins.push(
|
|
35
64
|
useLogger({
|
|
36
|
-
logFn: (
|
|
65
|
+
logFn: (_eventName, args) => {
|
|
37
66
|
if (args?.result?.errors) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
const request = args?.args?.contextValue?.request as
|
|
68
|
+
| Request
|
|
69
|
+
| undefined;
|
|
70
|
+
logger.error(
|
|
71
|
+
{
|
|
72
|
+
requestId: request ? requestIdFor(request) : undefined,
|
|
73
|
+
variables: args?.args?.variableValues,
|
|
74
|
+
errors: args.result.errors.map((error: GraphQLError) => ({
|
|
75
|
+
message: error.message,
|
|
76
|
+
path: error.path,
|
|
77
|
+
locations: error.locations,
|
|
78
|
+
stack: error.originalError?.stack ?? error.stack,
|
|
79
|
+
code: (error.originalError as { code?: string } | undefined)
|
|
80
|
+
?.code,
|
|
81
|
+
})),
|
|
82
|
+
},
|
|
83
|
+
'graphql execution errors'
|
|
50
84
|
);
|
|
51
85
|
}
|
|
52
86
|
},
|
|
53
87
|
})
|
|
54
88
|
);
|
|
55
|
-
return plugins;
|
|
89
|
+
return { plugins, provider };
|
|
56
90
|
}
|