@poa-box/core 0.1.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 (220) hide show
  1. package/README.md +201 -0
  2. package/dist/abis/DirectDemocracyVotingNew.d.ts +749 -0
  3. package/dist/abis/DirectDemocracyVotingNew.js +961 -0
  4. package/dist/abis/ERC20.d.ts +90 -0
  5. package/dist/abis/ERC20.js +125 -0
  6. package/dist/abis/EducationHubNew.d.ts +590 -0
  7. package/dist/abis/EducationHubNew.js +762 -0
  8. package/dist/abis/EligibilityModuleNew.d.ts +1615 -0
  9. package/dist/abis/EligibilityModuleNew.js +2082 -0
  10. package/dist/abis/Executor.d.ts +619 -0
  11. package/dist/abis/Executor.js +796 -0
  12. package/dist/abis/HybridVotingNew.d.ts +898 -0
  13. package/dist/abis/HybridVotingNew.js +1149 -0
  14. package/dist/abis/ImplementationRegistry.d.ts +270 -0
  15. package/dist/abis/ImplementationRegistry.js +355 -0
  16. package/dist/abis/OrgDeployerNew.d.ts +1275 -0
  17. package/dist/abis/OrgDeployerNew.js +1630 -0
  18. package/dist/abis/OrgRegistry.d.ts +670 -0
  19. package/dist/abis/OrgRegistry.js +868 -0
  20. package/dist/abis/ParticipationToken.d.ts +1086 -0
  21. package/dist/abis/ParticipationToken.js +1415 -0
  22. package/dist/abis/PasskeyAccount.d.ts +788 -0
  23. package/dist/abis/PasskeyAccount.js +1019 -0
  24. package/dist/abis/PasskeyAccountFactory.d.ts +344 -0
  25. package/dist/abis/PasskeyAccountFactory.js +449 -0
  26. package/dist/abis/PaymasterHub.d.ts +1603 -0
  27. package/dist/abis/PaymasterHub.js +2048 -0
  28. package/dist/abis/PaymentManager.d.ts +520 -0
  29. package/dist/abis/PaymentManager.js +671 -0
  30. package/dist/abis/PoaManager.d.ts +344 -0
  31. package/dist/abis/PoaManager.js +449 -0
  32. package/dist/abis/QuickJoinNew.d.ts +855 -0
  33. package/dist/abis/QuickJoinNew.js +1098 -0
  34. package/dist/abis/TaskManagerNew.d.ts +1236 -0
  35. package/dist/abis/TaskManagerNew.js +1573 -0
  36. package/dist/abis/ToggleModule.d.ts +193 -0
  37. package/dist/abis/ToggleModule.js +255 -0
  38. package/dist/abis/UniversalAccountRegistry.d.ts +577 -0
  39. package/dist/abis/UniversalAccountRegistry.js +750 -0
  40. package/dist/abis/ZkEmailInvites.d.ts +823 -0
  41. package/dist/abis/ZkEmailInvites.js +1060 -0
  42. package/dist/abis/external/AaveGovernanceV2.d.ts +128 -0
  43. package/dist/abis/external/AaveGovernanceV2.js +177 -0
  44. package/dist/abis/external/AaveGovernanceV3.d.ts +160 -0
  45. package/dist/abis/external/AaveGovernanceV3.js +220 -0
  46. package/dist/abis/external/AragonVoting.d.ts +107 -0
  47. package/dist/abis/external/AragonVoting.js +150 -0
  48. package/dist/abis/external/CurveGaugeController.d.ts +95 -0
  49. package/dist/abis/external/CurveGaugeController.js +130 -0
  50. package/dist/abis/external/CurveVotingEscrow.d.ts +86 -0
  51. package/dist/abis/external/CurveVotingEscrow.js +116 -0
  52. package/dist/abis/external/GovernorAlpha.d.ts +172 -0
  53. package/dist/abis/external/GovernorAlpha.js +228 -0
  54. package/dist/abis/external/MakerDAOChief.d.ts +92 -0
  55. package/dist/abis/external/MakerDAOChief.js +129 -0
  56. package/dist/abis/external/OZGovernor.d.ts +227 -0
  57. package/dist/abis/external/OZGovernor.js +317 -0
  58. package/dist/abis/external/SolidlyVotingEscrow.d.ts +196 -0
  59. package/dist/abis/external/SolidlyVotingEscrow.js +260 -0
  60. package/dist/abis/index.d.ts +62 -0
  61. package/dist/abis/index.js +98 -0
  62. package/dist/chains.d.ts +150 -0
  63. package/dist/chains.js +320 -0
  64. package/dist/context.d.ts +48 -0
  65. package/dist/context.js +38 -0
  66. package/dist/contracts.d.ts +24 -0
  67. package/dist/contracts.js +41 -0
  68. package/dist/encoding.d.ts +58 -0
  69. package/dist/encoding.js +243 -0
  70. package/dist/env.d.ts +10 -0
  71. package/dist/env.js +4 -0
  72. package/dist/error-catalog.d.ts +41 -0
  73. package/dist/error-catalog.js +941 -0
  74. package/dist/errors.d.ts +27 -0
  75. package/dist/errors.js +60 -0
  76. package/dist/execute/ethers.d.ts +93 -0
  77. package/dist/execute/ethers.js +323 -0
  78. package/dist/execute/index.d.ts +7 -0
  79. package/dist/execute/index.js +23 -0
  80. package/dist/execute/sponsored.d.ts +68 -0
  81. package/dist/execute/sponsored.js +277 -0
  82. package/dist/exit-codes.d.ts +18 -0
  83. package/dist/exit-codes.js +21 -0
  84. package/dist/format.d.ts +21 -0
  85. package/dist/format.js +89 -0
  86. package/dist/graph/client.d.ts +298 -0
  87. package/dist/graph/client.js +702 -0
  88. package/dist/graph/documents/activity.d.ts +9 -0
  89. package/dist/graph/documents/activity.js +164 -0
  90. package/dist/graph/documents/beacons.d.ts +172 -0
  91. package/dist/graph/documents/beacons.js +345 -0
  92. package/dist/graph/documents/index.d.ts +21 -0
  93. package/dist/graph/documents/index.js +57 -0
  94. package/dist/graph/documents/infrastructure.d.ts +30 -0
  95. package/dist/graph/documents/infrastructure.js +34 -0
  96. package/dist/graph/documents/org.d.ts +15 -0
  97. package/dist/graph/documents/org.js +249 -0
  98. package/dist/graph/documents/paymaster.d.ts +95 -0
  99. package/dist/graph/documents/paymaster.js +101 -0
  100. package/dist/graph/documents/role.d.ts +6 -0
  101. package/dist/graph/documents/role.js +41 -0
  102. package/dist/graph/documents/roles.d.ts +50 -0
  103. package/dist/graph/documents/roles.js +112 -0
  104. package/dist/graph/documents/task.d.ts +84 -0
  105. package/dist/graph/documents/task.js +236 -0
  106. package/dist/graph/documents/token.d.ts +13 -0
  107. package/dist/graph/documents/token.js +126 -0
  108. package/dist/graph/documents/treasury.d.ts +39 -0
  109. package/dist/graph/documents/treasury.js +136 -0
  110. package/dist/graph/documents/user.d.ts +120 -0
  111. package/dist/graph/documents/user.js +302 -0
  112. package/dist/graph/documents/voting-classes.d.ts +69 -0
  113. package/dist/graph/documents/voting-classes.js +149 -0
  114. package/dist/graph/documents/voting.d.ts +28 -0
  115. package/dist/graph/documents/voting.js +163 -0
  116. package/dist/graph/documents/vouch.d.ts +79 -0
  117. package/dist/graph/documents/vouch.js +171 -0
  118. package/dist/graph/documents/zkemail.d.ts +63 -0
  119. package/dist/graph/documents/zkemail.js +188 -0
  120. package/dist/graph/index.d.ts +2 -0
  121. package/dist/graph/index.js +41 -0
  122. package/dist/index.d.ts +49 -0
  123. package/dist/index.js +92 -0
  124. package/dist/ipfs.d.ts +39 -0
  125. package/dist/ipfs.js +214 -0
  126. package/dist/label-aliases.d.ts +31 -0
  127. package/dist/label-aliases.js +70 -0
  128. package/dist/metadata/education.d.ts +30 -0
  129. package/dist/metadata/education.js +31 -0
  130. package/dist/metadata/index.d.ts +12 -0
  131. package/dist/metadata/index.js +48 -0
  132. package/dist/metadata/org.d.ts +92 -0
  133. package/dist/metadata/org.js +93 -0
  134. package/dist/metadata/proposal.d.ts +44 -0
  135. package/dist/metadata/proposal.js +45 -0
  136. package/dist/metadata/role.d.ts +38 -0
  137. package/dist/metadata/role.js +35 -0
  138. package/dist/metadata/task.d.ts +108 -0
  139. package/dist/metadata/task.js +81 -0
  140. package/dist/metadata/token.d.ts +32 -0
  141. package/dist/metadata/token.js +36 -0
  142. package/dist/metadata/user.d.ts +63 -0
  143. package/dist/metadata/user.js +70 -0
  144. package/dist/multicall.d.ts +32 -0
  145. package/dist/multicall.js +96 -0
  146. package/dist/payout.d.ts +59 -0
  147. package/dist/payout.js +96 -0
  148. package/dist/perms.d.ts +47 -0
  149. package/dist/perms.js +120 -0
  150. package/dist/preflight.d.ts +72 -0
  151. package/dist/preflight.js +269 -0
  152. package/dist/reads/education.d.ts +79 -0
  153. package/dist/reads/education.js +122 -0
  154. package/dist/reads/eligibility.d.ts +377 -0
  155. package/dist/reads/eligibility.js +687 -0
  156. package/dist/reads/index.d.ts +16 -0
  157. package/dist/reads/index.js +55 -0
  158. package/dist/reads/org.d.ts +258 -0
  159. package/dist/reads/org.js +220 -0
  160. package/dist/reads/paymaster.d.ts +147 -0
  161. package/dist/reads/paymaster.js +235 -0
  162. package/dist/reads/project.d.ts +33 -0
  163. package/dist/reads/project.js +62 -0
  164. package/dist/reads/resolve.d.ts +34 -0
  165. package/dist/reads/resolve.js +64 -0
  166. package/dist/reads/task.d.ts +286 -0
  167. package/dist/reads/task.js +280 -0
  168. package/dist/reads/token.d.ts +94 -0
  169. package/dist/reads/token.js +75 -0
  170. package/dist/reads/treasury.d.ts +222 -0
  171. package/dist/reads/treasury.js +241 -0
  172. package/dist/reads/user.d.ts +229 -0
  173. package/dist/reads/user.js +155 -0
  174. package/dist/reads/vote.d.ts +296 -0
  175. package/dist/reads/vote.js +392 -0
  176. package/dist/reads/zkemail.d.ts +117 -0
  177. package/dist/reads/zkemail.js +217 -0
  178. package/dist/similarity.d.ts +36 -0
  179. package/dist/similarity.js +67 -0
  180. package/dist/sponsorship-config.d.ts +47 -0
  181. package/dist/sponsorship-config.js +41 -0
  182. package/dist/stats.d.ts +11 -0
  183. package/dist/stats.js +27 -0
  184. package/dist/task-lens.d.ts +113 -0
  185. package/dist/task-lens.js +210 -0
  186. package/dist/tx/education.d.ts +169 -0
  187. package/dist/tx/education.js +344 -0
  188. package/dist/tx/eligibility.d.ts +511 -0
  189. package/dist/tx/eligibility.js +818 -0
  190. package/dist/tx/governance.d.ts +85 -0
  191. package/dist/tx/governance.js +87 -0
  192. package/dist/tx/index.d.ts +17 -0
  193. package/dist/tx/index.js +56 -0
  194. package/dist/tx/intent.d.ts +67 -0
  195. package/dist/tx/intent.js +48 -0
  196. package/dist/tx/org.d.ts +321 -0
  197. package/dist/tx/org.js +544 -0
  198. package/dist/tx/paymaster.d.ts +140 -0
  199. package/dist/tx/paymaster.js +251 -0
  200. package/dist/tx/project.d.ts +146 -0
  201. package/dist/tx/project.js +217 -0
  202. package/dist/tx/task.d.ts +480 -0
  203. package/dist/tx/task.js +1149 -0
  204. package/dist/tx/token.d.ts +96 -0
  205. package/dist/tx/token.js +165 -0
  206. package/dist/tx/treasury.d.ts +474 -0
  207. package/dist/tx/treasury.js +911 -0
  208. package/dist/tx/user.d.ts +177 -0
  209. package/dist/tx/user.js +292 -0
  210. package/dist/tx/vote.d.ts +332 -0
  211. package/dist/tx/vote.js +762 -0
  212. package/dist/tx/zkemail.d.ts +90 -0
  213. package/dist/tx/zkemail.js +210 -0
  214. package/dist/validation.d.ts +8 -0
  215. package/dist/validation.js +39 -0
  216. package/dist/version.d.ts +142 -0
  217. package/dist/version.js +257 -0
  218. package/dist/zkemail.d.ts +161 -0
  219. package/dist/zkemail.js +299 -0
  220. package/package.json +130 -0
@@ -0,0 +1,702 @@
1
+ "use strict";
2
+ /**
3
+ * Subgraph Client — tiered transport (browser-pure port of src/lib/subgraph.ts)
4
+ *
5
+ * Two transports can serve the same subgraph:
6
+ *
7
+ * FREE ("Studio") `api.studio.thegraph.com/...` — no key, capped at
8
+ * 3K queries/day (rolling window, see x-ratelimit-reset).
9
+ * PAID ("gateway") `gateway.thegraph.com/api/subgraphs/id/<ID>` — billed
10
+ * against GRAPH_API_KEY, sent as an Authorization header.
11
+ *
12
+ * Routing is by AVAILABILITY, controlled by POP_SUBGRAPH_TIER=free|paid|auto
13
+ * (default `auto`):
14
+ *
15
+ * both free first, switch to paid when free is exhausted, and REMEMBER
16
+ * the switch via the injected TierStateStore so the next process
17
+ * does not repeat the doomed free round-trip
18
+ * paid only straight to the gateway, never probe Studio
19
+ * free only Studio; on exhaustion, fail with an actionable error naming
20
+ * GRAPH_API_KEY and the gateway URL to set
21
+ * neither PRECONDITION error (this chain has no POP subgraph)
22
+ *
23
+ * ## Injection seams (what differs from the CLI original)
24
+ *
25
+ * The logic is identical to the CLI's src/lib/subgraph.ts; only the
26
+ * environment touchpoints are injected:
27
+ *
28
+ * env EnvSource instead of process.env (POP_SUBGRAPH_TIER,
29
+ * GRAPH_API_KEY, POP_<NET>_* endpoint overrides)
30
+ * stateStore TierStateStore instead of ~/.pop/subgraph-tier-state.json —
31
+ * in-memory by default; the CLI injects a file-backed store,
32
+ * a browser host can inject localStorage
33
+ * fetch transport (defaults to globalThis.fetch)
34
+ * onWarn per-chain sweep failures (the CLI injects process.stderr)
35
+ * now clock, for tests
36
+ *
37
+ * The transport is plain fetch rather than graphql-request, but thrown errors
38
+ * carry the same observable shape (`.response.{status,headers,errors,error}`,
39
+ * `GraphQL Error (Code: NNN)` messages for non-JSON bodies) so the error
40
+ * classifiers below behave identically.
41
+ *
42
+ * ## Why exhaustion is persisted
43
+ *
44
+ * The CLI is a fresh process per command. A per-process flag meant that once
45
+ * Studio's daily quota was gone EVERY command burned a doomed Studio
46
+ * round-trip first, and queryWithFieldFallback repeated that once per tier.
47
+ *
48
+ * ## Why the pin is SHORT
49
+ *
50
+ * A burst limit and a spent daily quota look identical from a single 429, so
51
+ * exhaustion is pinned for FREE_BACKOFF_SECONDS (15 min) and no longer — never
52
+ * "until UTC midnight". Worst case we re-probe Studio ~96 times a day instead
53
+ * of thousands of times; best case a transient burst limit does not push a
54
+ * whole day of traffic onto the paid key. A `x-ratelimit-reset` sooner than the
55
+ * backoff shortens the pin but never extends it.
56
+ *
57
+ * The state store is advisory: a corrupt or unreadable one is ignored, never
58
+ * fatal.
59
+ */
60
+ Object.defineProperty(exports, "__esModule", { value: true });
61
+ exports.GraphClient = exports.GraphRequestError = exports.InMemoryTierStateStore = exports.FREE_BACKOFF_SECONDS = void 0;
62
+ exports.emptyTierState = emptyTierState;
63
+ exports.redactSubgraphUrl = redactSubgraphUrl;
64
+ exports.isQuotaError = isQuotaError;
65
+ exports.isAuthError = isAuthError;
66
+ exports.isUnknownFieldError = isUnknownFieldError;
67
+ const chains_1 = require("../chains");
68
+ const errors_1 = require("../errors");
69
+ const exit_codes_1 = require("../exit-codes");
70
+ const env_1 = require("../env");
71
+ /** How long a free-tier quota/rate error suppresses the free transport. */
72
+ exports.FREE_BACKOFF_SECONDS = 15 * 60;
73
+ function emptyTierState() {
74
+ return { version: 1, chains: {} };
75
+ }
76
+ /** Default store: per-client-instance memory. */
77
+ class InMemoryTierStateStore {
78
+ state;
79
+ load() { return this.state; }
80
+ save(state) { this.state = state; }
81
+ clear() { this.state = undefined; }
82
+ }
83
+ exports.InMemoryTierStateStore = InMemoryTierStateStore;
84
+ class GraphRequestError extends Error {
85
+ response;
86
+ status;
87
+ constructor(message, response) {
88
+ super(message);
89
+ this.name = 'GraphRequestError';
90
+ this.response = response;
91
+ this.status = response.status;
92
+ }
93
+ }
94
+ exports.GraphRequestError = GraphRequestError;
95
+ function redactSubgraphUrl(url) {
96
+ // Mask anything that could be a credential before a URL is printed or put in
97
+ // an error. The modern gateway form carries the key in a header, but the
98
+ // LEGACY form embeds it in the path (`/api/<KEY>/subgraphs/id/<ID>`), and
99
+ // some hosts accept `?api_key=`. Never let either reach stdout.
100
+ if (!url)
101
+ return url;
102
+ return url
103
+ .replace(/(\/api\/)(?!subgraphs\/)[^/?#]+/i, '$1<redacted>')
104
+ .replace(/([?&](?:api[-_]?key|access[-_]?token)=)[^&#]+/gi, '$1<redacted>');
105
+ }
106
+ function isGatewayUrl(url) {
107
+ return /(^|\/\/)([\w-]+\.)?gateway[\w-]*\.thegraph\.com/i.test(url);
108
+ }
109
+ // ---------------------------------------------------------------------------
110
+ // Error classification
111
+ // ---------------------------------------------------------------------------
112
+ /**
113
+ * Server-supplied strings only. Deliberately NOT `error.message` — for a
114
+ * transport error that embeds a dump of the whole request, matching on it
115
+ * would let a query that merely CONTAINS "429" or the word "quota" fake a
116
+ * rate limit and burn paid quota.
117
+ */
118
+ function serverMessages(error) {
119
+ const out = [];
120
+ const res = error?.response;
121
+ if (!res)
122
+ return out;
123
+ if (Array.isArray(res.errors)) {
124
+ for (const e of res.errors)
125
+ if (e?.message)
126
+ out.push(String(e.message));
127
+ }
128
+ if (typeof res.error === 'string')
129
+ out.push(res.error);
130
+ else if (res.error?.message)
131
+ out.push(String(res.error.message));
132
+ return out;
133
+ }
134
+ function httpStatus(error) {
135
+ const raw = error?.response?.status ?? error?.status;
136
+ const n = Number(raw);
137
+ return Number.isFinite(n) ? n : undefined;
138
+ }
139
+ /**
140
+ * Quota/rate exhaustion, in whatever shape the host actually uses.
141
+ *
142
+ * Confirmed live (2026-07): Studio answers a healthy query HTTP 200 with
143
+ * `x-ratelimit-limit: 3000` / `-remaining` / `-reset`, and the gateway answers
144
+ * an unauthenticated query HTTP **200** with `{"errors":[{"message":"auth
145
+ * error: ..."}]}` — i.e. HTTP status alone is not sufficient in either
146
+ * direction. So: match the status when it is one of the unambiguous
147
+ * exhaustion codes, otherwise match only the SERVER's message text.
148
+ */
149
+ function isQuotaError(error) {
150
+ const status = httpStatus(error);
151
+ if (status === 429 || status === 402)
152
+ return true;
153
+ const msgs = serverMessages(error);
154
+ const QUOTA_RE = /(rate[\s-]?limit|too many requests|quota|out of credits|payment required|free (?:tier|plan) limit|exceeded[^.]{0,40}(?:limit|budget))/i;
155
+ if (msgs.some(m => QUOTA_RE.test(m)))
156
+ return true;
157
+ // Non-JSON bodies (Studio serves a plain-text 429) become
158
+ // "GraphQL Error (Code: 429): {...}". Anchored so only the code matches.
159
+ if (typeof error?.message === 'string' && /^GraphQL Error \(Code: (?:429|402)\)/.test(error.message))
160
+ return true;
161
+ return false;
162
+ }
163
+ /** Missing/invalid/unauthorised API key on the paid transport. */
164
+ function isAuthError(error) {
165
+ if (isQuotaError(error))
166
+ return false;
167
+ const msgs = serverMessages(error);
168
+ const AUTH_RE = /(auth error|api key|authorization|unauthorized|forbidden)/i;
169
+ if (msgs.some(m => AUTH_RE.test(m)))
170
+ return true;
171
+ const status = httpStatus(error);
172
+ return status === 401;
173
+ }
174
+ /**
175
+ * Detect a GraphQL validation error caused by querying a field the
176
+ * deployed schema doesn't have (older subgraph version). Network/HTTP
177
+ * failures deliberately do NOT match — those should propagate.
178
+ */
179
+ function isUnknownFieldError(error) {
180
+ const messages = [];
181
+ const gqlErrors = error?.response?.errors;
182
+ if (Array.isArray(gqlErrors)) {
183
+ for (const e of gqlErrors) {
184
+ if (e?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED')
185
+ return true;
186
+ if (e?.message)
187
+ messages.push(String(e.message));
188
+ }
189
+ }
190
+ if (error?.message)
191
+ messages.push(String(error.message));
192
+ return messages.some(m => /cannot query field/i.test(m)
193
+ || /has no field/i.test(m)
194
+ || /unknown field/i.test(m)
195
+ || /unknown argument/i.test(m)
196
+ || /undefined field/i.test(m));
197
+ }
198
+ /** Case-insensitive header read across Headers instances and plain objects. */
199
+ function readHeader(headers, name) {
200
+ try {
201
+ if (!headers)
202
+ return undefined;
203
+ if (typeof headers.get === 'function')
204
+ return headers.get(name) ?? undefined;
205
+ for (const [k, v] of Object.entries(headers)) {
206
+ if (k.toLowerCase() === name.toLowerCase())
207
+ return String(v);
208
+ }
209
+ }
210
+ catch { /* headers shape is host-dependent; never let this throw */ }
211
+ return undefined;
212
+ }
213
+ class GraphClient {
214
+ env;
215
+ defaultChainId;
216
+ store;
217
+ providedFetch;
218
+ onWarn;
219
+ nowMs;
220
+ constructor(options = {}) {
221
+ this.env = options.env ?? env_1.EMPTY_ENV;
222
+ this.defaultChainId = options.defaultChainId;
223
+ this.store = options.stateStore ?? new InMemoryTierStateStore();
224
+ this.providedFetch = options.fetch;
225
+ this.onWarn = options.onWarn ?? (() => { });
226
+ this.nowMs = options.now ?? (() => Date.now());
227
+ }
228
+ /**
229
+ * Resolve the transport LAZILY, per request. Capturing globalThis.fetch at
230
+ * construction would freeze whatever implementation existed then — test
231
+ * stubs and framework fetch-patchers (Next.js, MSW) replace it later.
232
+ * Bind: a bare `fetch` reference loses `this` in browsers ("Illegal
233
+ * invocation").
234
+ */
235
+ resolveFetch() {
236
+ if (this.providedFetch)
237
+ return this.providedFetch;
238
+ const f = globalThis.fetch;
239
+ return f ? f.bind(globalThis) : undefined;
240
+ }
241
+ // -- state ----------------------------------------------------------------
242
+ nowSeconds() {
243
+ return Math.floor(this.nowMs() / 1000);
244
+ }
245
+ /**
246
+ * Never throws. A missing or hand-mangled state entry is treated as "no
247
+ * knowledge" — the cost is one wasted free-tier probe, which is strictly
248
+ * better than failing a read command over advisory cache state.
249
+ */
250
+ loadState() {
251
+ try {
252
+ const s = this.store.load();
253
+ if (s && s.version === 1 && s.chains && typeof s.chains === 'object' && !Array.isArray(s.chains)) {
254
+ return s;
255
+ }
256
+ }
257
+ catch { /* advisory only */ }
258
+ return emptyTierState();
259
+ }
260
+ saveState(state) {
261
+ try {
262
+ this.store.save(state);
263
+ }
264
+ catch { /* advisory only */ }
265
+ }
266
+ /** unix-seconds until which the free transport is considered spent, or undefined. */
267
+ getFreeExhaustedUntil(chainId) {
268
+ const entry = this.loadState().chains[String(chainId)];
269
+ if (!entry || typeof entry.freeExhaustedUntil !== 'number')
270
+ return undefined;
271
+ return entry.freeExhaustedUntil > this.nowSeconds() ? entry.freeExhaustedUntil : undefined;
272
+ }
273
+ /**
274
+ * Record that the free transport is spent. `resetAtSeconds` (from
275
+ * x-ratelimit-reset) may SHORTEN the pin but never extend it past
276
+ * FREE_BACKOFF_SECONDS — see the module header on why the pin stays short.
277
+ */
278
+ markFreeExhausted(chainId, reason, resetAtSeconds) {
279
+ const now = this.nowSeconds();
280
+ const cap = now + exports.FREE_BACKOFF_SECONDS;
281
+ const until = resetAtSeconds && resetAtSeconds > now ? Math.min(resetAtSeconds, cap) : cap;
282
+ const state = this.loadState();
283
+ state.chains[String(chainId)] = { freeExhaustedUntil: until, recordedAt: now, reason };
284
+ this.saveState(state);
285
+ }
286
+ clearFreeExhausted(chainId) {
287
+ const state = this.loadState();
288
+ if (!state.chains[String(chainId)])
289
+ return; // avoid a pointless store write
290
+ delete state.chains[String(chainId)];
291
+ this.saveState(state);
292
+ }
293
+ /**
294
+ * Proactive exhaustion: Studio tells us how much of the free quota is left on
295
+ * every response, so record "spent" at remaining === 0 rather than waiting for
296
+ * the next command to eat a 429.
297
+ */
298
+ observeRateLimit(chainId, headers) {
299
+ try {
300
+ const remainingRaw = readHeader(headers, 'x-ratelimit-remaining');
301
+ if (remainingRaw === undefined)
302
+ return;
303
+ const remaining = Number(remainingRaw);
304
+ if (!Number.isFinite(remaining))
305
+ return;
306
+ if (remaining > 0)
307
+ return;
308
+ const resetRaw = readHeader(headers, 'x-ratelimit-reset');
309
+ const resetAt = Number(resetRaw);
310
+ this.markFreeExhausted(chainId, 'x-ratelimit-remaining=0', Number.isFinite(resetAt) ? resetAt : undefined);
311
+ }
312
+ catch { /* observation only */ }
313
+ }
314
+ // -- transport ------------------------------------------------------------
315
+ getApiKey() {
316
+ return (this.env.GRAPH_API_KEY || '').trim();
317
+ }
318
+ /**
319
+ * One GraphQL POST. Errors carry `.response.{status,headers,errors,error}`
320
+ * and graphql-request-style messages so the classifiers above keep working.
321
+ */
322
+ async request(url, gqlQuery, variables, opts) {
323
+ // The Authorization header follows the PLANNED TIER, not hostname sniffing:
324
+ // POP_GRAPH_GATEWAY_URL / POP_<NET>_SUBGRAPH_GATEWAY overrides allow
325
+ // self-hosted and regional gateway hosts that a hostname regex misses —
326
+ // keying on the hostname sent those requests keyless and then blamed the
327
+ // operator's valid key.
328
+ const headers = { 'content-type': 'application/json' };
329
+ const key = this.getApiKey();
330
+ if (opts.paid && key)
331
+ headers['Authorization'] = `Bearer ${key}`;
332
+ const fetchImpl = this.resolveFetch();
333
+ if (!fetchImpl) {
334
+ throw new errors_1.CliError('No fetch implementation available for the subgraph client.', exit_codes_1.EXIT.INFRA, 'Pass GraphClientOptions.fetch (Node <18 needs a fetch polyfill).');
335
+ }
336
+ let response;
337
+ try {
338
+ response = await fetchImpl(url, {
339
+ method: 'POST',
340
+ headers,
341
+ body: JSON.stringify({ query: gqlQuery, variables }),
342
+ });
343
+ }
344
+ catch (err) {
345
+ // Network-level failure — no response. Propagate with the original
346
+ // message; classifiers treat it as neither quota nor auth.
347
+ throw err;
348
+ }
349
+ // Studio publishes x-ratelimit-remaining / -reset on every response.
350
+ // Reading them lets us record exhaustion BEFORE a request fails, so the
351
+ // next query skips the free transport instead of learning the hard way.
352
+ if (opts.chainId !== undefined && !opts.paid) {
353
+ this.observeRateLimit(opts.chainId, response.headers);
354
+ }
355
+ const text = await response.text();
356
+ let body;
357
+ try {
358
+ body = text ? JSON.parse(text) : undefined;
359
+ }
360
+ catch {
361
+ // Non-JSON body (Studio serves a plain-text 429; CDNs/WAFs serve HTML).
362
+ // Message format is load-bearing: isQuotaError anchors on
363
+ // "GraphQL Error (Code: NNN)". The raw text ALSO travels in
364
+ // response.error — graphql-request put text bodies there, and
365
+ // serverMessages() reads only server-supplied response fields, so
366
+ // without this the QUOTA_RE/AUTH_RE text classification would go dead
367
+ // for plain-text bodies on unusual statuses (a proxy 403 saying
368
+ // "rate limit exceeded" must still pin the free tier).
369
+ throw new GraphRequestError(`GraphQL Error (Code: ${response.status}): ${text.slice(0, 300)}`, { status: response.status, headers: response.headers, error: text.slice(0, 1000) });
370
+ }
371
+ // Mirror graphql-request v6's success gate exactly: an HTTP-ok response
372
+ // is a success ONLY when it has array errors empty/absent AND a truthy
373
+ // `data`. A 200 with `{}`, `{"data":null}`, an empty body, or a non-array
374
+ // `errors` value ("auth error") previously threw ClientError — resolving
375
+ // undefined here instead turned transport failures into "no rows", the
376
+ // exact silent-empty failure ChainQueryResult.error exists to prevent
377
+ // (and cleared still-valid free-tier pins via the success path).
378
+ const gqlErrors = body?.errors;
379
+ const hasGqlErrors = Array.isArray(gqlErrors) ? gqlErrors.length > 0 : gqlErrors != null;
380
+ if (!response.ok || hasGqlErrors || !body?.data) {
381
+ const first = Array.isArray(gqlErrors) && gqlErrors[0]?.message
382
+ ? String(gqlErrors[0].message)
383
+ : `GraphQL Error (Code: ${response.status})`;
384
+ throw new GraphRequestError(first, {
385
+ status: response.status,
386
+ headers: response.headers,
387
+ errors: Array.isArray(gqlErrors) ? gqlErrors : undefined,
388
+ error: body?.error ?? (typeof gqlErrors === 'string' ? gqlErrors : undefined),
389
+ data: body?.data,
390
+ });
391
+ }
392
+ return body.data;
393
+ }
394
+ // -- planning -------------------------------------------------------------
395
+ resolveMode() {
396
+ const raw = (this.env.POP_SUBGRAPH_TIER || '').trim().toLowerCase();
397
+ if (!raw || raw === 'auto')
398
+ return { mode: 'auto' };
399
+ if (raw === 'free' || raw === 'paid')
400
+ return { mode: raw };
401
+ return { mode: 'auto', ignored: raw };
402
+ }
403
+ /**
404
+ * Work out which transports exist for a chain and in what order to try them.
405
+ * Pure w.r.t. the network: reads env + config + the injected state store only.
406
+ */
407
+ resolveTransportPlan(chainId) {
408
+ // Per-call chainId wins; the client's default fills the gap. Env
409
+ // (POP_DEFAULT_CHAIN inside resolveNetworkConfig) remains the last resort,
410
+ // preserving the CLI's precedence exactly (its client sets no default).
411
+ const config = (0, chains_1.resolveNetworkConfig)(chainId ?? this.defaultChainId, this.env);
412
+ const effectiveChainId = config.chainId;
413
+ const { mode, ignored } = this.resolveMode();
414
+ const primary = (config.resolvedSubgraph || '').trim();
415
+ const gateway = (0, chains_1.getGatewaySubgraphUrl)(effectiveChainId, this.env);
416
+ // Classify by URL, not by which env var supplied it: an operator who points
417
+ // POP_<NET>_SUBGRAPH straight at the gateway has a paid primary, not a free one.
418
+ const freeUrl = primary && !isGatewayUrl(primary) ? primary : undefined;
419
+ const paidUrl = gateway || (primary && isGatewayUrl(primary) ? primary : undefined);
420
+ const hasApiKey = !!this.getApiKey();
421
+ // A gateway URL without a key is not a transport: it answers HTTP 200 with
422
+ // {"errors":[{"message":"auth error: missing authorization header"}]}.
423
+ const paidUsable = !!paidUrl && hasApiKey;
424
+ const paidKeyMissing = !!paidUrl && !hasApiKey;
425
+ const availability = freeUrl && paidUsable
426
+ ? 'both'
427
+ : paidUsable ? 'paid-only'
428
+ : freeUrl ? 'free-only'
429
+ : 'none';
430
+ const freeExhaustedUntil = freeUrl ? this.getFreeExhaustedUntil(effectiveChainId) : undefined;
431
+ const attempts = [];
432
+ let softenedOverride;
433
+ if (mode === 'paid') {
434
+ if (paidUsable) {
435
+ attempts.push({ tier: 'paid', url: paidUrl });
436
+ }
437
+ else if (freeUrl && !paidKeyMissing) {
438
+ // POP_SUBGRAPH_TIER is one global env var but paid transports are
439
+ // per-chain: Arbitrum has no gateway deployment, so honouring the
440
+ // override literally would make the whole chain unreadable while its
441
+ // Studio endpoint is demonstrably alive (queryAllChains then reports
442
+ // "no results" instead of data). Treat the override as prefer-paid:
443
+ // chains without a gateway fall back to free, and the softening is
444
+ // surfaced via modeOverrideIgnored so `pop config show` explains it.
445
+ // A missing API KEY is not softened — that is a misconfiguration the
446
+ // operator asked us to surface, not a per-chain gap.
447
+ attempts.push({ tier: 'free', url: freeUrl });
448
+ softenedOverride = `paid (no gateway configured for ${config.name} — using the free endpoint)`;
449
+ }
450
+ }
451
+ else if (mode === 'free') {
452
+ if (freeUrl)
453
+ attempts.push({ tier: 'free', url: freeUrl });
454
+ }
455
+ else {
456
+ // auto
457
+ //
458
+ // A live free-tier pin DEMOTES the free transport, it never drops it. The
459
+ // pin exists to skip a doomed Studio round-trip on the happy path, not to
460
+ // make the command depend solely on the gateway for the whole pin window:
461
+ // a revoked/unbilled GRAPH_API_KEY or a transient gateway outage must still
462
+ // fall through to a healthy Studio endpoint. Keeping free in the plan also
463
+ // keeps the self-healing path reachable — a free success clears a pin that
464
+ // was really just a transient burst limit (see executePlan).
465
+ const demoteFree = !!freeExhaustedUntil && paidUsable;
466
+ if (freeUrl && !demoteFree)
467
+ attempts.push({ tier: 'free', url: freeUrl });
468
+ if (paidUsable)
469
+ attempts.push({ tier: 'paid', url: paidUrl });
470
+ if (freeUrl && demoteFree)
471
+ attempts.push({ tier: 'free', url: freeUrl });
472
+ }
473
+ return {
474
+ chainId: effectiveChainId,
475
+ networkName: config.name,
476
+ mode,
477
+ ...(ignored || softenedOverride ? { modeOverrideIgnored: ignored || softenedOverride } : {}),
478
+ availability,
479
+ hasApiKey,
480
+ paidKeyMissing,
481
+ freeUrl,
482
+ paidUrl,
483
+ attempts,
484
+ ...(freeExhaustedUntil ? { freeExhaustedUntil } : {}),
485
+ };
486
+ }
487
+ gatewayEnvHint(chainId) {
488
+ const infix = (0, chains_1.getEnvInfixByChainId)(chainId) || 'GNOSIS';
489
+ return `Set GRAPH_API_KEY and POP_${infix}_SUBGRAPH_GATEWAY=<gateway url> (or POP_${infix}_SUBGRAPH_ID=<subgraph id>).`;
490
+ }
491
+ /** The error to raise when a plan has no usable transport at all. */
492
+ planError(plan) {
493
+ const supported = (0, chains_1.getAllSubgraphUrls)().map(n => `${n.name} (${n.chainId})`).join(', ');
494
+ if (plan.mode === 'paid') {
495
+ return new errors_1.CliError(plan.paidKeyMissing
496
+ ? `POP_SUBGRAPH_TIER=paid but GRAPH_API_KEY is not set, so the gateway cannot be used.`
497
+ : `POP_SUBGRAPH_TIER=paid but no gateway subgraph is configured for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.PRECONDITION, `${this.gatewayEnvHint(plan.chainId)} Or unset POP_SUBGRAPH_TIER to fall back to the free Studio endpoint.`);
498
+ }
499
+ if (plan.mode === 'free' && plan.availability !== 'none') {
500
+ return new errors_1.CliError(`POP_SUBGRAPH_TIER=free but no free (Studio) subgraph is configured for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.PRECONDITION, `Unset POP_SUBGRAPH_TIER to use the configured gateway endpoint instead.`);
501
+ }
502
+ if (plan.paidKeyMissing) {
503
+ return new errors_1.CliError(`POP has no free subgraph on ${plan.networkName} (chain ${plan.chainId}) and the configured gateway needs an API key.`, exit_codes_1.EXIT.PRECONDITION, `${this.gatewayEnvHint(plan.chainId)}`);
504
+ }
505
+ // Some configured chains are RPC-only (no POP subgraph deployed, or the
506
+ // deployment was removed). Without this guard the raw transport failure
507
+ // surfaces as an opaque blob containing an internal Graph deployment id and
508
+ // the whole serialized query, with no indication of WHICH chain failed.
509
+ return new errors_1.CliError(`POP has no subgraph on ${plan.networkName} (chain ${plan.chainId}), so this command cannot read org data.`, exit_codes_1.EXIT.PRECONDITION,
510
+ // getEnvInfixByChainId, not toUpperCase(): the camelCase names need the
511
+ // underscore, or the hint names a variable that does not exist
512
+ // (POP_BASESEPOLIA_SUBGRAPH instead of POP_BASE_SEPOLIA_SUBGRAPH).
513
+ `Chains with a subgraph: ${supported}. Override with POP_${(0, chains_1.getEnvInfixByChainId)(plan.chainId)}_SUBGRAPH if you host your own.`);
514
+ }
515
+ /** Free quota gone and there is no paid transport to fall back to. */
516
+ freeExhaustedError(plan, cause, paidFailure) {
517
+ const tail = paidFailure
518
+ ? ` and the configured paid gateway also failed (${String(paidFailure?.message || paidFailure).slice(0, 120)}).`
519
+ : plan.paidKeyMissing
520
+ ? ` and the configured paid gateway has no API key.`
521
+ : ` and no paid gateway is configured.`;
522
+ const err = new errors_1.CliError(`The free Graph Studio subgraph for ${plan.networkName} (chain ${plan.chainId}) is rate-limited (3K queries/day)`
523
+ + tail, exit_codes_1.EXIT.INFRA, paidFailure
524
+ ? `The gateway failure is likely transient — retry, and check the gateway status if it persists. Studio's quota resets on a rolling 24h window.`
525
+ : plan.paidKeyMissing
526
+ ? `Add GRAPH_API_KEY=<your gateway key> to your .env — the gateway URL is already set. Studio's quota resets on a rolling 24h window, so retrying later also works.`
527
+ : `${this.gatewayEnvHint(plan.chainId)} Studio's quota resets on a rolling 24h window, so retrying later also works.`);
528
+ err.cause = cause;
529
+ err.response = cause?.response;
530
+ return err;
531
+ }
532
+ paidAuthError(plan, cause) {
533
+ const err = new errors_1.CliError(`The Graph gateway rejected the API key for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.INFRA, `Check GRAPH_API_KEY is a valid gateway key with this subgraph enabled (${redactSubgraphUrl(plan.paidUrl)}).`);
534
+ err.cause = cause;
535
+ err.response = cause?.response;
536
+ return err;
537
+ }
538
+ paidQuotaError(plan, cause) {
539
+ const err = new errors_1.CliError(`The Graph gateway is out of query budget for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.INFRA, `Top up the billing balance for GRAPH_API_KEY, or raise its per-query budget in Subgraph Studio.`);
540
+ err.cause = cause;
541
+ err.response = cause?.response;
542
+ return err;
543
+ }
544
+ // -- execution ------------------------------------------------------------
545
+ async executePlan(plan, gqlQuery, variables) {
546
+ if (!plan.attempts.length)
547
+ throw this.planError(plan);
548
+ // When a paid attempt fails and we fall through to a demoted free attempt,
549
+ // remember why: if free then quota-fails, the error must state BOTH facts —
550
+ // "no paid gateway is configured" is a lie when one just returned a 500.
551
+ let paidFailure;
552
+ for (let i = 0; i < plan.attempts.length; i++) {
553
+ const attempt = plan.attempts[i];
554
+ const isLast = i === plan.attempts.length - 1;
555
+ try {
556
+ const data = await this.request(attempt.url, gqlQuery, variables, {
557
+ paid: attempt.tier === 'paid',
558
+ chainId: plan.chainId,
559
+ });
560
+ // A success on free means an earlier pin was a transient burst limit.
561
+ if (attempt.tier === 'free' && plan.freeExhaustedUntil)
562
+ this.clearFreeExhausted(plan.chainId);
563
+ return data;
564
+ }
565
+ catch (error) {
566
+ if (attempt.tier === 'free' && isQuotaError(error)) {
567
+ this.markFreeExhausted(plan.chainId, 'quota-error', Number(readHeader(error?.response?.headers, 'x-ratelimit-reset')) || undefined);
568
+ if (!isLast)
569
+ continue; // switch to the paid transport
570
+ throw this.freeExhaustedError(plan, error, paidFailure);
571
+ }
572
+ if (attempt.tier === 'paid') {
573
+ // A broken gateway (revoked key, exhausted budget, DNS/5xx) must not
574
+ // sink the command when a demoted free transport is still queued —
575
+ // that availability guarantee predates the tiering work. Schema and
576
+ // validation errors are excluded: they are a property of the query,
577
+ // not the transport, so retrying elsewhere only wastes a round-trip
578
+ // (queryWithFieldFallback owns that case).
579
+ if (!isLast && !isUnknownFieldError(error)) {
580
+ paidFailure = error;
581
+ continue;
582
+ }
583
+ if (isAuthError(error))
584
+ throw this.paidAuthError(plan, error);
585
+ if (isQuotaError(error))
586
+ throw this.paidQuotaError(plan, error);
587
+ }
588
+ // Schema/validation/network errors are NOT a reason to spend paid quota.
589
+ throw error;
590
+ }
591
+ }
592
+ /* istanbul ignore next — loop always returns or throws */
593
+ throw this.planError(plan);
594
+ }
595
+ /**
596
+ * Query a subgraph on the specified chain, routed through the tier plan
597
+ * (see the module header).
598
+ */
599
+ async query(gqlQuery, variables, chainId) {
600
+ return this.executePlan(this.resolveTransportPlan(chainId), gqlQuery, variables);
601
+ }
602
+ /**
603
+ * Try query tiers in order, falling through to the next tier when the
604
+ * deployed schema rejects a field (validation error). Any other error
605
+ * (network, HTTP, rate limit without fallback) is rethrown immediately.
606
+ * Returns the first successful result plus the tier index that served it.
607
+ *
608
+ * Each tier re-plans, so once tier 0 has recorded free-tier exhaustion the
609
+ * remaining tiers go straight to the gateway instead of repeating the probe.
610
+ */
611
+ async queryWithFieldFallback(tiers, opts) {
612
+ if (!tiers.length) {
613
+ throw new Error('queryWithFieldFallback requires at least one query tier');
614
+ }
615
+ let lastValidationError;
616
+ for (let tierIndex = 0; tierIndex < tiers.length; tierIndex++) {
617
+ try {
618
+ const data = await this.query(tiers[tierIndex].query, tiers[tierIndex].variables, opts?.chainId);
619
+ return { data, tierIndex };
620
+ }
621
+ catch (error) {
622
+ if (!isUnknownFieldError(error))
623
+ throw error;
624
+ lastValidationError = error;
625
+ }
626
+ }
627
+ throw lastValidationError;
628
+ }
629
+ /**
630
+ * Query a specific subgraph URL directly, still tier-aware: if `url` is a free
631
+ * (Studio) endpoint belonging to a known chain and it rate-limits, the request
632
+ * is retried against that chain's gateway rather than simply failing.
633
+ */
634
+ async queryUrl(url, gqlQuery, variables) {
635
+ const owner = (0, chains_1.getAllSubgraphUrls)().find(n => n.url === url);
636
+ if (owner)
637
+ return this.executePlan(this.resolveTransportPlan(owner.chainId), gqlQuery, variables);
638
+ // Unknown URL (self-hosted, or a one-off endpoint): no chain to plan for, so
639
+ // issue it directly. Hostname sniffing decides whether the API key rides
640
+ // along, as a fallback for direct calls that carry no tier.
641
+ return this.request(url, gqlQuery, variables, { paid: isGatewayUrl(url) });
642
+ }
643
+ /**
644
+ * Query all non-testnet subgraphs in parallel, each through its own tier plan.
645
+ * Returns results keyed by chainId. Failures are reported per-chain rather
646
+ * than swallowed: see ChainQueryResult.error.
647
+ */
648
+ async queryAllChains(gqlQuery, variables) {
649
+ const endpoints = (0, chains_1.getAllSubgraphUrls)();
650
+ const results = await Promise.allSettled(endpoints.map(async (ep) => {
651
+ const data = await this.query(gqlQuery, variables, ep.chainId);
652
+ return { chainId: ep.chainId, name: ep.name, data };
653
+ }));
654
+ return results.map((result, i) => {
655
+ if (result.status === 'fulfilled')
656
+ return result.value;
657
+ const reason = result.reason;
658
+ const message = (reason?.message && String(reason.message)) || 'subgraph query failed';
659
+ // Reported, not printed: the CLI wires onWarn to stderr so a human sees
660
+ // it without corrupting --json output. Silent per-chain failure
661
+ // previously rendered as "no orgs found".
662
+ try {
663
+ this.onWarn(`warn: subgraph query failed on ${endpoints[i].name} (chain ${endpoints[i].chainId}): ${message.slice(0, 200)}`);
664
+ }
665
+ catch { /* reporter failed */ }
666
+ return { chainId: endpoints[i].chainId, name: endpoints[i].name, data: null, error: message };
667
+ });
668
+ }
669
+ /**
670
+ * Describe the transport that will serve the next query on `chainId`.
671
+ * Purely local — issues no network request. URLs are redacted.
672
+ */
673
+ getTransportStatus(chainId) {
674
+ const plan = this.resolveTransportPlan(chainId);
675
+ const activeTier = plan.attempts[0]?.tier ?? null;
676
+ const parts = [];
677
+ parts.push(activeTier ? `${activeTier} tier active` : 'no transport available');
678
+ parts.push(plan.mode === 'auto' ? `mode auto (${plan.availability})` : `mode ${plan.mode} (forced)`);
679
+ parts.push(plan.hasApiKey ? 'GRAPH_API_KEY detected' : 'no GRAPH_API_KEY');
680
+ if (plan.paidKeyMissing)
681
+ parts.push('gateway configured but unusable without a key');
682
+ if (plan.freeExhaustedUntil) {
683
+ const mins = Math.max(1, Math.ceil((plan.freeExhaustedUntil - this.nowSeconds()) / 60));
684
+ parts.push(`free quota spent, re-probing in ~${mins}m`);
685
+ }
686
+ if (plan.modeOverrideIgnored)
687
+ parts.push(`tier override softened: ${plan.modeOverrideIgnored}`);
688
+ return {
689
+ mode: plan.mode,
690
+ availability: plan.availability,
691
+ activeTier,
692
+ hasApiKey: plan.hasApiKey,
693
+ paidKeyMissing: plan.paidKeyMissing,
694
+ freeUrl: redactSubgraphUrl(plan.freeUrl),
695
+ paidUrl: redactSubgraphUrl(plan.paidUrl),
696
+ ...(plan.freeExhaustedUntil ? { freeExhaustedUntil: plan.freeExhaustedUntil } : {}),
697
+ ...(plan.modeOverrideIgnored ? { modeOverrideIgnored: plan.modeOverrideIgnored } : {}),
698
+ summary: parts.join('; '),
699
+ };
700
+ }
701
+ }
702
+ exports.GraphClient = GraphClient;