@0xinsider/mcp 2.1.0 → 2.4.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 (113) hide show
  1. package/README.md +49 -17
  2. package/dist/catalog.generated.d.ts +42 -6
  3. package/dist/catalog.generated.d.ts.map +1 -1
  4. package/dist/catalog.generated.js +51 -6
  5. package/dist/catalog.generated.js.map +1 -1
  6. package/dist/cli-lib.d.ts +8 -0
  7. package/dist/cli-lib.d.ts.map +1 -1
  8. package/dist/cli-lib.js +123 -17
  9. package/dist/cli-lib.js.map +1 -1
  10. package/dist/client.d.ts +35 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +42 -4
  13. package/dist/client.js.map +1 -1
  14. package/dist/constants.d.ts +1 -1
  15. package/dist/constants.js +1 -1
  16. package/dist/product-cli/client.d.ts +3 -2
  17. package/dist/product-cli/client.d.ts.map +1 -1
  18. package/dist/product-cli/client.js.map +1 -1
  19. package/dist/product-cli/index.d.ts.map +1 -1
  20. package/dist/product-cli/index.js +3 -1
  21. package/dist/product-cli/index.js.map +1 -1
  22. package/dist/prompts/index.js +2 -2
  23. package/dist/sdk/client.d.ts +861 -136
  24. package/dist/sdk/client.d.ts.map +1 -1
  25. package/dist/sdk/client.js +924 -127
  26. package/dist/sdk/client.js.map +1 -1
  27. package/dist/sdk/errors.d.ts +48 -4
  28. package/dist/sdk/errors.d.ts.map +1 -1
  29. package/dist/sdk/errors.js +123 -3
  30. package/dist/sdk/errors.js.map +1 -1
  31. package/dist/sdk/index.d.ts +40 -9
  32. package/dist/sdk/index.d.ts.map +1 -1
  33. package/dist/sdk/index.js +6 -4
  34. package/dist/sdk/index.js.map +1 -1
  35. package/dist/sdk/pagination.d.ts +133 -15
  36. package/dist/sdk/pagination.d.ts.map +1 -1
  37. package/dist/sdk/pagination.js +132 -34
  38. package/dist/sdk/pagination.js.map +1 -1
  39. package/dist/sdk/retry.d.ts +68 -0
  40. package/dist/sdk/retry.d.ts.map +1 -0
  41. package/dist/sdk/retry.js +125 -0
  42. package/dist/sdk/retry.js.map +1 -0
  43. package/dist/sdk/schema.d.ts +2200 -688
  44. package/dist/sdk/schema.d.ts.map +1 -1
  45. package/dist/sdk/stream.d.ts +323 -8
  46. package/dist/sdk/stream.d.ts.map +1 -1
  47. package/dist/sdk/stream.js +582 -56
  48. package/dist/sdk/stream.js.map +1 -1
  49. package/dist/sdk/webhooks.d.ts +104 -12
  50. package/dist/sdk/webhooks.d.ts.map +1 -1
  51. package/dist/sdk/webhooks.js +22 -4
  52. package/dist/sdk/webhooks.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +13 -2
  55. package/dist/server.js.map +1 -1
  56. package/dist/tools/explore-markets.d.ts.map +1 -1
  57. package/dist/tools/explore-markets.js +2 -5
  58. package/dist/tools/explore-markets.js.map +1 -1
  59. package/dist/tools/games.d.ts +5 -0
  60. package/dist/tools/games.d.ts.map +1 -0
  61. package/dist/tools/games.js +103 -0
  62. package/dist/tools/games.js.map +1 -0
  63. package/dist/tools/get-insider-radar.d.ts.map +1 -1
  64. package/dist/tools/get-insider-radar.js +11 -40
  65. package/dist/tools/get-insider-radar.js.map +1 -1
  66. package/dist/tools/get-large-positions.d.ts.map +1 -1
  67. package/dist/tools/get-large-positions.js +3 -10
  68. package/dist/tools/get-large-positions.js.map +1 -1
  69. package/dist/tools/get-leaderboard.d.ts.map +1 -1
  70. package/dist/tools/get-leaderboard.js +23 -12
  71. package/dist/tools/get-leaderboard.js.map +1 -1
  72. package/dist/tools/get-market-intel.d.ts.map +1 -1
  73. package/dist/tools/get-market-intel.js +3 -6
  74. package/dist/tools/get-market-intel.js.map +1 -1
  75. package/dist/tools/get-position-timeline.d.ts.map +1 -1
  76. package/dist/tools/get-position-timeline.js +3 -10
  77. package/dist/tools/get-position-timeline.js.map +1 -1
  78. package/dist/tools/get-positions.d.ts +2 -0
  79. package/dist/tools/get-positions.d.ts.map +1 -1
  80. package/dist/tools/get-positions.js +8 -11
  81. package/dist/tools/get-positions.js.map +1 -1
  82. package/dist/tools/get-suspicious-trades.d.ts +26 -0
  83. package/dist/tools/get-suspicious-trades.d.ts.map +1 -0
  84. package/dist/tools/get-suspicious-trades.js +91 -0
  85. package/dist/tools/get-suspicious-trades.js.map +1 -0
  86. package/dist/tools/get-trader-pnl.d.ts.map +1 -1
  87. package/dist/tools/get-trader-pnl.js +3 -6
  88. package/dist/tools/get-trader-pnl.js.map +1 -1
  89. package/dist/tools/get-trader.d.ts.map +1 -1
  90. package/dist/tools/get-trader.js +3 -6
  91. package/dist/tools/get-trader.js.map +1 -1
  92. package/dist/tools/get-trending-wallets.d.ts.map +1 -1
  93. package/dist/tools/get-trending-wallets.js +3 -10
  94. package/dist/tools/get-trending-wallets.js.map +1 -1
  95. package/dist/tools/get-whale-trades.d.ts +2 -0
  96. package/dist/tools/get-whale-trades.d.ts.map +1 -1
  97. package/dist/tools/get-whale-trades.js +21 -16
  98. package/dist/tools/get-whale-trades.js.map +1 -1
  99. package/dist/tools/pick-of-the-day.d.ts.map +1 -1
  100. package/dist/tools/pick-of-the-day.js +5 -21
  101. package/dist/tools/pick-of-the-day.js.map +1 -1
  102. package/dist/tools/read-parity.d.ts +8 -0
  103. package/dist/tools/read-parity.d.ts.map +1 -1
  104. package/dist/tools/read-parity.js +125 -77
  105. package/dist/tools/read-parity.js.map +1 -1
  106. package/dist/tools/result.d.ts +31 -0
  107. package/dist/tools/result.d.ts.map +1 -0
  108. package/dist/tools/result.js +70 -0
  109. package/dist/tools/result.js.map +1 -0
  110. package/dist/tools/search-markets.d.ts.map +1 -1
  111. package/dist/tools/search-markets.js +16 -11
  112. package/dist/tools/search-markets.js.map +1 -1
  113. package/package.json +4 -4
@@ -19,12 +19,17 @@
19
19
  * - Extra typed convenience methods for list/candle/webhook-event/stream
20
20
  * surfaces and a typed `Grade` enum.
21
21
  */
22
- import { errorFromResponse, OxinsiderApiError, RequestTimeoutError, } from "./errors.js";
22
+ import { errorFromResponse, OxinsiderApiError, InvalidResponseError, RequestTimeoutError, } from "./errors.js";
23
+ import { RETRY_AFTER_CEILING_MS, retryAfterSeconds, sleepUnlessAborted, } from "./retry.js";
23
24
  /**
24
- * The full V1 operation table. One row per OpenAPI operation that returns a
25
- * `200`. Kept in lockstep with `web/public/api/v1/openapi.json` by
26
- * `scripts/check-sdk-openapi-drift.mjs`. Do not add a row without a matching
27
- * spec operation, and do not remove a spec operation without removing its row.
25
+ * The full V1 operation table. One row per OpenAPI operation with a
26
+ * documented 2xx, which since #16137 includes the `201`-only
27
+ * `registerAgent`; `kind` says which method reads its body. Kept in lockstep
28
+ * with `web/public/api/v1/openapi.json` by
29
+ * `scripts/check-sdk-openapi-drift.mjs`, which also checks every row's kind
30
+ * against the spec. Do not add a row without a matching spec operation, and
31
+ * do not remove a spec operation without removing its row. A redirect-only
32
+ * operation belongs in `REDIRECT_OPERATIONS` below, not here.
28
33
  */
29
34
  export const API_CLIENT_OPERATIONS = [
30
35
  { method: "GET", path: "/api/v1/me", operationId: "getAccountIdentity", auth: "bearer" },
@@ -76,6 +81,12 @@ export const API_CLIENT_OPERATIONS = [
76
81
  operationId: "getTraderPnl",
77
82
  auth: "bearer",
78
83
  },
84
+ {
85
+ method: "GET",
86
+ path: "/api/v1/trader/{address}/categories",
87
+ operationId: "getTraderCategoryRecords",
88
+ auth: "bearer",
89
+ },
79
90
  // Pre-existing op-table drift (surfaced by test/drift.test.ts, #6915): these
80
91
  // 200-returning openapi operations were never mirrored into the client table.
81
92
  // Added so the table stays contract-complete against the checked-in spec. All
@@ -94,6 +105,7 @@ export const API_CLIENT_OPERATIONS = [
94
105
  path: "/api/v1/trader/{address}/context.md",
95
106
  operationId: "getTraderContextMarkdown",
96
107
  auth: "bearer",
108
+ kind: "text",
97
109
  },
98
110
  {
99
111
  method: "POST",
@@ -116,6 +128,18 @@ export const API_CLIENT_OPERATIONS = [
116
128
  // Pre-existing gap, fixed here (#7209): the route has shipped in openapi.json,
117
129
  // llms-full.txt, agents.md, and the discovery doc, but never in the SDK table --
118
130
  // so `drift.test.ts` was RED on main and SDK users had no typed method for it.
131
+ {
132
+ method: "GET",
133
+ path: "/api/v1/sports/pre-game-sides",
134
+ operationId: "listPreGameSides",
135
+ auth: "bearer",
136
+ },
137
+ {
138
+ method: "GET",
139
+ path: "/api/v1/sports/pre-game-side-observations",
140
+ operationId: "listPreGameSideObservations",
141
+ auth: "bearer",
142
+ },
119
143
  {
120
144
  method: "GET",
121
145
  path: "/api/v1/sports-edge-signals",
@@ -128,6 +152,48 @@ export const API_CLIENT_OPERATIONS = [
128
152
  operationId: "listSportsEdgeObservations",
129
153
  auth: "bearer",
130
154
  },
155
+ {
156
+ method: "GET",
157
+ path: "/api/v1/games",
158
+ operationId: "listGames",
159
+ auth: "bearer",
160
+ },
161
+ {
162
+ method: "GET",
163
+ path: "/api/v1/games/{event_slug}",
164
+ operationId: "getGame",
165
+ auth: "bearer",
166
+ },
167
+ {
168
+ method: "GET",
169
+ path: "/api/v1/large-trades",
170
+ operationId: "listLargeTrades",
171
+ auth: "bearer",
172
+ },
173
+ {
174
+ method: "GET",
175
+ path: "/api/v1/large-trades/history",
176
+ operationId: "listLargeTradeHistory",
177
+ auth: "bearer",
178
+ },
179
+ {
180
+ method: "GET",
181
+ path: "/api/v1/large-trades/{id}/counterparties/executions",
182
+ operationId: "listLargeTradeCounterpartyExecutions",
183
+ auth: "bearer",
184
+ },
185
+ {
186
+ method: "GET",
187
+ path: "/api/v1/large-trades/{id}/counterparties/executions/{execution_id}/makers",
188
+ operationId: "listLargeTradeCounterpartyMakers",
189
+ auth: "bearer",
190
+ },
191
+ {
192
+ method: "GET",
193
+ path: "/api/v1/large-trades/{id}",
194
+ operationId: "getLargeTrade",
195
+ auth: "bearer",
196
+ },
131
197
  {
132
198
  method: "GET",
133
199
  path: "/api/v1/whale-trades",
@@ -200,6 +266,12 @@ export const API_CLIENT_OPERATIONS = [
200
266
  operationId: "getPlatforms",
201
267
  auth: "none",
202
268
  },
269
+ {
270
+ method: "GET",
271
+ path: "/api/v1/market/{condition_id}/holders",
272
+ operationId: "getMarketHolders",
273
+ auth: "bearer",
274
+ },
203
275
  {
204
276
  method: "GET",
205
277
  path: "/api/v1/market/{condition_id}/intel",
@@ -223,6 +295,7 @@ export const API_CLIENT_OPERATIONS = [
223
295
  path: "/api/v1/market/{condition_id}/context.md",
224
296
  operationId: "getMarketContextMarkdown",
225
297
  auth: "bearer",
298
+ kind: "text",
226
299
  },
227
300
  {
228
301
  method: "GET",
@@ -230,6 +303,22 @@ export const API_CLIENT_OPERATIONS = [
230
303
  operationId: "getMarketCandles",
231
304
  auth: "bearer",
232
305
  },
306
+ {
307
+ method: "GET",
308
+ path: "/api/v1/suspicious-trades",
309
+ operationId: "listSuspiciousTrades",
310
+ auth: "bearer",
311
+ },
312
+ {
313
+ method: "GET",
314
+ path: "/api/v1/suspicious-trades/{id}",
315
+ operationId: "getSuspiciousTrade",
316
+ auth: "bearer",
317
+ },
318
+ // Deprecated aliases (#16301). Both paths stay live with no retirement
319
+ // date; responses carry Deprecation and a Link rel="successor-version".
320
+ // `/api/v1/insider-radar/{id}` also keeps answering `object: "radar_flag"`,
321
+ // so these entries are what an existing integration must keep exercising.
233
322
  {
234
323
  method: "GET",
235
324
  path: "/api/v1/insider-radar",
@@ -253,6 +342,7 @@ export const API_CLIENT_OPERATIONS = [
253
342
  path: "/api/v1/stream",
254
343
  operationId: "getStream",
255
344
  auth: "bearer",
345
+ kind: "sse",
256
346
  },
257
347
  {
258
348
  method: "GET",
@@ -296,6 +386,24 @@ export const API_CLIENT_OPERATIONS = [
296
386
  operationId: "rotateWebhookSecret",
297
387
  auth: "bearer",
298
388
  },
389
+ {
390
+ method: "POST",
391
+ path: "/api/v1/webhooks/{id}/rotate-secret/prepare",
392
+ operationId: "prepareWebhookSecret",
393
+ auth: "bearer",
394
+ },
395
+ {
396
+ method: "POST",
397
+ path: "/api/v1/webhooks/{id}/rotate-secret/activate",
398
+ operationId: "activateWebhookSecret",
399
+ auth: "bearer",
400
+ },
401
+ {
402
+ method: "POST",
403
+ path: "/api/v1/webhooks/{id}/rotate-secret/retire",
404
+ operationId: "retireWebhookSecret",
405
+ auth: "bearer",
406
+ },
299
407
  {
300
408
  method: "GET",
301
409
  path: "/api/v1/webhooks/events",
@@ -325,12 +433,7 @@ export const API_CLIENT_OPERATIONS = [
325
433
  path: "/api/v1/mcp",
326
434
  operationId: "createMcpJsonRpcResponse",
327
435
  auth: "bearer",
328
- },
329
- {
330
- method: "GET",
331
- path: "/api/v1/mcp",
332
- operationId: "openMcpEventStream",
333
- auth: "bearer",
436
+ kind: "jsonrpc",
334
437
  },
335
438
  {
336
439
  method: "GET",
@@ -384,9 +487,100 @@ export const API_CLIENT_OPERATIONS = [
384
487
  method: "GET",
385
488
  path: "/api/v1/pick-of-the-day/ledger",
386
489
  operationId: "getPickOfTheDayLedger",
490
+ // Keyless since #16459: the commitment ledger is published to be
491
+ // republished, so a client can read it with no key configured.
492
+ auth: "none",
493
+ },
494
+ {
495
+ method: "POST",
496
+ path: "/api/v1/agents/register",
497
+ operationId: "registerAgent",
498
+ // `security: []` in the document: minting a sandbox key is the one write
499
+ // that must work before a caller has any credential.
500
+ auth: "none",
501
+ },
502
+ ];
503
+ /**
504
+ * Published operations whose success is a redirect, with the method that
505
+ * follows it (#16137). These have no 2xx body, so they are absent from
506
+ * `API_CLIENT_OPERATIONS` and from the generated `schema.ts`;
507
+ * `scripts/check-sdk-openapi-drift.mjs` requires every redirect-only spec
508
+ * operation to be declared here, so one cannot go missing in silence again.
509
+ */
510
+ export const REDIRECT_OPERATIONS = [
511
+ {
512
+ method: "GET",
513
+ path: "/api/v1/trader/{address}/export/download",
514
+ operationId: "downloadTraderExport",
387
515
  auth: "bearer",
516
+ status: 302,
517
+ handledBy: "getTraderExportDownloadUrl() resolves the Location; downloadTraderExport() then fetches the object with no Authorization header.",
518
+ },
519
+ {
520
+ method: "GET",
521
+ path: "/api/v1/openapi.json",
522
+ operationId: "redirectApiOpenapiSpec",
523
+ auth: "none",
524
+ status: 307,
525
+ handledBy: "No method: the document is public and static, so fetch the URL directly and let your runtime follow the redirect. This package ships the same contract as generated types in schema.ts.",
388
526
  },
389
527
  ];
528
+ /**
529
+ * Published operations this client deliberately does not wrap, with the
530
+ * reason. The drift check requires every remaining spec operation to be
531
+ * accounted for here, so "unsupported" is always a stated decision.
532
+ */
533
+ export const UNSUPPORTED_OPERATIONS = [
534
+ {
535
+ method: "GET",
536
+ path: "/api/v1/mcp",
537
+ operationId: "openMcpEventStream",
538
+ reason: "The MCP endpoint offers no server-to-client stream: GET answers 405 by design, so a client that reconnects to it would loop (#16354). Post JSON-RPC with mcp() instead.",
539
+ },
540
+ ];
541
+ /**
542
+ * The writes the API replays under `Idempotency-Key`: a second request with
543
+ * the same key and the same body returns the first result instead of
544
+ * repeating the write. Source of truth: the operations declaring the
545
+ * `Idempotency-Key` header parameter in `web/public/api/v1/openapi.json`,
546
+ * pinned by `scripts/check-sdk-openapi-drift.mjs` (#16182). Only these are
547
+ * retried on a transport or 5xx failure, and only when a key is set; a key on
548
+ * any other operation is refused before the request, since the server would
549
+ * ignore it and a retry could repeat the side effect (`verifyWebhook` sends
550
+ * a challenge to your URL each time; `submitTraderExport` starts a job).
551
+ */
552
+ export const IDEMPOTENT_WRITE_OPERATIONS = [
553
+ "createWebhook",
554
+ "updateWebhook",
555
+ "deleteWebhook",
556
+ "rotateWebhookSecret",
557
+ "prepareWebhookSecret",
558
+ "activateWebhookSecret",
559
+ "retireWebhookSecret",
560
+ "redeliverWebhookDelivery",
561
+ ];
562
+ /**
563
+ * POST operations that read and never write (#16182): the batch lookups.
564
+ * `POST /api/v1/traders/batch` and `POST /api/v1/markets/intel/batch` resolve
565
+ * their inputs and store nothing (`backend/src/api_v1/handlers/batch.rs`), so
566
+ * a repeat cannot duplicate a side effect. Each attempt is one request
567
+ * against the account's quota and one reservation of batch item units, which
568
+ * is exactly what a retried GET costs, so they are retried like a GET. Every
569
+ * other POST, PATCH or DELETE is retried only as a keyed write above.
570
+ */
571
+ export const READ_ONLY_POST_OPERATIONS = [
572
+ "batchGetTraders",
573
+ "batchGetMarketIntel",
574
+ ];
575
+ const idempotentWrites = new Set(IDEMPOTENT_WRITE_OPERATIONS);
576
+ const readOnlyPosts = new Set(READ_ONLY_POST_OPERATIONS);
577
+ /** The retry class of an operation; see `RetryEligibility`. */
578
+ export function retryEligibility(operation) {
579
+ if (operation.method === "GET" || readOnlyPosts.has(operation.operationId)) {
580
+ return "read";
581
+ }
582
+ return idempotentWrites.has(operation.operationId) ? "keyed" : "never";
583
+ }
390
584
  /**
391
585
  * Trader skill grade. Source of truth:
392
586
  * `components.schemas.Trader.properties.grade.enum` (S highest, F lowest).
@@ -394,17 +588,28 @@ export const API_CLIENT_OPERATIONS = [
394
588
  export const GRADES = ["S", "A", "B", "C", "D", "F"];
395
589
  /** Default production API base used when `baseUrl` is omitted. */
396
590
  export const DEFAULT_BASE_URL = "https://api.0xinsider.com";
591
+ /**
592
+ * The sandbox server, the second `servers` entry of the OpenAPI document: no
593
+ * credential, no production data, every documented operation answered with
594
+ * its example or a deterministic sample, `?sandbox_status=<code>` for a
595
+ * documented error, and `X-Oxi-Sandbox: true` on every response. The two
596
+ * Markdown documents answer `200 text/markdown` there and the export download
597
+ * answers its `302` to a sample file the sandbox serves itself. `GET
598
+ * /api/v1/stream` is the one operation it does not simulate and answers 400,
599
+ * because an SSE stream is a live connection rather than a body.
600
+ */
601
+ export const SANDBOX_BASE_URL = "https://0xinsider.com/sandbox";
602
+ /** A live secret key starts with this; the sandbox never needs one. */
603
+ const LIVE_KEY_PREFIX = "oxi_sk_live_";
397
604
  /** Default per-request deadline; see `ApiClientOptions.timeoutMs`. */
398
605
  export const DEFAULT_TIMEOUT_MS = 15_000;
399
606
  /** Default retry budget; see `ApiClientOptions.maxRetries`. */
400
607
  export const DEFAULT_MAX_RETRIES = 2;
401
608
  /** Statuses a retry can fix: rate limited, or a gateway/availability failure. */
402
- const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
609
+ const RETRYABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
403
610
  const RETRY_BASE_DELAY_MS = 500;
404
611
  const RETRY_MAX_BACKOFF_MS = 8_000;
405
612
  const RETRY_JITTER_MS = 250;
406
- /** A server-requested wait longer than this is the caller's to schedule. */
407
- const RETRY_AFTER_CEILING_MS = 60_000;
408
613
  function assertRetryCount(value, source) {
409
614
  if (!Number.isInteger(value) || value < 0) {
410
615
  throw new Error(`${source} must be a non-negative integer, got ${String(value)}`);
@@ -413,11 +618,14 @@ function assertRetryCount(value, source) {
413
618
  }
414
619
  /**
415
620
  * Milliseconds to wait before retry `attempt` (0-based), or `null` when the
416
- * server asked for a wait a retry loop should not hold.
621
+ * server asked for a wait a retry loop should not hold. The ceiling check
622
+ * runs on the server's number before jitter is added, so the value handed to
623
+ * the timer is at most `RETRY_AFTER_CEILING_MS + RETRY_JITTER_MS`, far inside
624
+ * the timer range (`MAX_TIMER_DELAY_MS`, `retry.ts`).
417
625
  */
418
626
  function retryDelayMs(attempt, retryAfter) {
419
627
  if (retryAfter !== null) {
420
- const requested = Math.max(0, retryAfter * 1000);
628
+ const requested = retryAfter * 1000;
421
629
  if (requested > RETRY_AFTER_CEILING_MS)
422
630
  return null;
423
631
  return requested + Math.random() * RETRY_JITTER_MS;
@@ -425,24 +633,6 @@ function retryDelayMs(attempt, retryAfter) {
425
633
  const ceiling = Math.min(RETRY_MAX_BACKOFF_MS, RETRY_BASE_DELAY_MS * 2 ** attempt);
426
634
  return ceiling / 2 + Math.random() * (ceiling / 2);
427
635
  }
428
- /** Resolve after `ms`, or reject with the signal's reason the moment it aborts. */
429
- function sleepUnlessAborted(ms, signal) {
430
- return new Promise((resolve, reject) => {
431
- if (signal?.aborted) {
432
- reject(signal.reason);
433
- return;
434
- }
435
- const onAbort = () => {
436
- clearTimeout(timer);
437
- reject(signal?.reason);
438
- };
439
- const timer = setTimeout(() => {
440
- signal?.removeEventListener("abort", onAbort);
441
- resolve();
442
- }, ms);
443
- signal?.addEventListener("abort", onAbort, { once: true });
444
- });
445
- }
446
636
  function isLoopbackHost(hostname) {
447
637
  const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
448
638
  return (host === "localhost" ||
@@ -469,6 +659,28 @@ export function assertTrustedBaseUrl(baseUrl) {
469
659
  return url;
470
660
  throw new Error(`Refusing to send the API key to ${url.origin}: baseUrl must use https: (http: is accepted only for a loopback host such as localhost or 127.0.0.1).`);
471
661
  }
662
+ /**
663
+ * The server URL as this client stores it: origin plus path, trailing slashes
664
+ * trimmed, and a trailing `/api/v1` removed once. Every operation path starts
665
+ * with `/api/v1/`, so a base that already ends in it would double the prefix;
666
+ * before #16138 such a base worked only because the path was discarded, and
667
+ * this keeps it working.
668
+ */
669
+ function normalizeBaseUrl(base) {
670
+ const trimmed = base.toString().replace(/\/+$/, "");
671
+ return trimmed.replace(/\/api\/v1$/, "");
672
+ }
673
+ /**
674
+ * Join an `/api/v1/...` path onto the server URL, keeping the server's own
675
+ * path (`/sandbox`) exactly once. REST (`buildUrl`) and the SSE stream
676
+ * (`stream.ts`) both resolve through this, so the two cannot disagree about
677
+ * where a path-bearing base points (#16138: `new URL("/api/v1/...", base)`
678
+ * resolved against the origin and dropped `/sandbox`).
679
+ */
680
+ export function resolveApiUrl(baseUrl, path) {
681
+ const suffix = path.startsWith("/") ? path : `/${path}`;
682
+ return new URL(`${baseUrl}${suffix}`);
683
+ }
472
684
  /**
473
685
  * One signal that aborts on the deadline OR when the caller's signal aborts.
474
686
  * Uses native composition where available. The manual fallback removes both
@@ -512,12 +724,137 @@ function composeRequestSignal(timeoutMs, caller) {
512
724
  }
513
725
  return { signal: controller.signal, dispose };
514
726
  }
727
+ /**
728
+ * `response` with `dispose` deferred to the end of its body: the stream's
729
+ * close, error or cancellation (#16686). Without a `dispose` (a runtime with
730
+ * `AbortSignal.any`, or no composition) the response is returned as is.
731
+ */
732
+ function releaseWhenBodySettles(response, dispose) {
733
+ if (!dispose)
734
+ return response;
735
+ if (!response.body) {
736
+ dispose();
737
+ return response;
738
+ }
739
+ const reader = response.body.getReader();
740
+ const body = new ReadableStream({
741
+ async pull(controller) {
742
+ try {
743
+ const { done, value } = await reader.read();
744
+ if (done) {
745
+ dispose();
746
+ controller.close();
747
+ return;
748
+ }
749
+ controller.enqueue(value);
750
+ }
751
+ catch (error) {
752
+ dispose();
753
+ controller.error(error);
754
+ }
755
+ },
756
+ cancel(reason) {
757
+ dispose();
758
+ return reader.cancel(reason);
759
+ },
760
+ });
761
+ return new Response(body, {
762
+ status: response.status,
763
+ statusText: response.statusText,
764
+ headers: response.headers,
765
+ });
766
+ }
767
+ /** JSON-RPC methods `POST /api/v1/mcp` answers without a credential. */
768
+ function isKeylessMcpMethod(method) {
769
+ return (method === "initialize" ||
770
+ method === "ping" ||
771
+ method === "tools/list" ||
772
+ method.startsWith("notifications/"));
773
+ }
515
774
  function isTimeoutAbort(error) {
516
775
  return (typeof error === "object" &&
517
776
  error !== null &&
518
777
  "name" in error &&
519
778
  error.name === "TimeoutError");
520
779
  }
780
+ /** Which method reads each kind of success body, named in the error that refuses the wrong one. */
781
+ const READER_FOR_KIND = {
782
+ envelope: "call() or list()",
783
+ text: "text()",
784
+ jsonrpc: "mcp()",
785
+ sse: "streamFeed(), streamFeedResilient() or consumeStreamCheckpointed()",
786
+ };
787
+ // --- MCP JSON-RPC (#16137) ---
788
+ /** The MCP methods `POST /api/v1/mcp` accepts, from the operation's request body. */
789
+ export const MCP_METHODS = [
790
+ "initialize",
791
+ "notifications/initialized",
792
+ "notifications/cancelled",
793
+ "ping",
794
+ "tools/list",
795
+ "tools/call",
796
+ ];
797
+ function isMcpJsonRpcResponse(body) {
798
+ if (typeof body !== "object" || body === null)
799
+ return false;
800
+ const candidate = body;
801
+ return (candidate.jsonrpc === "2.0" &&
802
+ (typeof candidate.id === "string" ||
803
+ typeof candidate.id === "number" ||
804
+ candidate.id === null));
805
+ }
806
+ // --- Export download (#16137) ---
807
+ /** The `downloadTraderExport` row of `REDIRECT_OPERATIONS`, as a request target. */
808
+ const exportDownloadOperation = {
809
+ method: REDIRECT_OPERATIONS[0].method,
810
+ path: REDIRECT_OPERATIONS[0].path,
811
+ operationId: REDIRECT_OPERATIONS[0].operationId,
812
+ auth: REDIRECT_OPERATIONS[0].auth,
813
+ };
814
+ /**
815
+ * Read a presigned URL's own expiry, and refuse a destination that would
816
+ * downgrade the transport. The backend already validates the redirect
817
+ * against its expected R2 origin; this is the client-side half, so a
818
+ * redirect can never move a download onto plain `http:`.
819
+ */
820
+ function presignedTarget(location) {
821
+ let url;
822
+ try {
823
+ url = new URL(location);
824
+ }
825
+ catch {
826
+ throw new InvalidResponseError(302, `The export download redirect is not an absolute URL: ${location}`, null);
827
+ }
828
+ if (url.protocol !== "https:" && !isLoopbackHost(url.hostname)) {
829
+ throw new InvalidResponseError(302, `Refusing to follow the export download redirect to ${url.origin}: it must use https: (http: is accepted only for a loopback host).`, null);
830
+ }
831
+ const signedAt = url.searchParams.get("X-Amz-Date");
832
+ const lifetime = url.searchParams.get("X-Amz-Expires");
833
+ const expiresAt = presignExpiry(signedAt, lifetime);
834
+ return { url: url.toString(), ...(expiresAt ? { expiresAt } : {}) };
835
+ }
836
+ /** `20260922T101500Z` plus `3600` seconds, as an ISO instant; `null` if either is unreadable. */
837
+ function presignExpiry(signedAt, lifetimeSeconds) {
838
+ if (!signedAt || !lifetimeSeconds)
839
+ return null;
840
+ const parsed = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(signedAt);
841
+ const seconds = Number(lifetimeSeconds);
842
+ if (!parsed || !Number.isFinite(seconds))
843
+ return null;
844
+ const [, year, month, day, hour, minute, second] = parsed;
845
+ const signed = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
846
+ return new Date(signed + seconds * 1000).toISOString();
847
+ }
848
+ /** The `filename="..."` of a `Content-Disposition`, or `null`. */
849
+ function filenameFromDisposition(disposition) {
850
+ if (!disposition)
851
+ return null;
852
+ const quoted = /filename\*?=(?:UTF-8'')?"([^"]+)"/i.exec(disposition);
853
+ if (quoted)
854
+ return decodeURIComponent(quoted[1]);
855
+ const bare = /filename\*?=(?:UTF-8'')?([^;]+)/i.exec(disposition);
856
+ return bare ? decodeURIComponent(bare[1].trim()) : null;
857
+ }
521
858
  /** `GET /api/v1/leaderboard` -> parameters -> strategy (`web/public/api/v1/openapi.json`). */
522
859
  export const LEADERBOARD_STRATEGIES = [
523
860
  "accumulator",
@@ -531,16 +868,48 @@ export const LEADERBOARD_STRATEGIES = [
531
868
  "speculator",
532
869
  "swing_trader",
533
870
  ];
871
+ /**
872
+ * Body-level `expand` values of `POST /api/v1/traders/batch`. The type is the
873
+ * operation's own body (`OperationBody["batchGetTraders"]["expand"]`); the
874
+ * const is the same list as a runtime value for a caller that iterates it.
875
+ */
876
+ export const BATCH_TRADER_EXPANSIONS = [
877
+ "strategy",
878
+ "categories",
879
+ "quant_metrics",
880
+ "trust",
881
+ ];
534
882
  const operationsById = new Map(API_CLIENT_OPERATIONS.map((operation) => [operation.operationId, operation]));
535
883
  export class OxinsiderApiClient {
536
884
  baseUrl;
537
885
  apiKey;
886
+ sandbox;
538
887
  fetchImpl;
539
888
  timeoutMs;
540
889
  maxRetries;
890
+ /**
891
+ * A client for the sandbox server (#16138): `SANDBOX_BASE_URL`, no
892
+ * credential needed, example data only, never production data. The same
893
+ * as `new OxinsiderApiClient({ ...options, sandbox: true })`; pass a
894
+ * sandbox key (`oxi_sk_test_*`) as `apiKey` to have it checked.
895
+ *
896
+ * @example
897
+ * const sandbox = OxinsiderApiClient.sandbox();
898
+ * const board = await sandbox.listLeaderboard({ limit: 5 }); // no key
899
+ * board.meta?.sandbox; // true
900
+ */
901
+ static sandbox(options = {}) {
902
+ return new OxinsiderApiClient({ ...options, sandbox: true });
903
+ }
541
904
  constructor(options = {}) {
542
- const base = assertTrustedBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
543
- this.baseUrl = base.toString().replace(/\/+$/, "");
905
+ this.sandbox = options.sandbox === true;
906
+ const base = assertTrustedBaseUrl(options.baseUrl ?? (this.sandbox ? SANDBOX_BASE_URL : DEFAULT_BASE_URL));
907
+ this.baseUrl = normalizeBaseUrl(base);
908
+ if (this.sandbox && options.apiKey?.startsWith(LIVE_KEY_PREFIX)) {
909
+ // The sandbox needs no credential and ignores a live key, so the only
910
+ // effect of sending one is a secret on the wire for nothing.
911
+ throw new Error("Refusing to send a live API key (oxi_sk_live_*) to the sandbox: omit apiKey, or pass a sandbox key (oxi_sk_test_*) from POST /api/v1/agents/register.");
912
+ }
544
913
  this.apiKey = options.apiKey;
545
914
  this.timeoutMs =
546
915
  options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs;
@@ -552,29 +921,236 @@ export class OxinsiderApiClient {
552
921
  // Bind so a passed-through `globalThis.fetch` keeps its receiver.
553
922
  this.fetchImpl = fetchImpl.bind(globalThis);
554
923
  }
924
+ // The implementation signature is what both overloads narrow; `unknown`
925
+ // here is the widest return both can refine, not a shape a caller sees.
926
+ async call(operationId, options = {}) {
927
+ const operation = this.resolveOperation(operationId, "envelope");
928
+ return this.request(operation, options, async (response) => {
929
+ if (response.status === 304) {
930
+ return notModifiedResponse(response);
931
+ }
932
+ const envelope = await parseJson(response);
933
+ if (isApiEnvelope(envelope)) {
934
+ const etag = response.headers.get("etag");
935
+ const sandbox = response.headers.get("x-oxi-sandbox") === "true";
936
+ const queryIgnored = response.headers.get("x-query-ignored");
937
+ const effectiveQuery = response.headers.get("x-effective-query");
938
+ // Only ADD to a meta the server actually sent. The contract makes
939
+ // `meta` required on every envelope, so synthesizing one from `?? {}`
940
+ // would hand a caller a `ResponseMeta` missing its required fields --
941
+ // which is what the hand-written type hid before #14278. `status` is
942
+ // always lifted (#16137): `201` and `202` are load-bearing on
943
+ // `registerAgent` and `submitTraderExport`, and a caller cannot read
944
+ // them off a body that looks identical to a 200.
945
+ if (envelope.meta) {
946
+ envelope.meta = {
947
+ ...envelope.meta,
948
+ status: response.status,
949
+ ...(etag ? { etag } : {}),
950
+ ...(sandbox ? { sandbox: true } : {}),
951
+ ...(queryIgnored ? { queryIgnored } : {}),
952
+ ...(effectiveQuery ? { effectiveQuery } : {}),
953
+ };
954
+ }
955
+ return envelope;
956
+ }
957
+ throw new OxinsiderApiError(response.status, envelope);
958
+ });
959
+ }
960
+ async text(operationId, options = {}) {
961
+ const operation = this.resolveOperation(operationId, "text");
962
+ return this.request(operation, options, async (response) => response.text(), { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" });
963
+ }
555
964
  /**
556
- * Execute any operation by id. Throws the matching `OxinsiderApiError`
557
- * subclass on a non-2xx response, returns a typed `not_modified` result for a
558
- * 304, and otherwise returns the parsed `{ object, data, meta }` envelope.
965
+ * Post one MCP JSON-RPC 2.0 message to `POST /api/v1/mcp` (#16137).
966
+ *
967
+ * The endpoint answers a JSON-RPC envelope, not the `{ object, data, meta }`
968
+ * envelope every other operation uses, so `call()` refuses it. A request
969
+ * (one carrying `id`) comes back as `status: 200` with `response` set; a
970
+ * supported notification (no `id`) comes back as `status: 202` with
971
+ * `response: null`, which is the empty body the server sends. A JSON-RPC
972
+ * `error` member is a protocol-level failure and is RETURNED, not thrown:
973
+ * only a non-2xx HTTP status throws, because an unknown tool is an answer
974
+ * and a revoked credential is not.
975
+ *
976
+ * The request is never retried: `tools/call` can have an effect, and the
977
+ * endpoint honours no `Idempotency-Key`.
978
+ *
979
+ * @example
980
+ * const listed = await client.mcp({ method: "tools/list", id: 1 });
981
+ * listed.response?.result;
559
982
  */
560
- async call(operationId, options = {}) {
983
+ async mcp(request, options = {}) {
984
+ // `initialize`, `ping`, `tools/list` and the notifications need no
985
+ // credential (`backend/src/mcp/mod.rs`); only `tools/call` does, so the
986
+ // local credential check is per method (#16686). A configured key is
987
+ // still sent on every method.
988
+ const operation = this.resolveOperation("createMcpJsonRpcResponse", "jsonrpc", !isKeylessMcpMethod(request.method));
989
+ const { sessionId, protocolVersion, ...transportOptions } = options;
990
+ const headers = { ...options.headers };
991
+ if (sessionId !== undefined) {
992
+ headers["mcp-session-id"] = sessionId;
993
+ }
994
+ if (protocolVersion !== undefined) {
995
+ headers["mcp-protocol-version"] = protocolVersion;
996
+ }
997
+ // Set after the spread: an explicit `jsonrpc: undefined` would otherwise
998
+ // drop the field and the server would answer 400 (#16686).
999
+ const body = { ...request, jsonrpc: "2.0" };
1000
+ return this.request(operation, { ...transportOptions, headers, body, maxRetries: 0 }, async (response) => {
1001
+ const sessionId = response.headers.get("mcp-session-id");
1002
+ const result = {
1003
+ status: response.status === 202 ? 202 : 200,
1004
+ response: null,
1005
+ ...(sessionId ? { sessionId } : {}),
1006
+ };
1007
+ if (response.status === 202)
1008
+ return result;
1009
+ const parsed = await parseJson(response);
1010
+ if (!isMcpJsonRpcResponse(parsed)) {
1011
+ throw new InvalidResponseError(response.status, "POST /api/v1/mcp returned a body that is not a JSON-RPC 2.0 response: expected an object with jsonrpc \"2.0\" and an id.", parsed);
1012
+ }
1013
+ result.response = parsed;
1014
+ return result;
1015
+ });
1016
+ }
1017
+ /**
1018
+ * Resolve the presigned object URL behind `GET /api/v1/trader/{address}/export/download`
1019
+ * without downloading anything (#16137).
1020
+ *
1021
+ * The route answers `302` with a `Location`, which `call()` treats as an
1022
+ * error. This reads the redirect target and stops there, so a caller can
1023
+ * hand the URL to a download manager or a browser. The returned URL is a
1024
+ * bearer credential in itself: anyone holding it can read the file until it
1025
+ * expires, so do not log it.
1026
+ *
1027
+ * Browser caveat: `fetch` with `redirect: "manual"` yields an opaque
1028
+ * redirect whose `Location` no script can read. The method says so rather
1029
+ * than guessing a URL; run the download from a server runtime.
1030
+ */
1031
+ async getTraderExportDownloadUrl(address, jobId, options = {}) {
1032
+ const operation = exportDownloadOperation;
1033
+ if (!this.apiKey && !this.sandbox) {
1034
+ throw new Error("downloadTraderExport requires an API key (oxi_sk_*)");
1035
+ }
1036
+ return this.request(operation, { ...options, path: { address }, query: { job_id: jobId } }, async (response) => {
1037
+ if (response.type === "opaqueredirect" || response.status === 0) {
1038
+ throw new InvalidResponseError(302, "This runtime hides redirect targets: fetch with redirect: \"manual\" returned an opaque redirect, so the presigned download URL cannot be read. Run the export download from a server runtime (Node 18+, Deno, Bun, a Worker).", null);
1039
+ }
1040
+ const location = response.headers.get("location");
1041
+ if (!location) {
1042
+ throw new InvalidResponseError(response.status, "GET /api/v1/trader/{address}/export/download answered a redirect with no Location header.", null);
1043
+ }
1044
+ return presignedTarget(location);
1045
+ }, {
1046
+ // The 302 IS the success here, so it must not enter the error path.
1047
+ isSuccess: (response) => response.status === 302 || response.type === "opaqueredirect",
1048
+ redirect: "manual",
1049
+ });
1050
+ }
1051
+ /**
1052
+ * Download a finished export (#16137): resolve the `302`, then fetch the
1053
+ * object store directly.
1054
+ *
1055
+ * The second request carries NO headers at all, so the live API key never
1056
+ * reaches the object store; the presigned URL is its own credential. The
1057
+ * body is not buffered -- read `response.body` as a stream and write it
1058
+ * where it belongs.
1059
+ *
1060
+ * There is no default deadline on the object fetch, the way `streamFeed`
1061
+ * has none: a multi-gigabyte export would fail the 15-second REST default
1062
+ * halfway through. Pass `signal` to cancel it, or `downloadTimeoutMs` for
1063
+ * a deadline of your own. `timeoutMs` still bounds the redirect request.
1064
+ *
1065
+ * @example
1066
+ * const { response, filename } = await client.downloadTraderExport(address, jobId);
1067
+ * await pipeline(Readable.fromWeb(response.body), createWriteStream(filename ?? "export.json"));
1068
+ */
1069
+ async downloadTraderExport(address, jobId, options = {}) {
1070
+ const target = await this.getTraderExportDownloadUrl(address, jobId, options);
1071
+ const downloadSignal = composeRequestSignal(options.downloadTimeoutMs ?? null, options.signal);
1072
+ let response;
1073
+ try {
1074
+ // No headers, and no credentials: the presigned URL authorizes itself,
1075
+ // and forwarding the bearer would hand the live API key to the object
1076
+ // store on every download.
1077
+ response = await this.fetchImpl(target.url, {
1078
+ method: "GET",
1079
+ signal: downloadSignal.signal,
1080
+ });
1081
+ }
1082
+ catch (error) {
1083
+ downloadSignal.dispose?.();
1084
+ throw error;
1085
+ }
1086
+ if (!response.ok) {
1087
+ downloadSignal.dispose?.();
1088
+ throw new InvalidResponseError(response.status, `The presigned export URL answered ${response.status}. A presigned link is valid for about an hour; request a fresh one with getTraderExportDownloadUrl.`, null);
1089
+ }
1090
+ // The caller reads the body after this returns, so the composed signal
1091
+ // (caller abort plus `downloadTimeoutMs`) stays wired until the body ends
1092
+ // or is cancelled, not only until the headers arrive (#16686).
1093
+ response = releaseWhenBodySettles(response, downloadSignal.dispose);
1094
+ const contentLength = response.headers.get("content-length");
1095
+ return {
1096
+ ...target,
1097
+ response,
1098
+ contentLength: contentLength === null ? null : Number.parseInt(contentLength, 10),
1099
+ contentType: response.headers.get("content-type"),
1100
+ filename: filenameFromDisposition(response.headers.get("content-disposition")),
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Look up an operation and refuse it when the caller reached for the wrong
1105
+ * reader (#16137): `call()` on a Markdown route used to fail as a bad 200.
1106
+ */
1107
+ resolveOperation(operationId, expected, requireCredential = true) {
561
1108
  const operation = operationsById.get(operationId);
562
1109
  if (!operation) {
563
1110
  throw new Error(`Unknown 0xinsider API operation: ${operationId}`);
564
1111
  }
565
- if (operation.auth === "bearer" && !this.apiKey) {
1112
+ const kind = operation.kind ?? "envelope";
1113
+ if (kind !== expected) {
1114
+ throw new Error(`${operationId} answers a "${kind}" body, not "${expected}": use ${READER_FOR_KIND[kind]}.`);
1115
+ }
1116
+ // The sandbox answers every operation without a credential; production
1117
+ // does not, and the local check saves a round trip that can only be 401.
1118
+ if (requireCredential && operation.auth === "bearer" && !this.apiKey && !this.sandbox) {
566
1119
  throw new Error(`${operationId} requires an API key (oxi_sk_*)`);
567
1120
  }
1121
+ return operation;
1122
+ }
1123
+ /**
1124
+ * One request pipeline for every transport: URL and headers, the retry
1125
+ * policy, one deadline shared by every attempt, the typed error hierarchy,
1126
+ * and cancellation held live through body consumption. `consume` runs
1127
+ * inside that window, so a slow body is still covered by the deadline and
1128
+ * by the caller's `signal`.
1129
+ */
1130
+ async request(operation, options, consume, transport = {}) {
1131
+ const operationId = operation.operationId;
568
1132
  const timeoutMs = options.timeoutMs === undefined ? this.timeoutMs : options.timeoutMs;
569
1133
  const maxRetries = options.maxRetries === undefined
570
1134
  ? this.maxRetries
571
1135
  : assertRetryCount(options.maxRetries, "maxRetries");
572
1136
  const url = this.buildUrl(operation, options);
573
- const headers = this.buildHeaders(operation, options);
1137
+ const headers = this.buildHeaders(operation, options, transport.accept);
574
1138
  const body = options.body === undefined ? undefined : JSON.stringify(options.body);
1139
+ const isSuccess = transport.isSuccess ??
1140
+ ((response) => response.ok || response.status === 304);
1141
+ // A key the server does not read is a false promise of safety: the
1142
+ // request would be retried as though replayable while the route repeats
1143
+ // its effect (#16182). Refuse it here, before anything is sent.
1144
+ const eligibility = retryEligibility(operation);
1145
+ const keyed = headers.has("idempotency-key");
1146
+ if (keyed && eligibility !== "keyed") {
1147
+ throw new Error(`${operationId} does not honour Idempotency-Key; the API replays only ${IDEMPOTENT_WRITE_OPERATIONS.join(", ")}. Remove idempotencyKey: a retry of this request could repeat its side effect.`);
1148
+ }
575
1149
  // Replaying a write without a key could repeat it (a second webhook
576
- // endpoint, say), so only a GET or a keyed write is retried (#14281).
577
- const retryable = operation.method === "GET" || headers.has("idempotency-key");
1150
+ // endpoint, say), so only a read or a keyed write is retried (#14281).
1151
+ // `headers` and `body` are built once, above, so every attempt carries
1152
+ // the same key and the same bytes.
1153
+ const retryable = eligibility === "read" || (eligibility === "keyed" && keyed);
578
1154
  // One deadline for every attempt: a retry that cannot finish before it is
579
1155
  // not started, so the caller sees the real failure, not a timeout.
580
1156
  const deadlineAt = timeoutMs === null ? null : Date.now() + timeoutMs;
@@ -598,6 +1174,7 @@ export class OxinsiderApiClient {
598
1174
  headers,
599
1175
  body,
600
1176
  signal: requestSignal.signal,
1177
+ ...(transport.redirect ? { redirect: transport.redirect } : {}),
601
1178
  });
602
1179
  }
603
1180
  catch (error) {
@@ -610,7 +1187,7 @@ export class OxinsiderApiClient {
610
1187
  await sleepUnlessAborted(delay, requestSignal.signal);
611
1188
  continue;
612
1189
  }
613
- if (response.ok || response.status === 304)
1190
+ if (isSuccess(response))
614
1191
  break;
615
1192
  const errorBody = await parseJson(response);
616
1193
  const retryAfter = retryAfterSeconds(response);
@@ -622,22 +1199,7 @@ export class OxinsiderApiClient {
622
1199
  }
623
1200
  await sleepUnlessAborted(delay, requestSignal.signal);
624
1201
  }
625
- if (response.status === 304) {
626
- return notModifiedResponse(response);
627
- }
628
- const envelope = await parseJson(response);
629
- if (isApiEnvelope(envelope)) {
630
- const etag = response.headers.get("etag");
631
- // Only ADD to a meta the server actually sent. The contract makes
632
- // `meta` required on every envelope, so synthesizing one from `?? {}`
633
- // would hand a caller a `ResponseMeta` missing its required fields --
634
- // which is what the hand-written type hid before #14278.
635
- if (etag && envelope.meta) {
636
- envelope.meta = { ...envelope.meta, etag };
637
- }
638
- return envelope;
639
- }
640
- throw new OxinsiderApiError(response.status, envelope);
1202
+ return await consume(response);
641
1203
  }
642
1204
  catch (error) {
643
1205
  if (timeoutMs !== null &&
@@ -655,66 +1217,151 @@ export class OxinsiderApiClient {
655
1217
  requestSignal.dispose?.();
656
1218
  }
657
1219
  }
658
- /**
659
- * Execute a Stripe-style list operation and return the list envelope.
660
- * Use `paginate()` (in `pagination.ts`) to auto-follow `next_cursor`.
661
- */
662
1220
  async list(operationId, options = {}) {
663
1221
  const result = await this.call(operationId, options);
664
1222
  if (isNotModified(result)) {
665
1223
  throw new OxinsiderApiError(304, "list() received a 304 not_modified; use call() if you send If-None-Match on a list endpoint");
666
1224
  }
667
1225
  if (!isListEnvelope(result)) {
668
- throw new OxinsiderApiError(200, `Operation ${operationId} did not return a list envelope`);
1226
+ throw new InvalidResponseError(200, `Operation ${operationId} did not return a list envelope: expected object "list", an array data, a boolean has_more and a string or absent next_cursor`, result);
669
1227
  }
670
1228
  return result;
671
1229
  }
672
1230
  // --- Typed convenience methods (key surfaces) ---
1231
+ //
1232
+ // Each takes the route's path parameters positionally, its documented
1233
+ // query as `params` (list reads) or `options.query` (single reads), and
1234
+ // the shared transport options: `signal`, `timeoutMs`, `maxRetries`,
1235
+ // `headers` (`If-None-Match` for a conditional read) and `idempotencyKey`
1236
+ // on a keyed write. The result is the operation's own envelope; no method
1237
+ // takes a type argument any more (#16136).
673
1238
  getTrader(address, options = {}) {
674
1239
  return this.call("getTrader", { ...options, path: { address } });
675
1240
  }
676
1241
  getTraderPnl(address, options = {}) {
677
1242
  return this.call("getTraderPnl", { ...options, path: { address } });
678
1243
  }
679
- batchGetTraders(identifiers, options = {}) {
680
- return this.call("batchGetTraders", {
1244
+ /**
1245
+ * Fetch one wallet's win record per canonical category.
1246
+ *
1247
+ * Counts every settled market at any position size, so it can differ from
1248
+ * `category_strengths` on `getTrader`, which reads the floored calibration
1249
+ * sample. A category under `min_decided_for_win_rate` keeps its `wins` and
1250
+ * `decided` with `win_rate: null` and `status: "not_enough_data"`; a category
1251
+ * the wallet has no settled market in is absent, which means no record rather
1252
+ * than a 0% record. The `Esports` record carries `games`: the wallet's record
1253
+ * per esports title (`LoL`, `CS2`, `Dota 2`, ...) under the same rule, named
1254
+ * as the holder chips name them in `category_win_rate_game`. Pass
1255
+ * `params.category` to filter to one bucket; a filter that reaches Esports
1256
+ * returns its games too.
1257
+ */
1258
+ getTraderCategoryRecords(address, params = {}, options = {}) {
1259
+ return this.call("getTraderCategoryRecords", {
681
1260
  ...options,
682
- body: { identifiers },
1261
+ path: { address },
1262
+ query: params,
683
1263
  });
684
1264
  }
685
- listLeaderboard(params = {}) {
686
- return this.list("listLeaderboard", { query: params });
1265
+ /**
1266
+ * Resolve 1 to 25 traders in one request (`POST /api/v1/traders/batch`).
1267
+ *
1268
+ * `traders` is sent as the request body's `traders` array, the field the
1269
+ * route requires: wallet addresses, usernames, `trd_` ids or integer trader
1270
+ * ids, resolved in input order. Until #16135 this method sent the array as
1271
+ * `identifiers`, a field the route does not declare, so every call was
1272
+ * refused for a missing `traders`. `options.expand` is sent as the body's
1273
+ * `expand` array and applies to every item.
1274
+ *
1275
+ * The response `data` keeps request order and one row per input, duplicates
1276
+ * included; a row is `status: "ok"` with `data`, or `status: "error"` with
1277
+ * the item's own `error` (an unknown identity is a per-item `not_found`, not
1278
+ * a request failure). `meta` is the batch's `BatchResponseMeta`:
1279
+ * `request_cost` and `rate_limit` follow the batch item quota, not the
1280
+ * per-request one.
1281
+ */
1282
+ batchGetTraders(traders, options = {}) {
1283
+ const { expand, ...request } = options;
1284
+ return this.call("batchGetTraders", {
1285
+ ...request,
1286
+ body: expand === undefined ? { traders } : { traders, expand },
1287
+ });
1288
+ }
1289
+ /**
1290
+ * Resolve up to 25 markets' intel in one request
1291
+ * (`POST /api/v1/markets/intel/batch`); `meta` is the batch's own.
1292
+ */
1293
+ batchGetMarketIntel(body, options = {}) {
1294
+ return this.call("batchGetMarketIntel", { ...options, body });
1295
+ }
1296
+ listLeaderboard(params = {}, options = {}) {
1297
+ return this.list("listLeaderboard", { ...options, query: params });
687
1298
  }
688
- listTrendingWallets(params = {}) {
689
- return this.list("listTrendingWallets", { query: params });
1299
+ listTrendingWallets(params = {}, options = {}) {
1300
+ return this.list("listTrendingWallets", { ...options, query: params });
690
1301
  }
691
- listWhaleTrades(params = {}) {
692
- return this.list("listWhaleTrades", { query: params });
1302
+ listLargeTrades(params = {}, options = {}) {
1303
+ return this.list("listLargeTrades", { ...options, query: params });
693
1304
  }
694
- listWhaleTradeHistory(params = {}) {
695
- return this.list("listWhaleTradeHistory", { query: params });
1305
+ listLargeTradeHistory(params = {}, options = {}) {
1306
+ return this.list("listLargeTradeHistory", { ...options, query: params });
696
1307
  }
697
- listPositions(params = {}) {
698
- return this.list("listPositions", { query: params });
1308
+ /** @deprecated Use {@link listLargeTrades} (#16304); calls the deprecated `/api/v1/whale-trades`. */
1309
+ listWhaleTrades(params = {}, options = {}) {
1310
+ return this.list("listWhaleTrades", { ...options, query: params });
699
1311
  }
700
- listLargePositions(params = {}) {
701
- return this.list("listLargePositions", { query: params });
1312
+ /** @deprecated Use {@link listLargeTradeHistory} (#16304); calls the deprecated `/api/v1/whale-trades/history`. */
1313
+ listWhaleTradeHistory(params = {}, options = {}) {
1314
+ return this.list("listWhaleTradeHistory", { ...options, query: params });
702
1315
  }
703
- listSmartMoneyFlows(params = {}) {
704
- return this.list("listSmartMoneyFlows", { query: params });
1316
+ listPositions(params = {}, options = {}) {
1317
+ return this.list("listPositions", { ...options, query: params });
1318
+ }
1319
+ listLargePositions(params = {}, options = {}) {
1320
+ return this.list("listLargePositions", { ...options, query: params });
1321
+ }
1322
+ listSmartMoneyFlows(params = {}, options = {}) {
1323
+ return this.list("listSmartMoneyFlows", { ...options, query: params });
705
1324
  }
706
1325
  /** Canonical alias of {@link listSmartMoneyFlows} (epic #6912). */
707
- listSharpMoneyFlows(params = {}) {
708
- return this.list("listSharpMoneyFlows", { query: params });
1326
+ listSharpMoneyFlows(params = {}, options = {}) {
1327
+ return this.list("listSharpMoneyFlows", { ...options, query: params });
709
1328
  }
710
1329
  /**
711
- * List the funded-primary sports signals with additive, shadow-only category
712
- * evidence. `category_skill` never changes membership, ordering, or sizing.
1330
+ * List covered games: both sides with their provider ids and live scores, the
1331
+ * UTC kickoff, the provider's own status, and every linked Polymarket market
1332
+ * with its condition id and outcome token ids. Ordered by kickoff, then by
1333
+ * `event_slug`, with unscheduled games last. An unknown `sport` or `status`
1334
+ * returns an empty page rather than an error, and the response's `coverage`
1335
+ * names what this deployment serves.
713
1336
  */
714
- listSportsEdgeSignals(params = {}) {
715
- return this.list("listSportsEdgeSignals", {
716
- query: params,
717
- });
1337
+ listGames(params = {}, options = {}) {
1338
+ return this.list("listGames", { ...options, query: params });
1339
+ }
1340
+ /**
1341
+ * Read one game by its `event_slug`, the identity the `live_sports_updated`
1342
+ * webhook pulse carries. A slug outside the published coverage returns 404.
1343
+ */
1344
+ getGame(eventSlug, options = {}) {
1345
+ return this.call("getGame", { ...options, path: { event_slug: eventSlug } });
1346
+ }
1347
+ /**
1348
+ * List upcoming games ranked by the side profitable wallets hold, with
1349
+ * additive, shadow-only category evidence. `category_skill` never changes
1350
+ * membership, ordering, or sizing. Rows carry `side`, `ranked_at`,
1351
+ * `backing_score` and `side_share`; the older `piled_side`,
1352
+ * `signal_created_at`, `conviction_score` and `smart_score` keys carry the
1353
+ * same values and stay on the wire.
1354
+ */
1355
+ listPreGameSides(params = {}, options = {}) {
1356
+ return this.list("listPreGameSides", { ...options, query: params });
1357
+ }
1358
+ /**
1359
+ * @deprecated Use {@link listPreGameSides} (#16310). Kept live; it calls the
1360
+ * deprecated `/api/v1/sports-edge-signals` path, which answers with
1361
+ * `Deprecation` and successor `Link` headers and the same body.
1362
+ */
1363
+ listSportsEdgeSignals(params = {}, options = {}) {
1364
+ return this.list("listSportsEdgeSignals", { ...options, query: params });
718
1365
  }
719
1366
  /**
720
1367
  * Read an explicitly observation-only sports cohort and its accountable
@@ -725,8 +1372,23 @@ export class OxinsiderApiClient {
725
1372
  * additive wider-holder projection, not an arrival-history or independent
726
1373
  * denominator view.
727
1374
  */
1375
+ async listPreGameSideObservations(params, options = {}) {
1376
+ const response = await this.list("listPreGameSideObservations", {
1377
+ ...options,
1378
+ query: params,
1379
+ });
1380
+ return sportsEdgeObservationsResponse(response);
1381
+ }
1382
+ /**
1383
+ * @deprecated Use {@link listPreGameSideObservations} (#16310). Kept live; it
1384
+ * calls the deprecated `/api/v1/sports-edge-observations` path, which answers
1385
+ * with `Deprecation` and successor `Link` headers and the same body.
1386
+ */
728
1387
  async listSportsEdgeObservations(params, options = {}) {
729
- const response = await this.list("listSportsEdgeObservations", { ...options, query: params });
1388
+ const response = await this.list("listSportsEdgeObservations", {
1389
+ ...options,
1390
+ query: params,
1391
+ });
730
1392
  return sportsEdgeObservationsResponse(response);
731
1393
  }
732
1394
  /**
@@ -738,24 +1400,40 @@ export class OxinsiderApiClient {
738
1400
  * projection cutoff inside `next_cursor` is excluded while its stable page
739
1401
  * position remains covered.
740
1402
  */
741
- async listSportsEdgeObservationsConditional(params, options = {}) {
742
- const response = await this.call("listSportsEdgeObservations", { ...options, query: params });
1403
+ async listPreGameSideObservationsConditional(params, options = {}) {
1404
+ const response = await this.call("listPreGameSideObservations", {
1405
+ ...options,
1406
+ query: params,
1407
+ });
743
1408
  if (isNotModified(response)) {
744
1409
  return response;
745
1410
  }
746
- if (!isListEnvelope(response)) {
747
- throw new OxinsiderApiError(200, "listSportsEdgeObservations did not return a list envelope");
748
- }
749
1411
  return sportsEdgeObservationsResponse(response);
750
1412
  }
751
- searchMarkets(q, options = {}) {
752
- return this.call("searchMarkets", {
1413
+ /**
1414
+ * @deprecated Use {@link listPreGameSideObservationsConditional} (#16310).
1415
+ * Kept live on the deprecated `/api/v1/sports-edge-observations` path.
1416
+ */
1417
+ async listSportsEdgeObservationsConditional(params, options = {}) {
1418
+ const response = await this.call("listSportsEdgeObservations", {
753
1419
  ...options,
754
- query: { q, ...options.query },
1420
+ query: params,
755
1421
  });
1422
+ if (isNotModified(response)) {
1423
+ return response;
1424
+ }
1425
+ return sportsEdgeObservationsResponse(response);
1426
+ }
1427
+ searchMarkets(q, options = {}) {
1428
+ const { query, ...request } = options;
1429
+ return this.list("searchMarkets", { ...request, query: { ...query, q } });
1430
+ }
1431
+ searchContent(q, options = {}) {
1432
+ const { query, ...request } = options;
1433
+ return this.list("searchContent", { ...request, query: { ...query, q } });
756
1434
  }
757
- exploreMarkets(params = {}) {
758
- return this.list("exploreMarkets", { query: params });
1435
+ exploreMarkets(params = {}, options = {}) {
1436
+ return this.list("exploreMarkets", { ...options, query: params });
759
1437
  }
760
1438
  getMarketIntel(conditionId, options = {}) {
761
1439
  return this.call("getMarketIntel", {
@@ -769,37 +1447,98 @@ export class OxinsiderApiClient {
769
1447
  path: { condition_id: conditionId },
770
1448
  });
771
1449
  }
1450
+ /**
1451
+ * One page of a market's graded (S/A/B) holder roster from a complete
1452
+ * provider holder scan: the list a Pick of the Day shows, for any market.
1453
+ * `params.outcome` (`yes` | `no` | `all`), `params.min_grade` (`S` | `A` |
1454
+ * `B`), `params.limit` and `params.cursor` (`mh_` prefix). The page carries
1455
+ * the `market`, `scan` and roster `totals` beside `data`; `total` is the
1456
+ * count matching the filters across every page.
1457
+ */
1458
+ getMarketHolders(conditionId, params = {}, options = {}) {
1459
+ return this.list("getMarketHolders", {
1460
+ ...options,
1461
+ path: { condition_id: conditionId },
1462
+ query: params,
1463
+ });
1464
+ }
772
1465
  /**
773
1466
  * Fetch bucketed OHLC candles for a market's outcome tokens.
774
- * Resolution defaults to the server default; pass `query.resolution` as
775
- * `"1d"` or `"1w"` to override.
1467
+ * Resolution defaults to the server default; pass `params.resolution` as
1468
+ * `"1d"` or `"1w"` to override. `params.from` is exclusive and `params.to`
1469
+ * is inclusive; when both are present, `from` must be less than or equal to
1470
+ * `to`.
776
1471
  */
777
- getMarketCandles(conditionId, options = {}) {
1472
+ getMarketCandles(conditionId, params = {}, options = {}) {
778
1473
  return this.call("getMarketCandles", {
779
1474
  ...options,
780
1475
  path: { condition_id: conditionId },
1476
+ query: params,
781
1477
  });
782
1478
  }
1479
+ /**
1480
+ * Fetch one suspicious trade by raw `whale_alerts.id` or the `rf_`-prefixed
1481
+ * id list responses emit. The envelope's `object` is `"suspicious_trade"`.
1482
+ */
1483
+ getSuspiciousTrade(id, options = {}) {
1484
+ return this.call("getSuspiciousTrade", { ...options, path: { id } });
1485
+ }
1486
+ /** List stored trades whose recorded suspicion score meets the flag threshold. */
1487
+ listSuspiciousTrades(params = {}, options = {}) {
1488
+ return this.list("listSuspiciousTrades", { ...options, query: params });
1489
+ }
1490
+ /**
1491
+ * @deprecated Use `getSuspiciousTrade()`; renamed in #16301. This method
1492
+ * keeps calling the deprecated `GET /api/v1/insider-radar/{id}`, which stays
1493
+ * live with no retirement date and still answers `object: "radar_flag"`, so
1494
+ * an integration branching on that envelope keeps working.
1495
+ */
783
1496
  getInsiderRadarFlag(id, options = {}) {
784
1497
  return this.call("getInsiderRadarFlag", { ...options, path: { id } });
785
1498
  }
786
- listInsiderRadar(params = {}) {
787
- return this.list("listInsiderRadar", { query: params });
1499
+ /**
1500
+ * @deprecated Use `listSuspiciousTrades()`; renamed in #16301. This method
1501
+ * keeps calling the deprecated `GET /api/v1/insider-radar`, which stays live
1502
+ * with no retirement date and answers with `Deprecation` plus a `Link
1503
+ * rel="successor-version"` header.
1504
+ */
1505
+ listInsiderRadar(params = {}, options = {}) {
1506
+ return this.list("listInsiderRadar", { ...options, query: params });
1507
+ }
1508
+ getLargeTrade(id, options = {}) {
1509
+ return this.call("getLargeTrade", { ...options, path: { id } });
1510
+ }
1511
+ /** @deprecated Use {@link getLargeTrade} (#16304); calls the deprecated `/api/v1/whale-trades/{id}`. */
1512
+ getWhaleTrade(id, options = {}) {
1513
+ return this.call("getWhaleTrade", { ...options, path: { id } });
788
1514
  }
789
1515
  // --- Webhooks ---
790
1516
  listWebhooks(options = {}) {
791
- return this.call("listWebhooks", options);
1517
+ return this.list("listWebhooks", options);
1518
+ }
1519
+ getWebhook(id, options = {}) {
1520
+ return this.call("getWebhook", { ...options, path: { id } });
1521
+ }
1522
+ createWebhook(body, options = {}) {
1523
+ return this.call("createWebhook", { ...options, body });
1524
+ }
1525
+ updateWebhook(id, body, options = {}) {
1526
+ return this.call("updateWebhook", { ...options, path: { id }, body });
1527
+ }
1528
+ deleteWebhook(id, options = {}) {
1529
+ return this.call("deleteWebhook", { ...options, path: { id } });
792
1530
  }
793
1531
  /**
794
1532
  * Fetch the self-describing webhook event catalog (each entry's `id`,
795
1533
  * description, payload shape, and active/dormant status).
796
1534
  */
797
1535
  listWebhookEvents(options = {}) {
798
- return this.call("listWebhookEvents", options);
1536
+ return this.list("listWebhookEvents", options);
799
1537
  }
800
1538
  /** List delivery attempts for one webhook endpoint (Stripe-style list). */
801
- listWebhookDeliveries(webhookId, params = {}) {
1539
+ listWebhookDeliveries(webhookId, params = {}, options = {}) {
802
1540
  return this.list("listWebhookDeliveries", {
1541
+ ...options,
803
1542
  path: { id: webhookId },
804
1543
  query: params,
805
1544
  });
@@ -825,9 +1564,44 @@ export class OxinsiderApiClient {
825
1564
  getApiDiscovery(options = {}) {
826
1565
  return this.call("getApiDiscovery", options);
827
1566
  }
1567
+ getPlatforms(options = {}) {
1568
+ return this.call("getPlatforms", options);
1569
+ }
828
1570
  getAccountIdentity(options = {}) {
829
1571
  return this.call("getAccountIdentity", options);
830
1572
  }
1573
+ /**
1574
+ * One wallet's context as Markdown, ready to paste into a model prompt
1575
+ * (`GET /api/v1/trader/{address}/context.md`). The JSON form of the same
1576
+ * read is `getTraderContext`.
1577
+ */
1578
+ getTraderContextMarkdown(address, options = {}) {
1579
+ return this.text("getTraderContextMarkdown", { ...options, path: { address } });
1580
+ }
1581
+ /**
1582
+ * One market's context as Markdown
1583
+ * (`GET /api/v1/market/{condition_id}/context.md`). The JSON form is
1584
+ * `getMarketIntel`.
1585
+ */
1586
+ getMarketContextMarkdown(conditionId, options = {}) {
1587
+ return this.text("getMarketContextMarkdown", {
1588
+ ...options,
1589
+ path: { condition_id: conditionId },
1590
+ });
1591
+ }
1592
+ /**
1593
+ * Mint a sandbox key (`POST /api/v1/agents/register`), the one write that
1594
+ * needs no credential. Answers `201`, which is why the SDK had no method
1595
+ * for it until #16137: the drift gate counted only operations with a
1596
+ * documented `200`. `meta.status` is `201`; `data.api_key` is the
1597
+ * `oxi_sk_test_*` key to pass to `OxinsiderApiClient.sandbox()`.
1598
+ *
1599
+ * Nothing is stored: the key cannot be listed or revoked and does not
1600
+ * expire. Register again for another one.
1601
+ */
1602
+ registerAgent(options = {}) {
1603
+ return this.call("registerAgent", options);
1604
+ }
831
1605
  getUsage(options = {}) {
832
1606
  return this.call("getUsage", options);
833
1607
  }
@@ -862,12 +1636,16 @@ export class OxinsiderApiClient {
862
1636
  getApiKey() {
863
1637
  return this.apiKey;
864
1638
  }
1639
+ /** Whether this client was built in sandbox mode (#16138). */
1640
+ isSandbox() {
1641
+ return this.sandbox;
1642
+ }
865
1643
  /** The resolved fetch implementation (used by the SSE stream consumer). */
866
1644
  getFetch() {
867
1645
  return this.fetchImpl;
868
1646
  }
869
1647
  buildUrl(operation, options) {
870
- const url = new URL(interpolatePath(operation.path, options.path ?? {}), `${this.baseUrl}/`);
1648
+ const url = resolveApiUrl(this.baseUrl, interpolatePath(operation.path, options.path ?? {}));
871
1649
  for (const [key, value] of Object.entries(options.query ?? {})) {
872
1650
  if (value === null || value === undefined) {
873
1651
  continue;
@@ -883,9 +1661,9 @@ export class OxinsiderApiClient {
883
1661
  }
884
1662
  return url.toString();
885
1663
  }
886
- buildHeaders(operation, options) {
1664
+ buildHeaders(operation, options, accept = "application/json") {
887
1665
  const headers = new Headers(options.headers);
888
- headers.set("accept", "application/json");
1666
+ headers.set("accept", accept);
889
1667
  if (options.body !== undefined && !headers.has("content-type")) {
890
1668
  headers.set("content-type", "application/json");
891
1669
  }
@@ -895,9 +1673,18 @@ export class OxinsiderApiClient {
895
1673
  if (options.idempotencyKey !== undefined) {
896
1674
  headers.set("idempotency-key", options.idempotencyKey);
897
1675
  }
1676
+ if (options.strictQuery) {
1677
+ headers.set("x-query-validation", "strict");
1678
+ }
898
1679
  return headers;
899
1680
  }
900
1681
  }
1682
+ /**
1683
+ * Narrow a `call()` result to the typed 304. Takes any envelope-shaped
1684
+ * value, so it works on an `OperationResult<K>` (whose `meta` is the
1685
+ * operation's own type) as well as the loose `ApiClientResponse<T>`
1686
+ * (#16136).
1687
+ */
901
1688
  export function isApiNotModifiedResponse(response) {
902
1689
  return response.object === "not_modified";
903
1690
  }
@@ -925,13 +1712,6 @@ async function parseJson(response) {
925
1712
  return text;
926
1713
  }
927
1714
  }
928
- function retryAfterSeconds(response) {
929
- const header = response.headers.get("retry-after");
930
- if (!header)
931
- return null;
932
- const seconds = Number(header);
933
- return Number.isFinite(seconds) ? seconds : null;
934
- }
935
1715
  function notModifiedResponse(response) {
936
1716
  const meta = { status: 304 };
937
1717
  const etag = response.headers.get("etag");
@@ -942,6 +1722,14 @@ function notModifiedResponse(response) {
942
1722
  if (requestId) {
943
1723
  meta.request_id = requestId;
944
1724
  }
1725
+ const queryIgnored = response.headers.get("x-query-ignored");
1726
+ if (queryIgnored) {
1727
+ meta.queryIgnored = queryIgnored;
1728
+ }
1729
+ const effectiveQuery = response.headers.get("x-effective-query");
1730
+ if (effectiveQuery) {
1731
+ meta.effectiveQuery = effectiveQuery;
1732
+ }
945
1733
  return { object: "not_modified", data: null, meta };
946
1734
  }
947
1735
  function isApiEnvelope(body) {
@@ -950,14 +1738,23 @@ function isApiEnvelope(body) {
950
1738
  "object" in body &&
951
1739
  "data" in body);
952
1740
  }
1741
+ /**
1742
+ * Whether `body` is a list envelope whose control fields the walker can act
1743
+ * on: `object: "list"`, an array `data`, a BOOLEAN `has_more`, and a
1744
+ * `next_cursor` that is a string, `null` or absent (#16246: the check used to
1745
+ * accept any `has_more` that was merely present, so a malformed value could
1746
+ * be coerced into "no more pages" or "more pages" by truthiness).
1747
+ */
953
1748
  export function isListEnvelope(body) {
954
- return (typeof body === "object" &&
955
- body !== null &&
956
- "object" in body &&
957
- body.object === "list" &&
958
- "data" in body &&
959
- Array.isArray(body.data) &&
960
- "has_more" in body);
1749
+ if (typeof body !== "object" || body === null)
1750
+ return false;
1751
+ const candidate = body;
1752
+ return (candidate.object === "list" &&
1753
+ Array.isArray(candidate.data) &&
1754
+ typeof candidate.has_more === "boolean" &&
1755
+ (candidate.next_cursor === undefined ||
1756
+ candidate.next_cursor === null ||
1757
+ typeof candidate.next_cursor === "string"));
961
1758
  }
962
1759
  function sportsEdgeObservationsResponse(response) {
963
1760
  const candidate = response;