@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,274 @@
1
+ import type { Plugin } from 'graphql-yoga';
2
+
3
+ export {
4
+ useRateLimit,
5
+ createRateLimiter,
6
+ resolveRateLimitConfig,
7
+ RATE_LIMIT_DEFAULTS,
8
+ };
9
+ export type { RateLimitConfig };
10
+
11
+ /** Probes must never be throttled, so orchestrators keep working. */
12
+ const EXEMPT_PATHS = new Set(['/healthcheck', '/readiness']);
13
+
14
+ /**
15
+ * Global, per-client-IP request rate limiting. A public GraphQL endpoint with no
16
+ * throttle lets one client monopolise the server and the backing Postgres. This
17
+ * is a coarse first line of defence applied before any GraphQL work happens.
18
+ *
19
+ * The counter is in-memory and therefore per-instance: behind multiple replicas
20
+ * each enforces its own share of the limit. A shared store (e.g. Redis) is the
21
+ * follow-up for exact cross-replica limits — see the deployment hardening issue.
22
+ */
23
+ interface RateLimitConfig {
24
+ /** Max requests allowed per client per window. `0` disables rate limiting. */
25
+ max: number;
26
+ /** Length of the fixed window in milliseconds. */
27
+ windowMs: number;
28
+ /**
29
+ * Number of trusted proxy hops in front of this server. `0` ignores
30
+ * forwarding headers entirely and keys on the socket address, which is the
31
+ * only safe reading when the server is directly exposed.
32
+ */
33
+ trustProxy: number;
34
+ /**
35
+ * Whether TRUST_PROXY was explicitly and validly set. No default is correct
36
+ * for both topologies: socket-keying behind a load balancer puts every client
37
+ * in one bucket, while trusting X-Forwarded-For without a hop count lets any
38
+ * caller mint a fresh bucket.
39
+ */
40
+ trustProxyConfigured: boolean;
41
+ }
42
+
43
+ const RATE_LIMIT_DEFAULTS: RateLimitConfig = {
44
+ max: 600,
45
+ windowMs: 60_000,
46
+ trustProxy: 0,
47
+ trustProxyConfigured: false,
48
+ };
49
+
50
+ type EnvSource = Record<string, string | undefined>;
51
+
52
+ /**
53
+ * Parse a non-negative integer from an env value, falling back to `fallback` when
54
+ * it is missing or malformed. We never throw, so a typo can't disable the limiter
55
+ * by accident — it reverts to the safe default.
56
+ */
57
+ function intFromEnv(value: string | undefined, fallback: number): number {
58
+ if (value === undefined || value.trim() === '') return fallback;
59
+ const parsed = Number(value);
60
+ if (!Number.isInteger(parsed) || parsed < 0) return fallback;
61
+ return parsed;
62
+ }
63
+
64
+ /**
65
+ * Parse TRUST_PROXY strictly. Missing or malformed means "not configured", not
66
+ * "directly exposed", so a typo cannot turn into a global shared bucket behind
67
+ * a load balancer.
68
+ */
69
+ function hopsFromEnv(value: string | undefined): number | undefined {
70
+ if (value === undefined || value.trim() === '') return undefined;
71
+ const parsed = Number(value);
72
+ if (!Number.isInteger(parsed) || parsed < 0) return undefined;
73
+ return parsed;
74
+ }
75
+
76
+ function resolveRateLimitConfig(env: EnvSource = process.env): RateLimitConfig {
77
+ const hops = hopsFromEnv(env.TRUST_PROXY);
78
+ return {
79
+ max: intFromEnv(env.RATE_LIMIT_MAX, RATE_LIMIT_DEFAULTS.max),
80
+ windowMs: Math.max(
81
+ 1,
82
+ intFromEnv(env.RATE_LIMIT_WINDOW_MS, RATE_LIMIT_DEFAULTS.windowMs)
83
+ ),
84
+ trustProxy: hops ?? RATE_LIMIT_DEFAULTS.trustProxy,
85
+ trustProxyConfigured: hops !== undefined,
86
+ };
87
+ }
88
+
89
+ interface RateLimitResult {
90
+ allowed: boolean;
91
+ limit: number;
92
+ remaining: number;
93
+ resetAt: number;
94
+ }
95
+
96
+ /**
97
+ * Fixed-window counter keyed by client id. `now` is injectable so the windowing
98
+ * logic can be tested deterministically. Counting is independent of how the id
99
+ * is derived, so this takes only the window options.
100
+ */
101
+ function createRateLimiter(
102
+ config: Pick<RateLimitConfig, 'max' | 'windowMs'>,
103
+ now: () => number = Date.now
104
+ ) {
105
+ const buckets = new Map<string, { count: number; resetAt: number }>();
106
+
107
+ function check(id: string): RateLimitResult {
108
+ const time = now();
109
+ let bucket = buckets.get(id);
110
+ if (!bucket || time >= bucket.resetAt) {
111
+ bucket = { count: 0, resetAt: time + config.windowMs };
112
+ buckets.set(id, bucket);
113
+ }
114
+ bucket.count += 1;
115
+ return {
116
+ allowed: bucket.count <= config.max,
117
+ limit: config.max,
118
+ remaining: Math.max(0, config.max - bucket.count),
119
+ resetAt: bucket.resetAt,
120
+ };
121
+ }
122
+
123
+ /** Drop expired buckets so memory doesn't grow with unique client count. */
124
+ function prune(): void {
125
+ const time = now();
126
+ for (const [id, bucket] of buckets) {
127
+ if (time >= bucket.resetAt) buckets.delete(id);
128
+ }
129
+ }
130
+
131
+ return { check, prune, size: () => buckets.size };
132
+ }
133
+
134
+ type MaybeNodeSocket = {
135
+ req?: { socket?: { remoteAddress?: string } };
136
+ socket?: { remoteAddress?: string };
137
+ };
138
+
139
+ function socketAddress(serverContext: unknown): string {
140
+ const ctx = serverContext as MaybeNodeSocket;
141
+ return ctx?.req?.socket?.remoteAddress ?? ctx?.socket?.remoteAddress ?? 'unknown';
142
+ }
143
+
144
+ /**
145
+ * Identify the client, trusting forwarding headers only as far as `trustProxy`
146
+ * hops allow.
147
+ *
148
+ * `X-Forwarded-For` is client-supplied and every proxy *appends* to it, so the
149
+ * left-hand entries are whatever the caller sent and cannot be trusted: reading
150
+ * the first hop lets one source rotate the header to mint a fresh bucket per
151
+ * request and evade the limit entirely (while growing the bucket Map). Only the
152
+ * rightmost entries — appended by proxies we actually control — are meaningful,
153
+ * so with N trusted hops the real client is the Nth entry from the right.
154
+ *
155
+ * Honouring the header at all still matters: it is what keeps NAT'd and
156
+ * LB-fronted clients in their own buckets instead of collapsing onto one shared
157
+ * address. Hence a hop count rather than a blanket on/off.
158
+ */
159
+ function clientId(
160
+ request: Request,
161
+ serverContext: unknown,
162
+ trustProxy: number
163
+ ): string {
164
+ if (trustProxy <= 0) return socketAddress(serverContext);
165
+
166
+ const forwarded = request.headers
167
+ .get('x-forwarded-for')
168
+ ?.split(',')
169
+ .map((part) => part.trim())
170
+ .filter(Boolean);
171
+ if (forwarded?.length) {
172
+ // Undefined when the chain is shorter than the configured hop count (a
173
+ // misconfiguration, or a request injected inside the trust boundary) —
174
+ // fall through to the socket rather than trust an attacker-chosen entry.
175
+ const client = forwarded[forwarded.length - trustProxy];
176
+ if (client) return client;
177
+ }
178
+
179
+ // Single-valued and overwritten by the adjacent proxy, so it carries no hop
180
+ // structure; only meaningful once we've established there is a proxy at all.
181
+ const realIp = request.headers.get('x-real-ip')?.trim();
182
+ if (realIp) return realIp;
183
+
184
+ return socketAddress(serverContext);
185
+ }
186
+
187
+ /**
188
+ * Yoga plugin that rejects over-limit requests with HTTP 429 before they reach
189
+ * GraphQL parsing/execution. Returns a no-op plugin when `max` is 0 (disabled).
190
+ */
191
+ function useRateLimit(
192
+ env: EnvSource = process.env,
193
+ warn: (message: string) => void = console.warn
194
+ ): Plugin {
195
+ const config = resolveRateLimitConfig(env);
196
+ if (config.max <= 0) return {};
197
+
198
+ if (!config.trustProxyConfigured) {
199
+ warn(
200
+ '[rate-limit] TRUST_PROXY is not set — rate limiting is DISABLED. No ' +
201
+ 'default is safe: keying on the socket address behind a load balancer ' +
202
+ 'puts every client in one bucket, and trusting X-Forwarded-For without ' +
203
+ 'a hop count lets any caller bypass the limit. Set TRUST_PROXY=0 for ' +
204
+ 'a directly-exposed server, or to the number of proxy hops in front of ' +
205
+ 'this API. NOTE: a GCP external Application Load Balancer appends TWO ' +
206
+ 'entries (client IP, then forwarding-rule IP), so a bare GCP LB is 2, ' +
207
+ 'plus 1 per extra in-cluster proxy. Set RATE_LIMIT_MAX=0 to silence this.'
208
+ );
209
+ return {};
210
+ }
211
+
212
+ const limiter = createRateLimiter(config);
213
+
214
+ // TRUST_PROXY=0 is the safe default, but it's wrong for the expected
215
+ // production topology, and the symptom — every client sharing one bucket
216
+ // behind the LB — otherwise appears only as unexplained throttling. Warn
217
+ // once on the first forwarded request rather than per request.
218
+ let warnedAboutProxy = config.trustProxy > 0;
219
+ const warnIfProxied = (request: Request) => {
220
+ const chain = request.headers.get('x-forwarded-for');
221
+ if (warnedAboutProxy || chain === null) return;
222
+ warnedAboutProxy = true;
223
+ const hops = chain.split(',').filter((part) => part.trim()).length;
224
+ warn(
225
+ '[rate-limit] TRUST_PROXY=0 but requests carry X-Forwarded-For — every ' +
226
+ 'client behind the proxy shares one rate-limit bucket. Set TRUST_PROXY ' +
227
+ `to the number of proxy hops in front of this API (observed chain ` +
228
+ `length: ${hops}) to bucket clients individually.`
229
+ );
230
+ };
231
+ // Periodically reclaim memory from expired buckets; unref'd so it never keeps
232
+ // the process alive on shutdown.
233
+ const sweep = setInterval(() => limiter.prune(), config.windowMs);
234
+ if (typeof sweep.unref === 'function') sweep.unref();
235
+
236
+ return {
237
+ onRequest({ request, serverContext, url, endResponse, fetchAPI }) {
238
+ if (EXEMPT_PATHS.has(url.pathname)) return;
239
+ // Yoga's CORS plugin answers OPTIONS before user-land plugins today, but
240
+ // keep the exemption local too: a throttled preflight is an opaque browser
241
+ // CORS error rather than a useful 429.
242
+ if (request.method === 'OPTIONS') return;
243
+ warnIfProxied(request);
244
+
245
+ const result = limiter.check(
246
+ clientId(request, serverContext, config.trustProxy)
247
+ );
248
+ if (result.allowed) return;
249
+
250
+ const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
251
+ endResponse(
252
+ new fetchAPI.Response(
253
+ JSON.stringify({
254
+ errors: [
255
+ {
256
+ message: 'Too many requests. Please retry later.',
257
+ extensions: { code: 'RATE_LIMITED' },
258
+ },
259
+ ],
260
+ }),
261
+ {
262
+ status: 429,
263
+ headers: {
264
+ 'content-type': 'application/json',
265
+ 'retry-after': String(retryAfter),
266
+ 'x-ratelimit-limit': String(result.limit),
267
+ 'x-ratelimit-remaining': String(result.remaining),
268
+ },
269
+ }
270
+ )
271
+ );
272
+ },
273
+ };
274
+ }
@@ -0,0 +1,78 @@
1
+ import type { Plugin } from 'graphql-yoga';
2
+ import type { DatabaseAdapter } from '../db/archive-node-adapter/archive-node-adapter.interface.js';
3
+
4
+ export { useReadiness, READINESS_PATH };
5
+
6
+ /**
7
+ * Readiness endpoint path. Distinct from Yoga's built-in `/healthcheck`, which is
8
+ * a *liveness* probe (the process is up). Readiness additionally checks that the
9
+ * database is reachable, so an orchestrator can stop routing traffic to an
10
+ * instance whose Postgres is down without killing the pod outright.
11
+ */
12
+ const READINESS_PATH = '/readiness';
13
+
14
+ /**
15
+ * Upper bound on one readiness ping. Keep it below the orchestrator probe
16
+ * timeout so the server answers 503 itself instead of letting the probe hang.
17
+ */
18
+ const DEFAULT_PING_TIMEOUT_MS = 2000;
19
+
20
+ function pingTimeoutFromEnv(value: string | undefined): number {
21
+ const parsed = Number(value);
22
+ return Number.isFinite(parsed) && parsed > 0
23
+ ? parsed
24
+ : DEFAULT_PING_TIMEOUT_MS;
25
+ }
26
+
27
+ /** Resolves, never rejects: the probe endpoint must not return 500. */
28
+ async function pingWithin(
29
+ db: Pick<DatabaseAdapter, 'ping'>,
30
+ timeoutMs: number
31
+ ): Promise<boolean> {
32
+ let timer: ReturnType<typeof setTimeout> | undefined;
33
+ const timedOut = new Promise<boolean>((resolve) => {
34
+ timer = setTimeout(() => {
35
+ console.warn(`[readiness] database ping exceeded ${timeoutMs}ms`);
36
+ resolve(false);
37
+ }, timeoutMs);
38
+ });
39
+ const answered = Promise.resolve()
40
+ .then(() => db.ping())
41
+ .catch(() => false);
42
+
43
+ try {
44
+ return await Promise.race([answered, timedOut]);
45
+ } finally {
46
+ if (timer !== undefined) clearTimeout(timer);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Yoga plugin serving a readiness probe at `READINESS_PATH`. Returns 200 when the
52
+ * database answers a trivial query, 503 otherwise. Registered ahead of other
53
+ * request hooks so probes are never throttled or otherwise interfered with.
54
+ */
55
+ function useReadiness(
56
+ db: Pick<DatabaseAdapter, 'ping'>,
57
+ timeoutMs = pingTimeoutFromEnv(process.env.READINESS_PING_TIMEOUT_MS)
58
+ ): Plugin {
59
+ return {
60
+ async onRequest({ url, endResponse, fetchAPI }) {
61
+ if (url.pathname.replace(/\/$/, '') !== READINESS_PATH) return;
62
+
63
+ const ready = await pingWithin(db, timeoutMs);
64
+ endResponse(
65
+ new fetchAPI.Response(
66
+ JSON.stringify({ status: ready ? 'ready' : 'not ready' }),
67
+ {
68
+ status: ready ? 200 : 503,
69
+ headers: {
70
+ 'content-type': 'application/json',
71
+ 'cache-control': 'no-store',
72
+ },
73
+ }
74
+ )
75
+ );
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,83 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Plugin } from 'graphql-yoga';
3
+ import { logger as defaultLogger } from './logger.js';
4
+ import type { Logger } from './logger.js';
5
+
6
+ export { useRequestLogging, requestIdFor };
7
+
8
+ /** Paths whose requests are not access-logged (frequent orchestrator probes). */
9
+ const QUIET_PATHS = new Set(['/healthcheck', '/readiness']);
10
+ /** Max characters kept from an inbound X-Request-Id. */
11
+ const MAX_REQUEST_ID_LENGTH = 128;
12
+ /** Printable ASCII only, minus quote and backslash. */
13
+ const UNSAFE_REQUEST_ID_CHARS = /[^\x20-\x7e]|["\\]/g;
14
+
15
+ /**
16
+ * Correlation id per in-flight request. Stored in a WeakMap so it is dropped
17
+ * automatically once the request is garbage-collected. Other log sites (e.g. the
18
+ * GraphQL error logger) can look it up to tie their lines to the access log.
19
+ */
20
+ const requestIds = new WeakMap<Request, string>();
21
+
22
+ function requestIdFor(request: Request): string | undefined {
23
+ return requestIds.get(request);
24
+ }
25
+
26
+ function sanitizeRequestId(raw: string | null): string {
27
+ if (!raw) return randomUUID();
28
+ const cleaned = raw
29
+ .replace(UNSAFE_REQUEST_ID_CHARS, '')
30
+ .slice(0, MAX_REQUEST_ID_LENGTH)
31
+ .trim();
32
+ return cleaned.length > 0 ? cleaned : randomUUID();
33
+ }
34
+
35
+ function pathOf(rawUrl: string): string {
36
+ try {
37
+ return new URL(rawUrl).pathname;
38
+ } catch {
39
+ return rawUrl;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Yoga plugin that assigns each request a correlation id (honouring an inbound
45
+ * bounded `X-Request-Id`) and emits one structured access-log line per request
46
+ * with the id, method, path, status, and duration. Probe endpoints are skipped
47
+ * to avoid log spam. `log` is injectable for tests.
48
+ */
49
+ function useRequestLogging(log: Logger = defaultLogger): Plugin {
50
+ const startTimes = new WeakMap<Request, number>();
51
+ return {
52
+ onRequest({ request }) {
53
+ const id = sanitizeRequestId(request.headers.get('x-request-id'));
54
+ requestIds.set(request, id);
55
+ startTimes.set(request, Date.now());
56
+ },
57
+ onResponse({ request, response }) {
58
+ const path = pathOf(request.url);
59
+ if (QUIET_PATHS.has(path)) return;
60
+
61
+ let id = requestIds.get(request);
62
+ let start = startTimes.get(request);
63
+ if (id === undefined || start === undefined) {
64
+ // CORS and health-check plugins can short-circuit before onRequest, but
65
+ // onResponse still runs. Backfill so access lines remain correlated.
66
+ id = sanitizeRequestId(request.headers.get('x-request-id'));
67
+ start = Date.now();
68
+ requestIds.set(request, id);
69
+ startTimes.set(request, start);
70
+ }
71
+ log.info(
72
+ {
73
+ requestId: id,
74
+ method: request.method,
75
+ path,
76
+ status: response.status,
77
+ durationMs: Date.now() - start,
78
+ },
79
+ 'request completed'
80
+ );
81
+ },
82
+ };
83
+ }
@@ -1,30 +1,85 @@
1
- import { createYoga, LogLevel } from 'graphql-yoga';
1
+ import { inspect } from 'node:util';
2
+ import { createYoga } from 'graphql-yoga';
2
3
  import { createServer } from 'http';
3
4
  import { Plugin } from '@envelop/core';
4
5
  import { schema } from '../resolvers.js';
5
6
  import type { GraphQLContext } from '../context.js';
7
+ import { parseBoolean } from '../config.js';
8
+ import { useReadiness } from './readiness.js';
9
+ import { resolveCorsOptions, warnIfCorsDisabled } from './cors.js';
10
+ import { logger, resolveYogaLogLevel } from './logger.js';
6
11
 
7
- export { BLOCK_RANGE_SIZE, ENABLE_BLOCK_TRANSACTION_DETAILS, buildServer };
12
+ export {
13
+ BLOCK_RANGE_SIZE,
14
+ ZKAPP_COMMAND_RANGE_SIZE,
15
+ ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT,
16
+ ENABLE_BLOCK_TRANSACTION_DETAILS,
17
+ ENABLE_ZKAPP_COMMANDS_QUERY,
18
+ buildYoga,
19
+ buildServer,
20
+ };
8
21
 
9
- const LOG_LEVEL = (process.env.LOG_LEVEL as LogLevel) || 'info';
10
22
  const BLOCK_RANGE_SIZE = Number(process.env.BLOCK_RANGE_SIZE) || 10000;
11
- const ENABLE_BLOCK_TRANSACTION_DETAILS =
12
- process.env.ENABLE_BLOCK_TRANSACTION_DETAILS === 'true';
23
+ const ENABLE_BLOCK_TRANSACTION_DETAILS = parseBoolean(
24
+ process.env.ENABLE_BLOCK_TRANSACTION_DETAILS
25
+ );
26
+ // The zkappCommands range query is far heavier than block transaction detail —
27
+ // it expands account updates — so it gets its own switch rather than riding on
28
+ // that flag. Default off: an operator opts in deliberately.
29
+ const ENABLE_ZKAPP_COMMANDS_QUERY = parseBoolean(
30
+ process.env.ENABLE_ZKAPP_COMMANDS_QUERY
31
+ );
32
+ const ZKAPP_COMMAND_RANGE_SIZE =
33
+ Number(process.env.ZKAPP_COMMAND_RANGE_SIZE) || 1000;
34
+ const ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT =
35
+ Number(process.env.ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT) || 5000;
36
+ const YOGA_LOG_LEVEL = resolveYogaLogLevel();
13
37
 
14
- function buildServer(context: GraphQLContext, plugins: Plugin[]) {
15
- const yoga = createYoga<GraphQLContext>({
38
+ const yogaLog =
39
+ (level: 'debug' | 'info' | 'warn' | 'error') =>
40
+ (...args: unknown[]) => {
41
+ const err = args.find((arg): arg is Error => arg instanceof Error);
42
+ const msg = args
43
+ .filter((arg) => !(arg instanceof Error))
44
+ .map((arg) =>
45
+ typeof arg === 'string' ? arg : inspect(arg, { depth: 3 })
46
+ )
47
+ .join(' ');
48
+ logger[level](err ? { err } : {}, msg || 'yoga');
49
+ };
50
+
51
+ function buildYoga(context: GraphQLContext, plugins: Plugin[]) {
52
+ const cors = resolveCorsOptions();
53
+
54
+ return createYoga<GraphQLContext>({
16
55
  schema,
17
- logging: LOG_LEVEL,
56
+ logging:
57
+ YOGA_LOG_LEVEL === false
58
+ ? false
59
+ : {
60
+ debug: yogaLog('debug'),
61
+ info: yogaLog('info'),
62
+ warn: yogaLog('warn'),
63
+ error: yogaLog('error'),
64
+ },
18
65
  graphqlEndpoint: '/',
19
66
  landingPage: false,
67
+ // Liveness — the process is up and serving HTTP.
20
68
  healthCheckEndpoint: '/healthcheck',
21
- graphiql: process.env.ENABLE_GRAPHIQL === 'true' ? true : false,
22
- plugins,
23
- cors: {
24
- origin: process.env.CORS_ORIGIN ?? '*',
25
- methods: ['GET', 'POST'],
26
- },
69
+ graphiql: parseBoolean(process.env.ENABLE_GRAPHIQL),
70
+ // Mask unexpected (non-GraphQLError) errors so internal details — SQL,
71
+ // connection strings, stack traces — never reach clients. `isDev: false`
72
+ // keeps Envelop from attaching original errors when NODE_ENV=development.
73
+ maskedErrors: { isDev: false },
74
+ // Readiness (DB reachable) is prepended so probes short-circuit before any
75
+ // other request hook (e.g. rate limiting) can interfere with them.
76
+ plugins: [useReadiness(context.db_client), ...plugins],
77
+ cors,
27
78
  context,
28
79
  });
29
- return createServer(yoga);
80
+ }
81
+
82
+ function buildServer(context: GraphQLContext, plugins: Plugin[]) {
83
+ warnIfCorsDisabled(resolveCorsOptions());
84
+ return createServer(buildYoga(context, plugins));
30
85
  }
@@ -12,7 +12,7 @@ import { DEFAULT_TOKEN_ID } from '../../blockchain/constants.js';
12
12
  import { createBlockInfo } from '../../blockchain/utils.js';
13
13
  import {
14
14
  getActionsQuery,
15
- checkActionState,
15
+ resolveActionStateBoundary,
16
16
  } from '../../db/sql/events-actions/queries.js';
17
17
  import {
18
18
  partitionBlocks,
@@ -28,6 +28,7 @@ import {
28
28
  } from '../../tracing/tracer.js';
29
29
  import {
30
30
  throwActionStateError,
31
+ throwActionStateOutOfRangeError,
31
32
  throwBlockRangeError,
32
33
  } from '../../errors/error.js';
33
34
  import { BLOCK_RANGE_SIZE } from '../../server/server.js';
@@ -70,32 +71,73 @@ class ActionsService implements IActionsService {
70
71
  return actionsData;
71
72
  }
72
73
 
73
- async executeActionsQuery(input: ActionFilterOptionsInput) {
74
- const { address, to, from, endActionState, fromActionState } = input;
74
+ /**
75
+ * Resolves an action state to the block height at which this account's action
76
+ * state first became that value, and raises a typed error when the request
77
+ * cannot be answered completely.
78
+ *
79
+ * The filter has to use a position on the chain. It used to compare
80
+ * `zkapp_field.id`, the interning key of the value, which records when a value
81
+ * was first written to the archive and not where it sits on the chain. Those
82
+ * two orders disagree on any archive that was filled out of chain order, and
83
+ * the filter then dropped real actions.
84
+ */
85
+ async resolveBoundaryHeight(
86
+ argumentName: 'fromActionState' | 'endActionState',
87
+ actionState: string,
88
+ address: string,
89
+ tokenId: string,
90
+ from?: number,
91
+ to?: number
92
+ ): Promise<string | undefined> {
93
+ const [boundary] = await resolveActionStateBoundary(
94
+ this.client,
95
+ address,
96
+ tokenId,
97
+ actionState,
98
+ from?.toString(),
99
+ to?.toString()
100
+ );
75
101
 
76
- // Check if action states exist.
77
- if (fromActionState) {
78
- const fromActionStateExists = await checkActionState(
79
- this.client,
80
- fromActionState
102
+ if (!boundary || boundary.boundary_height === null) {
103
+ throwActionStateError(
104
+ `${argumentName} ${actionState} is not an action state of account ${address}`
81
105
  );
82
- if (!fromActionStateExists || !fromActionStateExists.length) {
83
- throwActionStateError(
84
- `fromActionState ${fromActionState} does not exist`
85
- );
86
- }
106
+ return undefined;
87
107
  }
88
- if (endActionState) {
89
- const endActionStateExists = await checkActionState(
90
- this.client,
91
- endActionState
108
+
109
+ const boundaryHeight = Number(boundary.boundary_height);
110
+ const windowStart = Number(boundary.window_start);
111
+
112
+ // The checkpoint is older than the block range this query covers. For
113
+ // `fromActionState` that alone does not make the answer wrong: if the account
114
+ // emitted no action between the checkpoint and the start of the window, every
115
+ // action in the window is still the complete answer, and
116
+ // `state_entering_window` says so. Rejecting in that case would break the
117
+ // common case of a quiet zkApp folding from its genesis action state, which
118
+ // is the default o1js path because o1js sends no `from`/`to`.
119
+ //
120
+ // `endActionState` gets no such rescue. It bounds the request from above, so
121
+ // a checkpoint below the window means the whole requested span is below the
122
+ // window and every action in it is missing. An empty answer would be silently
123
+ // wrong, which is the failure this change exists to remove.
124
+ const answerIsStillComplete =
125
+ argumentName === 'fromActionState' && boundary.state_entering_window === true;
126
+ if (boundaryHeight < windowStart && !answerIsStillComplete) {
127
+ throwActionStateOutOfRangeError(
128
+ `${argumentName} ${actionState} is at block height ${boundaryHeight}, which is below the ` +
129
+ `queried block range starting at ${windowStart}, and this account emitted actions in ` +
130
+ `between. The answer would be missing them. Retry with from: ${boundaryHeight} ` +
131
+ `(at most ${BLOCK_RANGE_SIZE} blocks per query, so paginate if needed).`
92
132
  );
93
- if (!endActionStateExists || !endActionStateExists.length) {
94
- throwActionStateError(
95
- `endActionState ${endActionState} does not exist`
96
- );
97
- }
98
133
  }
134
+
135
+ return boundary.boundary_height;
136
+ }
137
+
138
+ async executeActionsQuery(input: ActionFilterOptionsInput) {
139
+ const { address, to, from, endActionState, fromActionState } = input;
140
+
99
141
  let { tokenId, status } = input;
100
142
 
101
143
  tokenId ||= DEFAULT_TOKEN_ID;
@@ -109,6 +151,27 @@ class ActionsService implements IActionsService {
109
151
  );
110
152
  }
111
153
 
154
+ const fromActionStateHeight = fromActionState
155
+ ? await this.resolveBoundaryHeight(
156
+ 'fromActionState',
157
+ fromActionState.toString(),
158
+ address,
159
+ tokenId,
160
+ from ?? undefined,
161
+ to ?? undefined
162
+ )
163
+ : undefined;
164
+ const endActionStateHeight = endActionState
165
+ ? await this.resolveBoundaryHeight(
166
+ 'endActionState',
167
+ endActionState.toString(),
168
+ address,
169
+ tokenId,
170
+ from ?? undefined,
171
+ to ?? undefined
172
+ )
173
+ : undefined;
174
+
112
175
  return getActionsQuery(
113
176
  this.client,
114
177
  address,
@@ -116,8 +179,8 @@ class ActionsService implements IActionsService {
116
179
  status,
117
180
  to?.toString(),
118
181
  from?.toString(),
119
- fromActionState?.toString(),
120
- endActionState?.toString()
182
+ fromActionStateHeight,
183
+ endActionStateHeight
121
184
  );
122
185
  }
123
186