@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.
Files changed (176) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +92 -268
  3. package/build/src/blockchain/utils.js.map +1 -1
  4. package/build/src/config.js +86 -0
  5. package/build/src/config.js.map +1 -0
  6. package/build/src/db/archive-node-adapter/archive-node-adapter.js +29 -2
  7. package/build/src/db/archive-node-adapter/archive-node-adapter.js.map +1 -1
  8. package/build/src/db/archive-node-adapter/postgres-options.js +35 -0
  9. package/build/src/db/archive-node-adapter/postgres-options.js.map +1 -0
  10. package/build/src/db/sql/best-chain.js +5 -0
  11. package/build/src/db/sql/best-chain.js.map +1 -0
  12. package/build/src/db/sql/events-actions/queries.js +102 -21
  13. package/build/src/db/sql/events-actions/queries.js.map +1 -1
  14. package/build/src/db/sql/verification-key-updates/queries.js +100 -0
  15. package/build/src/db/sql/verification-key-updates/queries.js.map +1 -0
  16. package/build/src/db/sql/zkapp-commands/queries.js +363 -0
  17. package/build/src/db/sql/zkapp-commands/queries.js.map +1 -0
  18. package/build/src/db/sql/zkapp-commands/types.js +2 -0
  19. package/build/src/db/sql/zkapp-commands/types.js.map +1 -0
  20. package/build/src/errors/error.js +4 -1
  21. package/build/src/errors/error.js.map +1 -1
  22. package/build/src/index.js +44 -7
  23. package/build/src/index.js.map +1 -1
  24. package/build/src/resolvers.js +42 -23
  25. package/build/src/resolvers.js.map +1 -1
  26. package/build/src/schema-version.js +2 -0
  27. package/build/src/schema-version.js.map +1 -0
  28. package/build/src/server/cors.js +41 -0
  29. package/build/src/server/cors.js.map +1 -0
  30. package/build/src/server/graceful-shutdown.js +42 -0
  31. package/build/src/server/graceful-shutdown.js.map +1 -0
  32. package/build/src/server/graphql-armor.js +39 -0
  33. package/build/src/server/graphql-armor.js.map +1 -0
  34. package/build/src/server/logger.js +45 -0
  35. package/build/src/server/logger.js.map +1 -0
  36. package/build/src/server/metrics.js +72 -0
  37. package/build/src/server/metrics.js.map +1 -0
  38. package/build/src/server/plugins.js +31 -15
  39. package/build/src/server/plugins.js.map +1 -1
  40. package/build/src/server/rate-limit.js +143 -0
  41. package/build/src/server/rate-limit.js.map +1 -0
  42. package/build/src/server/readiness.js +45 -0
  43. package/build/src/server/readiness.js.map +1 -0
  44. package/build/src/server/request-logging.js +58 -0
  45. package/build/src/server/request-logging.js.map +1 -0
  46. package/build/src/server/server.js +38 -13
  47. package/build/src/server/server.js.map +1 -1
  48. package/build/src/services/actions-service/actions-service.js +26 -15
  49. package/build/src/services/actions-service/actions-service.js.map +1 -1
  50. package/build/src/services/blocks-service/blocks-service.js +10 -9
  51. package/build/src/services/blocks-service/blocks-service.js.map +1 -1
  52. package/build/src/services/network-service/network-service.js +17 -9
  53. package/build/src/services/network-service/network-service.js.map +1 -1
  54. package/build/src/services/verification-key-updates-service/verification-key-updates-service.interface.js +2 -0
  55. package/build/src/services/verification-key-updates-service/verification-key-updates-service.interface.js.map +1 -0
  56. package/build/src/services/verification-key-updates-service/verification-key-updates-service.js +41 -0
  57. package/build/src/services/verification-key-updates-service/verification-key-updates-service.js.map +1 -0
  58. package/build/src/services/zkapp-commands-service/zkapp-commands-service.interface.js +2 -0
  59. package/build/src/services/zkapp-commands-service/zkapp-commands-service.interface.js.map +1 -0
  60. package/build/src/services/zkapp-commands-service/zkapp-commands-service.js +115 -0
  61. package/build/src/services/zkapp-commands-service/zkapp-commands-service.js.map +1 -0
  62. package/build/src/tracing/jaeger-tracing.js +2 -1
  63. package/build/src/tracing/jaeger-tracing.js.map +1 -1
  64. package/build/tests/devnet-dump/action-state-invariants.js +165 -0
  65. package/build/tests/devnet-dump/action-state-invariants.js.map +1 -0
  66. package/build/tests/devnet-dump/devnet-dump.test.js +24 -10
  67. package/build/tests/devnet-dump/devnet-dump.test.js.map +1 -1
  68. package/build/tests/devnet-dump/setup.js +63 -17
  69. package/build/tests/devnet-dump/setup.js.map +1 -1
  70. package/build/tests/integration/action-state-ordering.test.js +139 -0
  71. package/build/tests/integration/action-state-ordering.test.js.map +1 -0
  72. package/build/tests/integration/action-state-setup.js +48 -0
  73. package/build/tests/integration/action-state-setup.js.map +1 -0
  74. package/build/tests/integration/consensus-vs-chain.test.js +81 -0
  75. package/build/tests/integration/consensus-vs-chain.test.js.map +1 -0
  76. package/build/tests/integration/integration.test.js +166 -2
  77. package/build/tests/integration/integration.test.js.map +1 -1
  78. package/build/tests/integration/postgres-timeout.test.js +92 -0
  79. package/build/tests/integration/postgres-timeout.test.js.map +1 -0
  80. package/build/tests/integration/setup.js +58 -1
  81. package/build/tests/integration/setup.js.map +1 -1
  82. package/build/tests/integration/verification-key-setup.js +49 -0
  83. package/build/tests/integration/verification-key-setup.js.map +1 -0
  84. package/build/tests/integration/verification-key-updates.test.js +159 -0
  85. package/build/tests/integration/verification-key-updates.test.js.map +1 -0
  86. package/build/tests/live-api/events.test.js +11 -7
  87. package/build/tests/live-api/events.test.js.map +1 -1
  88. package/build/tests/live-api/networkState.test.js +1 -0
  89. package/build/tests/live-api/networkState.test.js.map +1 -1
  90. package/build/tests/live-network/live-network.test.js +1 -0
  91. package/build/tests/live-network/live-network.test.js.map +1 -1
  92. package/build/tests/resolvers.test.js +65 -37
  93. package/build/tests/resolvers.test.js.map +1 -1
  94. package/build/tests/services/verification-key-updates-service/verification-key-updates-service.test.js +35 -0
  95. package/build/tests/services/verification-key-updates-service/verification-key-updates-service.test.js.map +1 -0
  96. package/build/tests/services/zkapp-commands-service/zkapp-commands-service.test.js +159 -0
  97. package/build/tests/services/zkapp-commands-service/zkapp-commands-service.test.js.map +1 -0
  98. package/build/tests/test-helpers.js +15 -0
  99. package/build/tests/test-helpers.js.map +1 -1
  100. package/build/tests/unit/best-chain-anchor.test.js +70 -0
  101. package/build/tests/unit/best-chain-anchor.test.js.map +1 -0
  102. package/build/tests/unit/config.test.js +106 -0
  103. package/build/tests/unit/config.test.js.map +1 -0
  104. package/build/tests/unit/cors.test.js +95 -0
  105. package/build/tests/unit/cors.test.js.map +1 -0
  106. package/build/tests/unit/error-masking.test.js +119 -0
  107. package/build/tests/unit/error-masking.test.js.map +1 -0
  108. package/build/tests/unit/graceful-shutdown.test.js +99 -0
  109. package/build/tests/unit/graceful-shutdown.test.js.map +1 -0
  110. package/build/tests/unit/graphql-armor.test.js +121 -0
  111. package/build/tests/unit/graphql-armor.test.js.map +1 -0
  112. package/build/tests/unit/logging.test.js +120 -0
  113. package/build/tests/unit/logging.test.js.map +1 -0
  114. package/build/tests/unit/metrics.test.js +114 -0
  115. package/build/tests/unit/metrics.test.js.map +1 -0
  116. package/build/tests/unit/network-service.test.js +66 -0
  117. package/build/tests/unit/network-service.test.js.map +1 -0
  118. package/build/tests/unit/postgres-options.test.js +56 -0
  119. package/build/tests/unit/postgres-options.test.js.map +1 -0
  120. package/build/tests/unit/rate-limit.test.js +271 -0
  121. package/build/tests/unit/rate-limit.test.js.map +1 -0
  122. package/build/tests/unit/readiness.test.js +75 -0
  123. package/build/tests/unit/readiness.test.js.map +1 -0
  124. package/build/tests/unit/schema-nullability.test.js +40 -0
  125. package/build/tests/unit/schema-nullability.test.js.map +1 -0
  126. package/build/tests/unit/schema-version.test.js +48 -0
  127. package/build/tests/unit/schema-version.test.js.map +1 -0
  128. package/build/tests/unit/yoga-http-contract.test.js +30 -0
  129. package/build/tests/unit/yoga-http-contract.test.js.map +1 -0
  130. package/build/tests/unit/zkapp-commands-gating.test.js +38 -0
  131. package/build/tests/unit/zkapp-commands-gating.test.js.map +1 -0
  132. package/build/zkapp/utils.js +2 -0
  133. package/build/zkapp/utils.js.map +1 -1
  134. package/package.json +33 -14
  135. package/schema.graphql +126 -8
  136. package/src/blockchain/types.ts +65 -1
  137. package/src/blockchain/utils.ts +26 -2
  138. package/src/config.ts +133 -0
  139. package/src/db/archive-node-adapter/archive-node-adapter.interface.ts +26 -3
  140. package/src/db/archive-node-adapter/archive-node-adapter.ts +65 -2
  141. package/src/db/archive-node-adapter/postgres-options.ts +90 -0
  142. package/src/db/sql/best-chain.ts +34 -0
  143. package/src/db/sql/events-actions/queries.ts +168 -24
  144. package/src/db/sql/verification-key-updates/queries.ts +132 -0
  145. package/src/db/sql/zkapp-commands/queries.ts +406 -0
  146. package/src/db/sql/zkapp-commands/types.ts +43 -0
  147. package/src/envionment.d.ts +20 -0
  148. package/src/errors/error.ts +17 -1
  149. package/src/index.ts +64 -7
  150. package/src/resolvers-types.ts +311 -24
  151. package/src/resolvers.ts +70 -34
  152. package/src/schema-version.ts +24 -0
  153. package/src/server/cors.ts +97 -0
  154. package/src/server/graceful-shutdown.ts +76 -0
  155. package/src/server/graphql-armor.ts +78 -0
  156. package/src/server/logger.ts +72 -0
  157. package/src/server/metrics.ts +104 -0
  158. package/src/server/plugins.ts +53 -19
  159. package/src/server/rate-limit.ts +274 -0
  160. package/src/server/readiness.ts +78 -0
  161. package/src/server/request-logging.ts +83 -0
  162. package/src/server/server.ts +70 -15
  163. package/src/services/actions-service/actions-service.ts +87 -24
  164. package/src/services/blocks-service/blocks-service.ts +16 -14
  165. package/src/services/network-service/network-service.ts +30 -12
  166. package/src/services/verification-key-updates-service/verification-key-updates-service.interface.ts +9 -0
  167. package/src/services/verification-key-updates-service/verification-key-updates-service.ts +72 -0
  168. package/src/services/zkapp-commands-service/zkapp-commands-service.interface.ts +9 -0
  169. package/src/services/zkapp-commands-service/zkapp-commands-service.ts +181 -0
  170. package/src/tracing/jaeger-tracing.ts +2 -1
  171. package/build/tests/consensus/run-compare.js +0 -40
  172. package/build/tests/consensus/run-compare.js.map +0 -1
  173. package/build/tests/consensus/select-consensus-precomputed.js +0 -80
  174. package/build/tests/consensus/select-consensus-precomputed.js.map +0 -1
  175. package/build/tests/consensus/types.js +0 -15
  176. 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
+ }
@@ -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 { inspect } from 'node:util';
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
- if (process.env.ENABLE_LOGGING) {
17
- const provider = await initJaegerProvider();
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
- provider
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: (eventName, args) => {
65
+ logFn: (_eventName, args) => {
37
66
  if (args?.result?.errors) {
38
- console.debug(
39
- eventName,
40
- inspect(args.args.contextValue.params, {
41
- showHidden: false,
42
- depth: null,
43
- colors: true,
44
- }),
45
- inspect(args.result.errors, {
46
- showHidden: false,
47
- depth: null,
48
- colors: true,
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
  }