@cliwant/mcp-sam-gov 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. package/src/usitc.ts +420 -0
@@ -0,0 +1,721 @@
1
+ /**
2
+ * datasource.ts — the shared fetch envelope for the keyless DataSources
3
+ * (ADR-0005). This is the ONE audited `assemble init → fetchWithRetry → r.json()`
4
+ * skeleton the keyless adapters share; the per-source quirks (timeout / headers /
5
+ * redirect) are OPTIONS, not three bespoke fetch fns. `RequestInit` already
6
+ * carries `headers` and `redirect`, so getJson forwards them without adding logic
7
+ * — folding the fetch fns into one is lossless.
8
+ *
9
+ * R2 SCOPE (ADR-0005 v2 FIX-B): getJson ships ONLY { label, headers?, redirect?,
10
+ * timeoutMs? }. `redirect:"error"` STAYS: a zero-logic RequestInit passthrough,
11
+ * absent for Treasury, and Socrata + the queued CKAN connector need the identical
12
+ * SSRF hardening.
13
+ *
14
+ * THE MIN-INTERVAL GATE (ADR-0011 orchestrator, v6 cycle 15 — the R2 deferred
15
+ * slice, now landed): `throughGate(key, minIntervalMs, fn)` is a STANDALONE
16
+ * exported primitive (below), the shared home for EDGAR's former module-singleton
17
+ * self-throttle. It is deliberately NOT wired into getJson and `gateKey`/
18
+ * `minIntervalMs` are deliberately NOT added to GetJsonOptions — there is no
19
+ * getJson consumer that throttles, so that would be dead option-surface (FIX-B
20
+ * "no dead surface"). If a future source makes a throttled getJson call, wiring
21
+ * the gate into getJson can be revisited then.
22
+ *
23
+ * `label` is the fetchWithRetry taxonomy key AND surfaces verbatim in
24
+ * ToolError.upstreamEndpoint to the MCP caller — it MUST be HOST-ONLY and never
25
+ * contain a token (Socrata/EDGAR m7).
26
+ *
27
+ * The shape-drift CHECK stays at each call site (bespoke per-source fields); only
28
+ * the THROW is standardized via `driftError`. This is deliberately NOT a `guard`
29
+ * hook baked into getJson: a hook would collide with Socrata's count(*) companion
30
+ * (which must degrade to total:null and NEVER throw schema_drift) and its
31
+ * discoverDatasets memoize boundary — leaving the check at the call site keeps
32
+ * that split honest (ADR-0005 §1c / Q4).
33
+ */
34
+
35
+ import {
36
+ fetchWithRetry,
37
+ errorFromResponse,
38
+ ToolErrorCarrier,
39
+ isHonorRetryAfter,
40
+ type ToolError,
41
+ } from "./errors.js";
42
+
43
+ export type GetJsonOptions = {
44
+ /** fetchWithRetry taxonomy + error surface. HOST-ONLY, never a token. */
45
+ label: string;
46
+ /** Set on init ONLY when defined — Treasury passes nothing → no `headers` key. */
47
+ headers?: Record<string, string>;
48
+ /** SSRF hardening passthrough; omitted from init when absent (Treasury/EDGAR). */
49
+ redirect?: "error";
50
+ /** Request timeout; default 15_000 (all sources today). */
51
+ timeoutMs?: number;
52
+ /**
53
+ * HTTP method — the SINGLE literal `"POST"` (ADR-0014, the first non-GET
54
+ * consumer: NIH RePORTER is a POST-with-JSON-body API). Set on init ONLY when
55
+ * defined, so every existing GET caller (which omits it) keeps a byte-identical
56
+ * init with NO `method` key. Typed as the literal (not `string`) so a stray
57
+ * `method:"GET"` cannot silently alter a consumer. A retry of a read-only POST
58
+ * search with a re-readable string body is safe.
59
+ */
60
+ method?: "POST";
61
+ /**
62
+ * Request body (a pre-serialized string, e.g. `JSON.stringify(payload)`).
63
+ * A `RequestInit` field, exactly like `headers`/`redirect` — getJson forwards
64
+ * it verbatim, adding no logic. Set on init ONLY when defined (the `!== undefined`
65
+ * idiom), so a GET caller's init stays byte-identical.
66
+ */
67
+ body?: string;
68
+ };
69
+
70
+ /**
71
+ * GET + parse one JSON resource through the shared envelope. Assembles `init`
72
+ * (a fresh `AbortSignal.timeout` always; `headers`/`redirect` set ONLY when the
73
+ * option is provided — byte-identical to each source's prior hand-rolled init),
74
+ * calls `fetchWithRetry` (retry/backoff + the 429/5xx/404/400 taxonomy), then
75
+ * returns the parsed body. The caller validates the shape and throws
76
+ * `driftError` on drift.
77
+ */
78
+ export async function getJson<T = unknown>(
79
+ url: string,
80
+ opts: GetJsonOptions,
81
+ ): Promise<T> {
82
+ const init: RequestInit = {
83
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
84
+ };
85
+ if (opts.headers !== undefined) init.headers = opts.headers;
86
+ if (opts.redirect) init.redirect = opts.redirect;
87
+ // POST/body passthrough (ADR-0014) — set ONLY when defined, matching the
88
+ // `headers` idiom, so a GET caller's init stays byte-identical (no `method`/
89
+ // `body` key). `method`/`body` are RequestInit fields forwarded without logic.
90
+ if (opts.method !== undefined) init.method = opts.method;
91
+ if (opts.body !== undefined) init.body = opts.body;
92
+ const r = await fetchWithRetry(url, init, opts.label);
93
+ return (await r.json()) as T;
94
+ }
95
+
96
+ /**
97
+ * getJsonWithHeaders — the header-exposing sibling of `getJson` (ADR-0038 M1).
98
+ *
99
+ * WHY a SEPARATE primitive (and NOT a mutation of getJson): a PostgREST source
100
+ * (FAC / any Range-paginating REST API) carries its EXACT total in the
101
+ * `Content-Range` RESPONSE HEADER, but `getJson` returns ONLY `await r.json()`
102
+ * (the parsed body) and DISCARDS the `Response`/headers — so the header is
103
+ * unreachable through it. Mutating getJson's return shape would break every one
104
+ * of its ~9 callers (NOT byte-identical); this additive variant leaves getJson
105
+ * untouched. It runs the IDENTICAL init assembly + `fetchWithRetry(url, init,
106
+ * opts.label)` envelope as getJson (same headers / `redirect` / timeout / method
107
+ * / body / retry-taxonomy), and returns the parsed body PLUS ONLY the
108
+ * `content-range` header string — it NEVER surfaces the raw `Headers` object, so
109
+ * no incidental response header (Set-Cookie, a rate-limit token, etc.) can reach
110
+ * a consumer's `_meta`/output. A 200 non-JSON body makes `r.json()` throw a
111
+ * `SyntaxError`, exactly as with getJson — the caller reclassifies it (the
112
+ * fdic.ts / ADR-0038 S1 pattern), keeping the shared envelope free of source
113
+ * quirks.
114
+ */
115
+ export async function getJsonWithHeaders<T = unknown>(
116
+ url: string,
117
+ opts: GetJsonOptions,
118
+ ): Promise<{ body: T; contentRange: string | null }> {
119
+ const init: RequestInit = {
120
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
121
+ };
122
+ if (opts.headers !== undefined) init.headers = opts.headers;
123
+ if (opts.redirect) init.redirect = opts.redirect;
124
+ if (opts.method !== undefined) init.method = opts.method;
125
+ if (opts.body !== undefined) init.body = opts.body;
126
+ const r = await fetchWithRetry(url, init, opts.label);
127
+ return {
128
+ body: (await r.json()) as T,
129
+ contentRange: r.headers.get("content-range"),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * getText — the shared fetch → `r.text()` → error-classify skeleton for the
135
+ * keyless XML/RSS/ATOM sources (far/gao/fpds; ADR-0013). Sibling of `getJson`;
136
+ * returns the RAW body text (each source runs its own bespoke string/regex
137
+ * parser). The three sources' only structural variation — headers, redirect,
138
+ * timeout, retry strategy, and the redirect-classification message — are OPTIONS,
139
+ * reconciled so each source's fetch semantics are BYTE-IDENTICAL to its former
140
+ * hand-rolled fetcher.
141
+ *
142
+ * Two strategies, selected by `retry`:
143
+ * - retry !== false (DEFAULT — far/gao): `fetchWithRetry` (3-attempt retry +
144
+ * the 429/5xx/404/4xx/network taxonomy), then `r.text()`.
145
+ * - retry === false (fpds, m-redirect): a SINGLE direct `fetch`. A redirect
146
+ * `"error"` TypeError is classified INLINE as a NON-retryable `schema_drift`
147
+ * (via `driftError` + `redirectMessage`) — NOT the retryable
148
+ * `upstream_unavailable` that `fetchWithRetry`'s generic network-catch would
149
+ * emit, exactly what m-redirect forbids (the live search.do→sam.gov 301 must
150
+ * fail closed, single attempt). This is why fpds does its own `fetch` rather
151
+ * than routing through the shared, retry-all-transients `fetchWithRetry`.
152
+ *
153
+ * Unlike `getJson`, `label` is an OPAQUE passthrough — NOT host-only normalized
154
+ * (far's label is path-bearing `ecfr:versioner/…`; forcing host-only would break
155
+ * it). All three sources are keyless, so no token can appear in a label.
156
+ */
157
+ export type GetTextOptions = {
158
+ /** fetchWithRetry taxonomy key + `ToolError.upstreamEndpoint`. Opaque
159
+ * passthrough (NOT host-only normalized — far's is path-bearing). */
160
+ label: string;
161
+ /** Set on init ONLY when defined (far Accept-only / gao+fpds UA+Accept). */
162
+ headers?: Record<string, string>;
163
+ /** SSRF hardening passthrough; omitted from init when absent (far/gao). */
164
+ redirect?: "error";
165
+ /** Request timeout; default 15_000 (all three sources today). */
166
+ timeoutMs?: number;
167
+ /** DEFAULT true → the fetchWithRetry path (far/gao). false → the single-fetch
168
+ * path (fpds; the redirect TypeError must be caught on the sole attempt). */
169
+ retry?: boolean;
170
+ /** `driftError` message when a redirect TypeError is caught on the single-fetch
171
+ * path — preserves fpds's exact honesty disclosure. Only consulted on the
172
+ * retry:false + redirect fault. */
173
+ redirectMessage?: string;
174
+ };
175
+
176
+ /**
177
+ * GET one text resource through the shared envelope. Assembles `init`
178
+ * (byte-identical to `getJson`'s rule: a fresh `AbortSignal.timeout` always;
179
+ * `headers`/`redirect` set ONLY when the option is provided), then either
180
+ * retries via `fetchWithRetry` (default) or does a single classified `fetch`
181
+ * (retry:false), and returns the raw `r.text()` body.
182
+ */
183
+ export async function getText(
184
+ url: string,
185
+ opts: GetTextOptions,
186
+ ): Promise<string> {
187
+ const init: RequestInit = {
188
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
189
+ };
190
+ if (opts.headers !== undefined) init.headers = opts.headers;
191
+ if (opts.redirect) init.redirect = opts.redirect;
192
+
193
+ // Default path (far/gao): retry transient errors up to 3× via the shared
194
+ // primitive, then return the body text.
195
+ if (opts.retry !== false) {
196
+ const r = await fetchWithRetry(url, init, opts.label);
197
+ return await r.text();
198
+ }
199
+
200
+ // Single-attempt path (fpds m-redirect): the fetch is done HERE so a
201
+ // redirect:"error" TypeError is classified as a NON-retryable schema_drift
202
+ // (never routed through fetchWithRetry, which would retry it 3× as a retryable
203
+ // upstream_unavailable). A 5xx/429/404/timeout is classified + THROWS (never a
204
+ // fake empty). Byte-for-byte the shipped fpds single-fetch body.
205
+ let r: Response;
206
+ try {
207
+ r = await fetch(url, init);
208
+ } catch (e) {
209
+ if (isRedirectError(e)) {
210
+ // Fail closed — NEVER follow the off-host redirect, NEVER read its body,
211
+ // and do NOT let it masquerade as a retryable outage.
212
+ throw driftError(
213
+ opts.label,
214
+ opts.redirectMessage ??
215
+ `Off-host redirect refused (redirect:"error") while fetching ${opts.label}.`,
216
+ );
217
+ }
218
+ // timeout / abort / network — retryable upstream, but THROWS (never fake-empty).
219
+ throw new ToolErrorCarrier({
220
+ kind: "upstream_unavailable",
221
+ message: `Network error reaching ${opts.label}: ${e instanceof Error ? e.message : String(e)}`,
222
+ retryable: true,
223
+ retryAfterSeconds: 30,
224
+ upstreamEndpoint: opts.label,
225
+ });
226
+ }
227
+ if (!r.ok) {
228
+ // 404/429/5xx/4xx → the errors.ts taxonomy. A DOWN service NEVER reads empty.
229
+ throw new ToolErrorCarrier(errorFromResponse(r, opts.label));
230
+ }
231
+ return await r.text();
232
+ }
233
+
234
+ /**
235
+ * Is a thrown error the redirect:"error" TypeError (undici: cause "unexpected
236
+ * redirect")? The live FPDS search.do→sam.gov 301 is the concrete case
237
+ * (ADR-0012 §1a). Moved here from fpds.ts (ADR-0013) as `getText`'s audited home
238
+ * — it is only reachable when a caller sets `redirect:"error"` (undici throws
239
+ * the unexpected-redirect TypeError only in `"error"` mode), so it is inert for
240
+ * any retry:false caller that does NOT set redirect.
241
+ */
242
+ export function isRedirectError(e: unknown): boolean {
243
+ if (!(e instanceof TypeError)) return false;
244
+ const causeMsg =
245
+ e.cause && typeof (e.cause as { message?: unknown }).message === "string"
246
+ ? (e.cause as { message: string }).message
247
+ : "";
248
+ return /redirect/i.test(causeMsg) || /redirect/i.test(e.message);
249
+ }
250
+
251
+ /**
252
+ * The shared `schema_drift` constructor (all sources threw the identical
253
+ * carrier). Each source keeps its bespoke field-CHECK inline and calls this to
254
+ * standardize only the THROW. `label` becomes `upstreamEndpoint` — host-only,
255
+ * never a token.
256
+ */
257
+ export function driftError(label: string, message: string): ToolErrorCarrier {
258
+ return new ToolErrorCarrier({
259
+ kind: "schema_drift",
260
+ message,
261
+ retryable: false,
262
+ upstreamEndpoint: label,
263
+ });
264
+ }
265
+
266
+ // ─── Per-key min-interval gate (shared self-throttle primitive) ────
267
+ /**
268
+ * One serialized promise chain + last-run timestamp PER KEY. Module-level so a
269
+ * key's chain persists across every call in the process (the whole point of a
270
+ * self-throttle: a tool call can fan out and many tools may run). Different keys
271
+ * are independent chains — they never block each other.
272
+ */
273
+ const gates = new Map<string, { chain: Promise<unknown>; lastAt: number }>();
274
+
275
+ /**
276
+ * Serialize every call sharing `key` through a single promise chain, spacing the
277
+ * START of consecutive runs by ≥ `minIntervalMs`. This is the generalization of
278
+ * EDGAR's former module singleton (edgar.ts `edgarGateChain`/`edgarLastFetchAt`/
279
+ * `throughEdgarGate`); `throughGate("edgar", 110, fn)` reproduces its behavior
280
+ * EXACTLY. Semantics that MUST stay byte-identical to the old EDGAR gate:
281
+ * - the Map entry is fetched-or-created and MUTATED IN PLACE (never replaced),
282
+ * so a key keeps one chain across the process;
283
+ * - before invoking `fn()`, wait `max(0, minIntervalMs - (now - lastAt))` using
284
+ * the **bare global `setTimeout`** (NOT `node:timers/promises`) — the fault
285
+ * suite's timer-neutralizing patch makes this offline-instant; an import-based
286
+ * timer would break that (and offline determinism);
287
+ * - `lastAt` is stamped with `Date.now()` **BEFORE** `fn()` is called, so the
288
+ * spacing is measured from the START of the previous run (edgar L97-98);
289
+ * - the chain SWALLOWS each step's error so the queue keeps flowing, while the
290
+ * real result/error still propagates on the promise returned to THAT caller.
291
+ */
292
+ export function throughGate<T>(
293
+ key: string,
294
+ minIntervalMs: number,
295
+ fn: () => Promise<T>,
296
+ ): Promise<T> {
297
+ let gate = gates.get(key);
298
+ if (!gate) {
299
+ gate = { chain: Promise.resolve(), lastAt: 0 };
300
+ gates.set(key, gate);
301
+ }
302
+ const g = gate;
303
+ const run = g.chain.then(async () => {
304
+ const wait = g.lastAt + minIntervalMs - Date.now();
305
+ if (wait > 0) await new Promise((res) => setTimeout(res, wait));
306
+ g.lastAt = Date.now();
307
+ return fn();
308
+ });
309
+ // Keep the chain alive whether this link resolves or rejects.
310
+ g.chain = run.then(
311
+ () => undefined,
312
+ () => undefined,
313
+ );
314
+ return run;
315
+ }
316
+
317
+ // ═══════════════════════════════════════════════════════════════════════════
318
+ // RESILIENCE PORT (ADR-0045 Phase 1 — landed COMPLETELY INERT)
319
+ // ═══════════════════════════════════════════════════════════════════════════
320
+ //
321
+ // ★POLICY BOUNDARY (ADR-0045 §"정책 경계", invariant — see the safety rules):
322
+ // These primitives serve PUBLIC OPEN-DATA AVAILABILITY only. They are NOT an
323
+ // access-control bypass. Specifically, and unconditionally:
324
+ // • NO auth / paywall / CAPTCHA / behind-a-login bypass.
325
+ // • Rate limits are HONORED, never routed around: a 429 — and any 5xx bearing a
326
+ // Retry-After header (isHonorRetryAfter, errors.ts M2) — waits and fails
327
+ // honestly; it NEVER counts as a breaker failure nor triggers a fallback.
328
+ // • NO IP-rotation / residential-proxy / egress-hunting (m1-policy). The default
329
+ // deployment is client-side (each user's own clean egress); if OUR egress is
330
+ // blocked we HONOR it and fail — we do not escalate.
331
+ // • A mirror/snapshot is an HONEST cache of public data with provenance +
332
+ // freshness disclosed (P5); a non-live body can NEVER be labelled live.
333
+ //
334
+ // ★INERT in Phase 1: NO adapter opts into a multi-path chain or the conditional
335
+ // primitive, and getJson/getText/getJsonWithHeaders are BYTE-IDENTICAL. A
336
+ // single-path chain is a pure passthrough (no breaker consult, no overhead) =
337
+ // today's behavior. The breaker only activates for a ≥2-path chain, so for all
338
+ // 27 single-path sources it is a NO-OP that never skips the live attempt.
339
+
340
+ /**
341
+ * Provenance the resilience layer stamps on a served body (ADR-0045 B2). Mirrors
342
+ * `getJsonWithHeaders`'s `{ body, contentRange }` provenance shape. `dataPath` is
343
+ * a FRESHNESS enum (M2), not a topology label: an independent host serving live
344
+ * data is still `"live"`. `asOf` (ISO-8601 UTC, m3) is present ONLY for a
345
+ * non-live (`snapshot`) body.
346
+ */
347
+ export type Provenance = {
348
+ dataPath: "live" | "snapshot";
349
+ asOf?: string;
350
+ };
351
+
352
+ /**
353
+ * getJsonWithProvenance — the resilient-fetch primitive (ADR-0045 B2). Runs the
354
+ * IDENTICAL init-assembly + `fetchWithRetry(url, init, opts.label)` envelope as
355
+ * `getJson`, and returns `{ body, provenance }`. The port ALWAYS returns an
356
+ * explicit provenance (honesty-B2 + regression-M1 reconciled): in Phase 1 there
357
+ * is a SINGLE live path, so `provenance` is ALWAYS `{ dataPath:"live" }` (asOf
358
+ * omitted). Because the port always stamps provenance, a mirror/snapshot body can
359
+ * never be structurally mislabelled as live (a future adapter that forgets to
360
+ * thread it still cannot claim live).
361
+ *
362
+ * ★INERT: NO adapter calls this yet — it is dormant infrastructure. It is a
363
+ * SEPARATE primitive (getJson is untouched, byte-identical), exported for the
364
+ * path-chain and for direct unit tests.
365
+ */
366
+ export async function getJsonWithProvenance<T = unknown>(
367
+ url: string,
368
+ opts: GetJsonOptions,
369
+ ): Promise<{ body: T; provenance: Provenance }> {
370
+ const init: RequestInit = {
371
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
372
+ };
373
+ if (opts.headers !== undefined) init.headers = opts.headers;
374
+ if (opts.redirect) init.redirect = opts.redirect;
375
+ if (opts.method !== undefined) init.method = opts.method;
376
+ if (opts.body !== undefined) init.body = opts.body;
377
+ const r = await fetchWithRetry(url, init, opts.label);
378
+ return {
379
+ body: (await r.json()) as T,
380
+ // Single live path in Phase 1 — always live, no asOf.
381
+ provenance: { dataPath: "live" },
382
+ };
383
+ }
384
+
385
+ // ─── Per-host circuit breaker (ADR-0045 B1) ────────────────────────────────
386
+ /**
387
+ * A per-host circuit breaker (bounded, keyed by a FIXED host set — the same
388
+ * discipline as `gates` above, so the Map can NEVER grow unboundedly; m3-reg).
389
+ *
390
+ * Trip condition (INTENTIONALLY narrow, B1-regression): 5 CONSECUTIVE HARD
391
+ * failures on a host — where HARD = a 5xx or a network `TypeError` ONLY. It is
392
+ * EXPLICITLY not tripped by 429 (rate-limited), a 5xx bearing Retry-After
393
+ * (isHonorRetryAfter — B1-policy), 404, or Timeout/Abort (errors.ts:162-172's
394
+ * existing fast-fail). Once open, it stays open 30s, then admits a SINGLE
395
+ * half-open probe; a probe success closes it, a probe failure re-opens it.
396
+ *
397
+ * ★INERT in Phase 1: the breaker is only ever CONSULTED by `throughPathChain`
398
+ * for a ≥2-path chain. No source declares ≥2 paths this phase, so it never
399
+ * activates in production — but it is unit-tested directly.
400
+ */
401
+ const BREAKER_TRIP_THRESHOLD = 5;
402
+ const BREAKER_OPEN_MS = 30_000;
403
+
404
+ type BreakerState = {
405
+ /** Consecutive HARD failures since the last success (resets on any success). */
406
+ consecutiveHardFailures: number;
407
+ /** Epoch ms until which the breaker is OPEN (0 = closed). */
408
+ openUntil: number;
409
+ /** True once a half-open probe has been admitted (dedupes to a SINGLE probe). */
410
+ probeInFlight: boolean;
411
+ };
412
+
413
+ export class CircuitBreaker {
414
+ /** Bounded: only hosts in the fixed set are ever tracked (m3-regression). */
415
+ private readonly hosts: ReadonlySet<string>;
416
+ private readonly states = new Map<string, BreakerState>();
417
+ /** Injectable clock for deterministic offline unit tests (defaults Date.now). */
418
+ private readonly now: () => number;
419
+
420
+ constructor(hosts: Iterable<string>, now: () => number = () => Date.now()) {
421
+ this.hosts = new Set(hosts);
422
+ this.now = now;
423
+ }
424
+
425
+ private state(host: string): BreakerState | undefined {
426
+ if (!this.hosts.has(host)) return undefined; // untracked host ⇒ pure no-op
427
+ let s = this.states.get(host);
428
+ if (!s) {
429
+ s = { consecutiveHardFailures: 0, openUntil: 0, probeInFlight: false };
430
+ this.states.set(host, s);
431
+ }
432
+ return s;
433
+ }
434
+
435
+ /**
436
+ * Should the live attempt to `host` be SKIPPED right now? True only while the
437
+ * breaker is OPEN and its 30s window has not elapsed AND a half-open probe is
438
+ * already in flight. When the window elapses, exactly ONE caller is admitted as
439
+ * the half-open probe (this returns false and marks probeInFlight) and the rest
440
+ * are skipped until that probe reports back. An untracked host is NEVER skipped.
441
+ */
442
+ shouldSkip(host: string): boolean {
443
+ const s = this.state(host);
444
+ if (!s || s.openUntil === 0) return false; // closed (or untracked) ⇒ attempt
445
+ if (this.now() < s.openUntil) {
446
+ // Fully OPEN within the 30s window ⇒ skip every live attempt.
447
+ return true;
448
+ }
449
+ // Window elapsed → HALF-OPEN. Admit exactly ONE probe (returns false so it
450
+ // attempts live); skip all others until that probe reports back.
451
+ if (!s.probeInFlight) {
452
+ s.probeInFlight = true;
453
+ return false;
454
+ }
455
+ return true;
456
+ }
457
+
458
+ /** Record a live success — closes the breaker and resets the failure run. */
459
+ onSuccess(host: string): void {
460
+ const s = this.state(host);
461
+ if (!s) return;
462
+ s.consecutiveHardFailures = 0;
463
+ s.openUntil = 0;
464
+ s.probeInFlight = false;
465
+ }
466
+
467
+ /**
468
+ * Record a live failure. A NON-hard error (429 / honor-Retry-After / 404 /
469
+ * timeout / abort) is IGNORED — it neither counts toward the trip threshold nor
470
+ * resets the run (it is orthogonal to host health). A HARD error increments the
471
+ * consecutive count and trips the breaker at the threshold; if it arrives during
472
+ * a half-open probe it immediately re-opens the window.
473
+ */
474
+ onFailure(host: string, err: unknown): void {
475
+ const s = this.state(host);
476
+ if (!s) return;
477
+ if (!isHardFailure(err)) return; // 429/honor-RA/404/timeout ⇒ not host health
478
+ if (s.probeInFlight) {
479
+ // Half-open probe failed → re-open for another full window.
480
+ s.openUntil = this.now() + BREAKER_OPEN_MS;
481
+ s.probeInFlight = false;
482
+ return;
483
+ }
484
+ s.consecutiveHardFailures += 1;
485
+ if (s.consecutiveHardFailures >= BREAKER_TRIP_THRESHOLD) {
486
+ s.openUntil = this.now() + BREAKER_OPEN_MS;
487
+ }
488
+ }
489
+
490
+ /** Test-only introspection: is the breaker currently OPEN for `host`? */
491
+ isOpen(host: string): boolean {
492
+ const s = this.states.get(host);
493
+ return !!s && s.openUntil !== 0 && this.now() < s.openUntil;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Is a thrown error a HARD failure for circuit-breaker purposes (ADR-0045 B1)?
499
+ * HARD = a 5xx or a network `TypeError` ONLY. Everything else is excluded:
500
+ * • isHonorRetryAfter (429 / 5xx+Retry-After) — B1-policy, honor the wait;
501
+ * • Timeout/Abort — classified upstream_unavailable with retryable:FALSE
502
+ * (errors.ts:169), so the `retryable===true` gate excludes it;
503
+ * • 404 (not_found), 4xx (invalid_input), schema_drift, unknown — not outages.
504
+ * A 5xx-after-retries and a network TypeError both surface from `fetchWithRetry`
505
+ * as a `ToolErrorCarrier` upstream_unavailable with retryable:TRUE — the one
506
+ * signature this admits. A raw (unclassified) `TypeError` also counts, for
507
+ * robustness when the breaker is driven directly.
508
+ */
509
+ export function isHardFailure(err: unknown): boolean {
510
+ if (isHonorRetryAfter(err)) return false;
511
+ if (err instanceof ToolErrorCarrier) {
512
+ const te = err.toolError;
513
+ return te.kind === "upstream_unavailable" && te.retryable === true;
514
+ }
515
+ return err instanceof TypeError;
516
+ }
517
+
518
+ // ─── Path-chain abstraction (ADR-0045 §"경로 체인") ──────────────────────────
519
+ /**
520
+ * One ordered access path for a source. `provenance` is what a SUCCESS on this
521
+ * path yields (live for the primary API, snapshot for a self-hosted mirror);
522
+ * `host` is the HOST-ONLY key the breaker tracks (must be in the breaker's fixed
523
+ * set); `run` performs the fetch+parse (typically a `getJson*` call).
524
+ */
525
+ export type ResiliencePath<T> = {
526
+ provenance: Provenance;
527
+ host: string;
528
+ run: () => Promise<T>;
529
+ };
530
+
531
+ /**
532
+ * Try an ordered array of access paths, returning the first success + its
533
+ * provenance (ADR-0045 §"경로 체인").
534
+ *
535
+ * ★A SINGLE-entry chain is a PURE PASSTHROUGH — no breaker consult, no try/catch
536
+ * overhead, byte-for-byte today's behavior (B1-regression). The breaker is
537
+ * consulted ONLY for a ≥2-path chain, so the 27 single-path sources are never
538
+ * affected.
539
+ *
540
+ * For a multi-path chain: an open breaker on a path's host SKIPS that path (no
541
+ * live attempt); a HARD failure records against the breaker and falls through to
542
+ * the next path; a SUCCESS records success and returns. An isHonorRetryAfter
543
+ * error (429 / 5xx+Retry-After) is RE-THROWN IMMEDIATELY (B1-policy) — it never
544
+ * counts against the breaker and never falls through to a mirror/snapshot; we
545
+ * wait and fail honestly. If every path is exhausted, the last error is thrown
546
+ * (honest failure — never a fabricated empty).
547
+ */
548
+ export async function throughPathChain<T>(
549
+ paths: ReadonlyArray<ResiliencePath<T>>,
550
+ breaker?: CircuitBreaker,
551
+ ): Promise<{ body: T; provenance: Provenance }> {
552
+ if (paths.length === 0) {
553
+ throw new ToolErrorCarrier({
554
+ kind: "unknown",
555
+ message: "throughPathChain called with no paths.",
556
+ retryable: false,
557
+ });
558
+ }
559
+ // Single-path fast path: pure passthrough, no breaker, no overhead (=today).
560
+ if (paths.length === 1) {
561
+ const only = paths[0]!;
562
+ return { body: await only.run(), provenance: only.provenance };
563
+ }
564
+ let lastErr: unknown;
565
+ for (const path of paths) {
566
+ if (breaker && breaker.shouldSkip(path.host)) {
567
+ // Breaker open for this host → skip the live attempt, try the next path.
568
+ lastErr =
569
+ lastErr ??
570
+ new ToolErrorCarrier({
571
+ kind: "upstream_unavailable",
572
+ message: `Circuit breaker open for ${path.host}; skipped.`,
573
+ retryable: true,
574
+ upstreamEndpoint: path.host,
575
+ });
576
+ continue;
577
+ }
578
+ try {
579
+ const body = await path.run();
580
+ if (breaker) breaker.onSuccess(path.host);
581
+ return { body, provenance: path.provenance };
582
+ } catch (e) {
583
+ // Honor an explicit upstream wait — NEVER count it against the breaker and
584
+ // NEVER fall through to a fallback path (B1-policy). Wait + fail honestly.
585
+ if (isHonorRetryAfter(e)) throw e;
586
+ if (breaker) breaker.onFailure(path.host, e);
587
+ lastErr = e;
588
+ }
589
+ }
590
+ throw lastErr;
591
+ }
592
+
593
+ // ─── Conditional GET (ADR-0045 B2-regression) ──────────────────────────────
594
+ /**
595
+ * A validator-bearing cache entry for `getJsonConditional`. `body` is the last
596
+ * parsed payload; `etag`/`lastModified` are the validators to replay; `asOf`
597
+ * (ISO-8601 UTC) is when the body was fetched.
598
+ */
599
+ export type CacheEntry<T> = {
600
+ body: T;
601
+ etag?: string;
602
+ lastModified?: string;
603
+ asOf?: string;
604
+ };
605
+
606
+ /**
607
+ * getJsonConditional — a SEPARATE conditional-GET primitive (ADR-0045
608
+ * B2-regression). It is DELIBERATELY NOT folded into the shared getJson/getText:
609
+ * adding If-None-Match/If-Modified-Since to getJson would make a 304 (a bodiless
610
+ * response) hit `r.json()` → a SyntaxError that fdic:340 / fedreg:588 reclassify
611
+ * as schema_drift — a hard regression. So this primitive:
612
+ * (a) sends validators ONLY when it holds a cache entry;
613
+ * (b) intercepts a 304 BEFORE the r.ok gate and returns the cached body;
614
+ * (c) NEVER calls `r.json()` on a 304.
615
+ * It keeps getJson's retry taxonomy for the non-304 case (5xx/429/network retry;
616
+ * timeout/abort fast-fail), via a local loop that mirrors fetchWithRetry but adds
617
+ * the 304 short-circuit ABOVE the r.ok gate.
618
+ *
619
+ * ★INERT: no adapter uses it yet. A cache MISS (no entry) behaves like getJson
620
+ * (no validators sent, 200 parsed) — so an adapter that later adopts it without a
621
+ * warm cache is envelope-identical.
622
+ */
623
+ export async function getJsonConditional<T = unknown>(
624
+ url: string,
625
+ opts: GetJsonOptions,
626
+ cache?: CacheEntry<T>,
627
+ ): Promise<{ body: T; notModified: boolean; provenance: Provenance }> {
628
+ // (a) Send validators ONLY when a cache entry is held.
629
+ const headers: Record<string, string> = { ...(opts.headers ?? {}) };
630
+ if (cache?.etag !== undefined) headers["If-None-Match"] = cache.etag;
631
+ if (cache?.lastModified !== undefined)
632
+ headers["If-Modified-Since"] = cache.lastModified;
633
+
634
+ const buildInit = (): RequestInit => {
635
+ const init: RequestInit = {
636
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
637
+ };
638
+ if (Object.keys(headers).length > 0) init.headers = headers;
639
+ if (opts.redirect) init.redirect = opts.redirect;
640
+ if (opts.method !== undefined) init.method = opts.method;
641
+ if (opts.body !== undefined) init.body = opts.body;
642
+ return init;
643
+ };
644
+
645
+ const maxAttempts = 3;
646
+ let lastErr: ToolError | undefined;
647
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
648
+ try {
649
+ const r = await fetch(url, buildInit());
650
+ // (b)/(c) Intercept 304 BEFORE the r.ok gate; NEVER read its (absent) body.
651
+ if (r.status === 304) {
652
+ if (!cache) {
653
+ // A 304 with no held validators is a protocol violation — fail honestly
654
+ // rather than fabricate an empty body.
655
+ throw new ToolErrorCarrier({
656
+ kind: "schema_drift",
657
+ message: `Unexpected 304 from ${opts.label} without a cache entry.`,
658
+ retryable: false,
659
+ upstreamEndpoint: opts.label,
660
+ });
661
+ }
662
+ return {
663
+ body: cache.body,
664
+ notModified: true,
665
+ provenance: cache.asOf
666
+ ? { dataPath: "snapshot", asOf: cache.asOf }
667
+ : { dataPath: "live" },
668
+ };
669
+ }
670
+ if (r.ok) {
671
+ return {
672
+ body: (await r.json()) as T,
673
+ notModified: false,
674
+ provenance: { dataPath: "live" },
675
+ };
676
+ }
677
+ const err = errorFromResponse(r, opts.label);
678
+ if (!err.retryable || attempt === maxAttempts) {
679
+ throw new ToolErrorCarrier(err);
680
+ }
681
+ lastErr = err;
682
+ const wait = err.retryAfterSeconds
683
+ ? Math.min(err.retryAfterSeconds, 60)
684
+ : Math.pow(2, attempt - 1);
685
+ await new Promise((res) => setTimeout(res, wait * 1000));
686
+ } catch (e) {
687
+ if (e instanceof ToolErrorCarrier) throw e;
688
+ // Timeout/abort ⇒ fast-fail non-retryable (mirrors fetchWithRetry:162-172).
689
+ if (
690
+ e instanceof Error &&
691
+ (e.name === "TimeoutError" || e.name === "AbortError")
692
+ ) {
693
+ throw new ToolErrorCarrier({
694
+ kind: "upstream_unavailable",
695
+ message: `Request to ${opts.label} timed out.`,
696
+ retryable: false,
697
+ upstreamEndpoint: opts.label,
698
+ });
699
+ }
700
+ lastErr = {
701
+ kind: "upstream_unavailable",
702
+ message: `Network error reaching ${opts.label}: ${(e as Error).message}`,
703
+ retryable: true,
704
+ retryAfterSeconds: 30,
705
+ upstreamEndpoint: opts.label,
706
+ };
707
+ if (attempt === maxAttempts) throw new ToolErrorCarrier(lastErr);
708
+ await new Promise((res) =>
709
+ setTimeout(res, Math.pow(2, attempt - 1) * 1000),
710
+ );
711
+ }
712
+ }
713
+ throw new ToolErrorCarrier(
714
+ lastErr ?? {
715
+ kind: "unknown",
716
+ message: `${opts.label} failed after ${maxAttempts} attempts.`,
717
+ retryable: false,
718
+ upstreamEndpoint: opts.label,
719
+ },
720
+ );
721
+ }