@cliwant/mcp-sam-gov 1.4.0 → 1.6.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 (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +240 -231
  3. package/README.ko.md +240 -231
  4. package/README.md +725 -706
  5. package/dist/cbp-border.d.ts +51 -0
  6. package/dist/cbp-border.d.ts.map +1 -0
  7. package/dist/cbp-border.js +123 -0
  8. package/dist/cbp-border.js.map +1 -0
  9. package/dist/datagov-catalog.d.ts.map +1 -1
  10. package/dist/datagov-catalog.js +16 -2
  11. package/dist/datagov-catalog.js.map +1 -1
  12. package/dist/ecfr.d.ts +2 -2
  13. package/dist/ecfr.d.ts.map +1 -1
  14. package/dist/ecfr.js +24 -10
  15. package/dist/ecfr.js.map +1 -1
  16. package/dist/edgar.d.ts.map +1 -1
  17. package/dist/edgar.js +26 -6
  18. package/dist/edgar.js.map +1 -1
  19. package/dist/epa-envirofacts.d.ts.map +1 -1
  20. package/dist/epa-envirofacts.js +14 -1
  21. package/dist/epa-envirofacts.js.map +1 -1
  22. package/dist/errors.d.ts +10 -0
  23. package/dist/errors.d.ts.map +1 -1
  24. package/dist/errors.js +11 -0
  25. package/dist/errors.js.map +1 -1
  26. package/dist/far.d.ts.map +1 -1
  27. package/dist/far.js +3 -1
  28. package/dist/far.js.map +1 -1
  29. package/dist/federal-register.d.ts +2 -2
  30. package/dist/federal-register.d.ts.map +1 -1
  31. package/dist/federal-register.js +26 -10
  32. package/dist/federal-register.js.map +1 -1
  33. package/dist/feedback.d.ts +64 -0
  34. package/dist/feedback.d.ts.map +1 -0
  35. package/dist/feedback.js +131 -0
  36. package/dist/feedback.js.map +1 -0
  37. package/dist/fema.d.ts +36 -0
  38. package/dist/fema.d.ts.map +1 -1
  39. package/dist/fema.js +124 -0
  40. package/dist/fema.js.map +1 -1
  41. package/dist/gov-domains.d.ts +66 -0
  42. package/dist/gov-domains.d.ts.map +1 -0
  43. package/dist/gov-domains.js +211 -0
  44. package/dist/gov-domains.js.map +1 -0
  45. package/dist/nist-controls.d.ts +48 -0
  46. package/dist/nist-controls.d.ts.map +1 -0
  47. package/dist/nist-controls.js +174 -0
  48. package/dist/nist-controls.js.map +1 -0
  49. package/dist/nws-weather.d.ts +57 -0
  50. package/dist/nws-weather.d.ts.map +1 -0
  51. package/dist/nws-weather.js +131 -0
  52. package/dist/nws-weather.js.map +1 -0
  53. package/dist/openfda-drugsfda.d.ts +72 -0
  54. package/dist/openfda-drugsfda.d.ts.map +1 -0
  55. package/dist/openfda-drugsfda.js +230 -0
  56. package/dist/openfda-drugsfda.js.map +1 -0
  57. package/dist/openfda.d.ts.map +1 -1
  58. package/dist/openfda.js +31 -8
  59. package/dist/openfda.js.map +1 -1
  60. package/dist/server.d.ts.map +1 -1
  61. package/dist/server.js +374 -11
  62. package/dist/server.js.map +1 -1
  63. package/dist/treasury.d.ts +2 -0
  64. package/dist/treasury.d.ts.map +1 -1
  65. package/dist/treasury.js +7 -0
  66. package/dist/treasury.js.map +1 -1
  67. package/dist/usaspending.d.ts +32 -1
  68. package/dist/usaspending.d.ts.map +1 -1
  69. package/dist/usaspending.js +143 -16
  70. package/dist/usaspending.js.map +1 -1
  71. package/package.json +111 -111
  72. package/src/attachments.ts +652 -652
  73. package/src/bea.ts +372 -372
  74. package/src/bls.ts +1943 -1943
  75. package/src/cache.ts +73 -73
  76. package/src/cbp-border.ts +177 -0
  77. package/src/census-economic.ts +431 -431
  78. package/src/census.ts +735 -735
  79. package/src/ckan.ts +495 -495
  80. package/src/clinicaltrials.ts +923 -923
  81. package/src/cms-facility.ts +379 -379
  82. package/src/cms-hospital.ts +344 -344
  83. package/src/cms-supplier.ts +527 -527
  84. package/src/cms-utilization.ts +389 -389
  85. package/src/cms.ts +634 -634
  86. package/src/coerce.ts +47 -47
  87. package/src/courtlistener.ts +465 -465
  88. package/src/cpsc.ts +333 -333
  89. package/src/datagov-catalog.ts +312 -296
  90. package/src/datagov.ts +907 -907
  91. package/src/datagovKey.ts +68 -68
  92. package/src/datasource.ts +721 -721
  93. package/src/disclosure.ts +61 -61
  94. package/src/dol.ts +515 -515
  95. package/src/ecfr.ts +248 -231
  96. package/src/echo.ts +496 -496
  97. package/src/edgar.ts +3046 -3014
  98. package/src/epa-envirofacts.ts +358 -342
  99. package/src/errors.ts +324 -303
  100. package/src/fac.ts +529 -529
  101. package/src/far.ts +1009 -1007
  102. package/src/fdic.ts +2052 -2052
  103. package/src/federal-register.ts +725 -706
  104. package/src/feedback.ts +160 -0
  105. package/src/fema.ts +680 -541
  106. package/src/fpds.ts +620 -620
  107. package/src/fred.ts +464 -464
  108. package/src/gao.ts +744 -744
  109. package/src/gov-domains.ts +237 -0
  110. package/src/govinfo.ts +497 -497
  111. package/src/grants.ts +290 -290
  112. package/src/gsa-csv.ts +992 -992
  113. package/src/gsa-perdiem.ts +361 -361
  114. package/src/integrity.ts +928 -928
  115. package/src/keys.ts +268 -268
  116. package/src/lda.ts +385 -385
  117. package/src/meta.ts +292 -292
  118. package/src/nhtsa.ts +352 -352
  119. package/src/nih.ts +375 -375
  120. package/src/nist-controls.ts +219 -0
  121. package/src/nonprofit.ts +460 -460
  122. package/src/nppes.ts +834 -834
  123. package/src/nsf.ts +706 -706
  124. package/src/nvd.ts +1124 -1124
  125. package/src/nws-weather.ts +167 -0
  126. package/src/ofac.ts +1166 -1166
  127. package/src/openfda-device.ts +356 -356
  128. package/src/openfda-drugsfda.ts +313 -0
  129. package/src/openfda.ts +518 -495
  130. package/src/pricing.ts +1075 -1075
  131. package/src/sam-gov/client.ts +774 -774
  132. package/src/sam-gov/index.ts +32 -32
  133. package/src/sam-gov/types.ts +152 -152
  134. package/src/sba.ts +357 -357
  135. package/src/server.ts +6688 -6297
  136. package/src/snapshot.ts +223 -223
  137. package/src/socrata.ts +532 -532
  138. package/src/treasury.ts +582 -575
  139. package/src/usaspending.ts +2852 -2680
  140. package/src/usitc.ts +420 -420
@@ -1,342 +1,358 @@
1
- /**
2
- * epa-envirofacts.ts — EPA Envirofacts RESTful data service (`data.epa.gov`,
3
- * `/efservice`), the `tri_facility` table (ADR-0059). KEYLESS.
4
- *
5
- * WHAT IT ADDS: `epa_tri_facilities` — a location/compliance lane: look up EPA
6
- * Toxics Release Inventory (TRI) reporting facilities by state / facility-name /
7
- * county. This is the demand-side environmental-footprint complement to the
8
- * market-sizing (Census CBP) and macro (FRED) sources — a place-of-performance
9
- * environmental screen for a given geography.
10
- *
11
- * ★THE TWO-REQUEST PATTERN (the load-bearing P1 honesty): the efservice REST API
12
- * embeds filters as PATH SEGMENTS and reports NO total in the data slice. So the
13
- * EXACT total comes from a SEPARATE count sub-query — the identical filter path
14
- * with a `/count/JSON` tail returns `[{"TOTALQUERYRESULTS": 1247}]`. This tool
15
- * runs the count FIRST (best-effort) then the data slice: totalAvailable =
16
- * TOTALQUERYRESULTS (P1, EXACT), NEVER the returned rows' length. If the count
17
- * sub-query fails or is absent, totalAvailable falls to null + a disclosing note
18
- * (never a length-faked total) and the data slice still returns.
19
- *
20
- * The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
21
- * (redirect:"error") / `driftError` (datasource.ts), `str`/`num` (coerce.ts,
22
- * null-never-empty-string / null-never-0), and `withMeta`·`buildMeta` (meta.ts,
23
- * offset pagination + totalAvailable). It MIRRORS census-economic.ts's
24
- * fixed-host SSRF idiom (a single host const + a post-construction hostname/
25
- * protocol assertion + redirect:"error") and datagov-catalog.ts's schema_drift
26
- * catch-ladder (ToolErrorCarrier rethrow FIRST so a 5xx keeps its taxonomy →
27
- * SyntaxError→driftError → bare rethrow).
28
- *
29
- * GET https://data.epa.gov/efservice/tri_facility/state_abbr/{ST}
30
- * [/facility_name/CONTAINING/{NAME}] [/county_name/CONTAINING/{COUNTY}]
31
- * /count/JSON → [{ "TOTALQUERYRESULTS": <int> }] (the total)
32
- * GET …same filter path… /rows/{offset}:{offset+limit-1}/JSON
33
- * → [{ tri_facility_id, facility_name, street_address, city_name,
34
- * county_name, state_abbr, zip_code, region, fac_closed_ind, … }, …]
35
- *
36
- * ★ SSRF (the load-bearing guard — values ride as PATH SEGMENTS, not query params):
37
- * the host is a compile-time literal (`EPA_HOST`); the table + column names + the
38
- * `CONTAINING` operator + `rows`/`count`/`JSON` are all MODULE literals. Every
39
- * USER value is BOTH charclass-validated (state `^[A-Za-z]{2}$`; facilityName/
40
- * county letters/digits/space/&/-/. only, rejecting `/` and `..` path-traversal)
41
- * AND `encodeURIComponent`-encoded before it joins the path. `offset`/`limit` are
42
- * coerced to non-negative integers. A post-construction hostname/protocol
43
- * assertion + `redirect:"error"` fail closed on any off-host 3xx.
44
- *
45
- * ★ HONESTY (ADR-0059 P1–P5, live-verified 2026-07-15 on data.epa.gov):
46
- * [input] require at least `state` OR `facilityName` — an all-empty query is
47
- * REFUSED with invalid_input (0 fetch) so the whole national table is
48
- * never scanned.
49
- * [P1] totalAvailable = the count sub-query's TOTALQUERYRESULTS (EXACT — e.g.
50
- * VA = 1247), NOT the slice length. hasMore = offset+returned < total.
51
- * Count fails/absent ⇒ totalAvailable:null + a disclosing note.
52
- * [P2] an empty array ⇒ honest empty (returned:0, complete:true). getJson maps
53
- * a 4xx/5xx via errorFromResponse and THROWS (503 ⇒ upstream_unavailable,
54
- * 400 ⇒ invalid_input, 404 ⇒ not_found); a 200 non-array/non-JSON body ⇒
55
- * schema_drift (NEVER a fabricated empty).
56
- * [P3] fac_closed_ind ("0"/"1" live; "N"/"Y" per the schema) ⇒ a normalized
57
- * boolean `closed` — an UNRECOGNIZED value ⇒ null (never a fabricated
58
- * false). Addresses/names are strings via `str` (null-never-empty-string).
59
- * [P4] a data body that is not an array ⇒ driftError; a count body missing
60
- * TOTALQUERYRESULTS ⇒ totalAvailable:null (handled, not a crash).
61
- */
62
-
63
- import { ToolErrorCarrier } from "./errors.js";
64
- import { getJson, driftError } from "./datasource.js";
65
- import { str, num } from "./coerce.js";
66
- import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
67
-
68
- // Re-export the shared honesty coercions (single audited copy in ./coerce.js) so a
69
- // regression fails together across sources. NO local num/str.
70
- export { num, str };
71
-
72
- // ─── SSRF core: the single fixed host + module-literal path pieces ──
73
- const EPA_HOST = "data.epa.gov";
74
- const EPA_TABLE = "tri_facility";
75
- // HOST+path label — surfaces in ToolError.upstreamEndpoint; keyless, so no token
76
- // can ever appear here.
77
- const EPA_LABEL = "epa-envirofacts:/efservice/tri_facility";
78
-
79
- // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
80
- // state rides as a PATH SEGMENT → strictly 2 ASCII letters (the efservice filter is
81
- // case-insensitive; live-verified `va` === `VA`).
82
- const STATE_RE = /^[A-Za-z]{2}$/;
83
- // facilityName / county ride as PATH SEGMENTS after `.../CONTAINING/`. Allow only
84
- // letters / digits / space / & / - / . (a superset of real facility names); this
85
- // REJECTS `/` (path injection) and any other separator. `..` path-traversal is
86
- // rejected explicitly below (the charclass permits a lone `.`, so `..` must be
87
- // caught separately).
88
- const NAME_RE = /^[A-Za-z0-9 &.\-]+$/;
89
- const NAME_MAX = 100;
90
-
91
- const LIMIT_MIN = 1;
92
- const LIMIT_MAX = 100;
93
- const LIMIT_DEFAULT = 25;
94
-
95
- // ─── Honesty notes (ADR-0059 required set) ────────────────────────
96
- const CLOSED_NOTE =
97
- "`closed` is the normalized EPA fac_closed_ind ('0'/'N' → false = active, '1'/'Y' → true = closed); an unrecognized value is null (unknown), NEVER a fabricated false. A closed facility no longer actively reports to TRI.";
98
- const NOMINAL_NOTE =
99
- "These are EPA TRI (Toxics Release Inventory) REPORTING facilities for the geography — a nominal environmental-footprint screen, NOT a compliance, enforcement, or violation determination. Use ECHO / enforcement sources for compliance status.";
100
- const COUNT_FALLBACK_NOTE =
101
- "The count sub-query (…/count/JSON) failed or did not report TOTALQUERYRESULTS, so totalAvailable is null (unknown) — it was NOT faked from the returned row count. hasMore is a heuristic (a full page ⇒ likely more); re-page with offset to confirm.";
102
-
103
- // ─── Curated facility shape ───────────────────────────────────────
104
- export type TriFacility = {
105
- triFacilityId: string | null;
106
- facilityName: string | null;
107
- streetAddress: string | null;
108
- city: string | null;
109
- county: string | null;
110
- state: string | null;
111
- zip: string | null;
112
- region: string | null;
113
- closed: boolean | null;
114
- };
115
-
116
- /**
117
- * Normalize EPA's fac_closed_ind to a boolean. Live values are "0" (active) / "1"
118
- * (closed); the schema also documents "N"/"Y". Anything else ⇒ null (unknown) —
119
- * NEVER a fabricated false (P3).
120
- */
121
- export function normalizeClosed(v: unknown): boolean | null {
122
- const s = str(v);
123
- if (s === null) return null;
124
- const t = s.toUpperCase();
125
- if (t === "1" || t === "Y" || t === "YES" || t === "TRUE") return true;
126
- if (t === "0" || t === "N" || t === "NO" || t === "FALSE") return false;
127
- return null;
128
- }
129
-
130
- /** Map ONE efservice row → the curated facility shape (every scalar via `str`). */
131
- function mapFacility(row: unknown): TriFacility {
132
- const r = (row ?? {}) as Record<string, unknown>;
133
- return {
134
- triFacilityId: str(r.tri_facility_id),
135
- facilityName: str(r.facility_name),
136
- streetAddress: str(r.street_address),
137
- city: str(r.city_name),
138
- county: str(r.county_name),
139
- state: str(r.state_abbr),
140
- zip: str(r.zip_code),
141
- region: str(r.region),
142
- closed: normalizeClosed(r.fac_closed_ind),
143
- };
144
- }
145
-
146
- // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect:"error") ──
147
- /**
148
- * GET one efservice JSON resource at a MODULE-BUILT path (all user values already
149
- * charclass-validated + encodeURIComponent-encoded by the caller). Builds
150
- * `https://${EPA_HOST}${path}` on the FIXED host, asserts the CONSTRUCTED URL's
151
- * hostname === the host over https (belt-and-suspenders), and sets
152
- * `redirect:"error"` (an off-host 3xx must NOT be followed). Keyless — no headers.
153
- */
154
- async function getEpa(path: string): Promise<unknown> {
155
- const url = `https://${EPA_HOST}${path}`;
156
- const built = new URL(url);
157
- if (built.hostname !== EPA_HOST || built.protocol !== "https:") {
158
- throw new ToolErrorCarrier({
159
- kind: "invalid_input",
160
- message: `Constructed EPA Envirofacts URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(EPA_HOST)} over https — refusing to fetch (SSRF safety).`,
161
- retryable: false,
162
- upstreamEndpoint: EPA_LABEL,
163
- });
164
- }
165
- return getJson(built.toString(), { label: EPA_LABEL, redirect: "error" });
166
- }
167
-
168
- export type EpaTriFacilitiesArgs = {
169
- state?: string;
170
- facilityName?: string;
171
- county?: string;
172
- limit?: number;
173
- offset?: number;
174
- };
175
-
176
- /** Validate + encode ONE user path-segment value (facilityName / county). */
177
- function validateName(value: string, field: string): string {
178
- if (value.length > NAME_MAX || !NAME_RE.test(value) || value.includes("..")) {
179
- throw new ToolErrorCarrier({
180
- kind: "invalid_input",
181
- retryable: false,
182
- message: `Invalid ${field} ${JSON.stringify(value)} — allowed: letters, digits, space, & - . (≤${NAME_MAX} chars); '/' and '..' are rejected (it rides in the request PATH as a segment; it is strictly validated to prevent path injection).`,
183
- upstreamEndpoint: EPA_LABEL,
184
- });
185
- }
186
- return encodeURIComponent(value);
187
- }
188
-
189
- /**
190
- * Look up EPA TRI reporting facilities by state / facilityName / county →
191
- * normalized facility rows + honest `_meta`. Requires at least `state` OR
192
- * `facilityName` (an all-empty query is refused). Runs a count sub-query FIRST for
193
- * the EXACT total (P1), then the data slice; a count failure degrades to
194
- * totalAvailable:null + a note (never a length-faked total).
195
- */
196
- export async function triFacilities(
197
- args: EpaTriFacilitiesArgs,
198
- ): Promise<MetaBundle> {
199
- // ── [input guard] require at least state OR facilityName (never scan the whole
200
- // national table). ──
201
- const hasState = args.state !== undefined && args.state !== "";
202
- const hasName = args.facilityName !== undefined && args.facilityName !== "";
203
- if (!hasState && !hasName) {
204
- throw new ToolErrorCarrier({
205
- kind: "invalid_input",
206
- retryable: false,
207
- message:
208
- "epa_tri_facilities requires at least `state` (2-letter, e.g. 'VA') OR `facilityName` an all-empty query would scan the entire national TRI table and is refused. Add a filter and retry.",
209
- upstreamEndpoint: EPA_LABEL,
210
- });
211
- }
212
-
213
- // ── Validate + build the filter path segments (SSRF: charclass + encode each). ──
214
- const segments: string[] = ["efservice", EPA_TABLE];
215
- const filtersApplied: string[] = [];
216
-
217
- if (hasState) {
218
- const state = args.state as string;
219
- if (!STATE_RE.test(state)) {
220
- throw new ToolErrorCarrier({
221
- kind: "invalid_input",
222
- retryable: false,
223
- message: `Invalid state ${JSON.stringify(state)} — expected a 2-letter state code (^[A-Za-z]{2}$), e.g. "VA" (it rides in the request PATH; it is strictly validated).`,
224
- upstreamEndpoint: EPA_LABEL,
225
- });
226
- }
227
- segments.push("state_abbr", encodeURIComponent(state));
228
- filtersApplied.push(`state:${state}`);
229
- }
230
-
231
- if (hasName) {
232
- const enc = validateName(args.facilityName as string, "facilityName");
233
- segments.push("facility_name", "CONTAINING", enc);
234
- filtersApplied.push(`facilityName~${args.facilityName}`);
235
- }
236
-
237
- if (args.county !== undefined && args.county !== "") {
238
- const enc = validateName(args.county, "county");
239
- segments.push("county_name", "CONTAINING", enc);
240
- filtersApplied.push(`county~${args.county}`);
241
- }
242
-
243
- // ── Coerce limit / offset to non-negative integers (belt-and-suspenders behind
244
- // the server Zod; a DIRECT handler call bypasses Zod). ──
245
- let limit = LIMIT_DEFAULT;
246
- if (typeof args.limit === "number" && Number.isFinite(args.limit)) {
247
- limit = Math.trunc(args.limit);
248
- if (limit < LIMIT_MIN) limit = LIMIT_MIN;
249
- if (limit > LIMIT_MAX) limit = LIMIT_MAX;
250
- }
251
- let offset = 0;
252
- if (typeof args.offset === "number" && Number.isFinite(args.offset) && args.offset > 0) {
253
- offset = Math.trunc(args.offset);
254
- }
255
-
256
- const filterPath = "/" + segments.join("/");
257
-
258
- // ── (1) The COUNT sub-query FIRST (best-effort — the EXACT total, P1). Any
259
- // failure (network/5xx/drift/missing field) degrades to totalAvailable:null +
260
- // a disclosing note; it NEVER throws and NEVER fakes the total from the slice
261
- // length. ──
262
- let totalAvailable: number | null = null;
263
- let countFailed = false;
264
- try {
265
- const countBody = await getEpa(`${filterPath}/count/JSON`);
266
- if (
267
- Array.isArray(countBody) &&
268
- countBody.length > 0 &&
269
- countBody[0] !== null &&
270
- typeof countBody[0] === "object"
271
- ) {
272
- const t = num((countBody[0] as Record<string, unknown>).TOTALQUERYRESULTS);
273
- if (t !== null && t >= 0) {
274
- totalAvailable = t;
275
- } else {
276
- countFailed = true; // present body but no usable TOTALQUERYRESULTS (P4)
277
- }
278
- } else {
279
- countFailed = true; // count body not the expected [{...}] shape
280
- }
281
- } catch {
282
- countFailed = true; // any count error degrade, never propagate (P1)
283
- }
284
-
285
- // ── (2) The DATA slice the authoritative request. efservice rows are INCLUSIVE
286
- // on both ends: rows/{offset}:{offset+limit-1}. Its errors follow P2. ──
287
- const end = offset + limit - 1;
288
- const dataPath = `${filterPath}/rows/${offset}:${end}/JSON`;
289
-
290
- // Catch-ladder (datagov-catalog shape, VERBATIM): preserve the 4xx/5xx/timeout
291
- // ToolErrorCarrier taxonomy FIRST; reclassify a 200 non-JSON SyntaxError to
292
- // schema_drift SECOND; bare-rethrow LAST.
293
- let body: unknown;
294
- try {
295
- body = await getEpa(dataPath);
296
- } catch (e) {
297
- if (e instanceof ToolErrorCarrier) throw e;
298
- if (e instanceof SyntaxError)
299
- throw driftError(
300
- EPA_LABEL,
301
- "EPA Envirofacts returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
302
- );
303
- throw e;
304
- }
305
-
306
- // [P4] the data body MUST be an array (a non-array 200 is drift, never a
307
- // fabricated empty).
308
- if (!Array.isArray(body)) {
309
- throw driftError(
310
- EPA_LABEL,
311
- "EPA Envirofacts shape drift — the /rows/…/JSON response must be a JSON array of facilities.",
312
- );
313
- }
314
-
315
- const facilities = (body as unknown[]).map(mapFacility);
316
- const returned = facilities.length;
317
-
318
- // ── [P1] pagination. total known ⇒ hasMore = offset+returned < total; total
319
- // unknown (count degraded) ⇒ a full page is the honest heuristic for "more". ──
320
- const hasMore =
321
- totalAvailable !== null
322
- ? offset + returned < totalAvailable
323
- : returned === limit;
324
- const nextOffset = hasMore ? offset + returned : null;
325
-
326
- const notes: string[] = [CLOSED_NOTE, NOMINAL_NOTE];
327
- if (countFailed) notes.push(COUNT_FALLBACK_NOTE);
328
-
329
- const meta: Partial<ResponseMeta> = {
330
- source: `${EPA_HOST} EPA Envirofacts /efservice/${EPA_TABLE} (TRI facilities; keyless)`,
331
- keylessMode: true,
332
- returned,
333
- totalAvailable,
334
- filtersApplied,
335
- filtersDropped: [],
336
- fieldsUnavailable: [],
337
- pagination: { offset, limit, hasMore, nextOffset },
338
- notes,
339
- };
340
-
341
- return withMeta({ facilities }, meta);
342
- }
1
+ /**
2
+ * epa-envirofacts.ts — EPA Envirofacts RESTful data service (`data.epa.gov`,
3
+ * `/efservice`), the `tri_facility` table (ADR-0059). KEYLESS.
4
+ *
5
+ * WHAT IT ADDS: `epa_tri_facilities` — a location/compliance lane: look up EPA
6
+ * Toxics Release Inventory (TRI) reporting facilities by state / facility-name /
7
+ * county. This is the demand-side environmental-footprint complement to the
8
+ * market-sizing (Census CBP) and macro (FRED) sources — a place-of-performance
9
+ * environmental screen for a given geography.
10
+ *
11
+ * ★THE TWO-REQUEST PATTERN (the load-bearing P1 honesty): the efservice REST API
12
+ * embeds filters as PATH SEGMENTS and reports NO total in the data slice. So the
13
+ * EXACT total comes from a SEPARATE count sub-query — the identical filter path
14
+ * with a `/count/JSON` tail returns `[{"TOTALQUERYRESULTS": 1247}]`. This tool
15
+ * runs the count FIRST (best-effort) then the data slice: totalAvailable =
16
+ * TOTALQUERYRESULTS (P1, EXACT), NEVER the returned rows' length. If the count
17
+ * sub-query fails or is absent, totalAvailable falls to null + a disclosing note
18
+ * (never a length-faked total) and the data slice still returns.
19
+ *
20
+ * The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
21
+ * (redirect:"error") / `driftError` (datasource.ts), `str`/`num` (coerce.ts,
22
+ * null-never-empty-string / null-never-0), and `withMeta`·`buildMeta` (meta.ts,
23
+ * offset pagination + totalAvailable). It MIRRORS census-economic.ts's
24
+ * fixed-host SSRF idiom (a single host const + a post-construction hostname/
25
+ * protocol assertion + redirect:"error") and datagov-catalog.ts's schema_drift
26
+ * catch-ladder (ToolErrorCarrier rethrow FIRST so a 5xx keeps its taxonomy →
27
+ * SyntaxError→driftError → bare rethrow).
28
+ *
29
+ * GET https://data.epa.gov/efservice/tri_facility/state_abbr/{ST}
30
+ * [/facility_name/CONTAINING/{NAME}] [/county_name/CONTAINING/{COUNTY}]
31
+ * /count/JSON → [{ "TOTALQUERYRESULTS": <int> }] (the total)
32
+ * GET …same filter path… /rows/{offset}:{offset+limit-1}/JSON
33
+ * → [{ tri_facility_id, facility_name, street_address, city_name,
34
+ * county_name, state_abbr, zip_code, region, fac_closed_ind, … }, …]
35
+ *
36
+ * ★ SSRF (the load-bearing guard — values ride as PATH SEGMENTS, not query params):
37
+ * the host is a compile-time literal (`EPA_HOST`); the table + column names + the
38
+ * `CONTAINING` operator + `rows`/`count`/`JSON` are all MODULE literals. Every
39
+ * USER value is BOTH charclass-validated (state `^[A-Za-z]{2}$`; facilityName/
40
+ * county letters/digits/space/&/-/. only, rejecting `/` and `..` path-traversal)
41
+ * AND `encodeURIComponent`-encoded before it joins the path. `offset`/`limit` are
42
+ * coerced to non-negative integers. A post-construction hostname/protocol
43
+ * assertion + `redirect:"error"` fail closed on any off-host 3xx.
44
+ *
45
+ * ★ HONESTY (ADR-0059 P1–P5, live-verified 2026-07-15 on data.epa.gov):
46
+ * [input] require at least `state` OR `facilityName` — an all-empty query is
47
+ * REFUSED with invalid_input (0 fetch) so the whole national table is
48
+ * never scanned.
49
+ * [P1] totalAvailable = the count sub-query's TOTALQUERYRESULTS (EXACT — e.g.
50
+ * VA = 1247), NOT the slice length. hasMore = offset+returned < total.
51
+ * Count fails/absent ⇒ totalAvailable:null + a disclosing note.
52
+ * [P2] an empty array ⇒ honest empty (returned:0, complete:true). getJson maps
53
+ * a 4xx/5xx via errorFromResponse and THROWS (503 ⇒ upstream_unavailable,
54
+ * 400 ⇒ invalid_input, 404 ⇒ not_found); a 200 non-array/non-JSON body ⇒
55
+ * schema_drift (NEVER a fabricated empty).
56
+ * [P3] fac_closed_ind ("0"/"1" live; "N"/"Y" per the schema) ⇒ a normalized
57
+ * boolean `closed` — an UNRECOGNIZED value ⇒ null (never a fabricated
58
+ * false). Addresses/names are strings via `str` (null-never-empty-string).
59
+ * [P4] a data body that is not an array ⇒ driftError; a count body missing
60
+ * TOTALQUERYRESULTS ⇒ totalAvailable:null (handled, not a crash).
61
+ */
62
+
63
+ import { ToolErrorCarrier } from "./errors.js";
64
+ import { getJson, driftError } from "./datasource.js";
65
+ import { str, num } from "./coerce.js";
66
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
67
+
68
+ // Re-export the shared honesty coercions (single audited copy in ./coerce.js) so a
69
+ // regression fails together across sources. NO local num/str.
70
+ export { num, str };
71
+
72
+ // ─── SSRF core: the single fixed host + module-literal path pieces ──
73
+ const EPA_HOST = "data.epa.gov";
74
+ const EPA_TABLE = "tri_facility";
75
+ // HOST+path label — surfaces in ToolError.upstreamEndpoint; keyless, so no token
76
+ // can ever appear here.
77
+ const EPA_LABEL = "epa-envirofacts:/efservice/tri_facility";
78
+
79
+ // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
80
+ // state rides as a PATH SEGMENT → strictly 2 ASCII letters (the efservice filter is
81
+ // case-insensitive; live-verified `va` === `VA`).
82
+ const STATE_RE = /^[A-Za-z]{2}$/;
83
+ // facilityName / county ride as PATH SEGMENTS after `.../CONTAINING/`. Allow only
84
+ // letters / digits / space / & / - / . (a superset of real facility names); this
85
+ // REJECTS `/` (path injection) and any other separator. `..` path-traversal is
86
+ // rejected explicitly below (the charclass permits a lone `.`, so `..` must be
87
+ // caught separately).
88
+ const NAME_RE = /^[A-Za-z0-9 &.\-]+$/;
89
+ const NAME_MAX = 100;
90
+
91
+ const LIMIT_MIN = 1;
92
+ const LIMIT_MAX = 100;
93
+ const LIMIT_DEFAULT = 25;
94
+
95
+ // ─── Honesty notes (ADR-0059 required set) ────────────────────────
96
+ const CLOSED_NOTE =
97
+ "`closed` is the normalized EPA fac_closed_ind ('0'/'N' → false = active, '1'/'Y' → true = closed); an unrecognized value is null (unknown), NEVER a fabricated false. A closed facility no longer actively reports to TRI.";
98
+ const NOMINAL_NOTE =
99
+ "These are EPA TRI (Toxics Release Inventory) REPORTING facilities for the geography — a nominal environmental-footprint screen, NOT a compliance, enforcement, or violation determination. Use ECHO / enforcement sources for compliance status.";
100
+ const COUNT_FALLBACK_NOTE =
101
+ "The count sub-query (…/count/JSON) failed or did not report TOTALQUERYRESULTS, so totalAvailable is null (unknown) — it was NOT faked from the returned row count. hasMore is a heuristic (a full page ⇒ likely more); re-page with offset to confirm.";
102
+
103
+ // ─── Curated facility shape ───────────────────────────────────────
104
+ export type TriFacility = {
105
+ triFacilityId: string | null;
106
+ facilityName: string | null;
107
+ streetAddress: string | null;
108
+ city: string | null;
109
+ county: string | null;
110
+ state: string | null;
111
+ zip: string | null;
112
+ region: string | null;
113
+ closed: boolean | null;
114
+ };
115
+
116
+ /**
117
+ * Normalize EPA's fac_closed_ind to a boolean. Live values are "0" (active) / "1"
118
+ * (closed); the schema also documents "N"/"Y". Anything else ⇒ null (unknown) —
119
+ * NEVER a fabricated false (P3).
120
+ */
121
+ export function normalizeClosed(v: unknown): boolean | null {
122
+ const s = str(v);
123
+ if (s === null) return null;
124
+ const t = s.toUpperCase();
125
+ if (t === "1" || t === "Y" || t === "YES" || t === "TRUE") return true;
126
+ if (t === "0" || t === "N" || t === "NO" || t === "FALSE") return false;
127
+ return null;
128
+ }
129
+
130
+ /** Map ONE efservice row → the curated facility shape (every scalar via `str`). */
131
+ function mapFacility(row: unknown): TriFacility {
132
+ const r = (row ?? {}) as Record<string, unknown>;
133
+ return {
134
+ triFacilityId: str(r.tri_facility_id),
135
+ facilityName: str(r.facility_name),
136
+ streetAddress: str(r.street_address),
137
+ city: str(r.city_name),
138
+ county: str(r.county_name),
139
+ state: str(r.state_abbr),
140
+ zip: str(r.zip_code),
141
+ region: str(r.region),
142
+ closed: normalizeClosed(r.fac_closed_ind),
143
+ };
144
+ }
145
+
146
+ // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect:"error") ──
147
+ /**
148
+ * GET one efservice JSON resource at a MODULE-BUILT path (all user values already
149
+ * charclass-validated + encodeURIComponent-encoded by the caller). Builds
150
+ * `https://${EPA_HOST}${path}` on the FIXED host, asserts the CONSTRUCTED URL's
151
+ * hostname === the host over https (belt-and-suspenders), and sets
152
+ * `redirect:"error"` (an off-host 3xx must NOT be followed). Keyless — no headers.
153
+ */
154
+ async function getEpa(path: string): Promise<unknown> {
155
+ const url = `https://${EPA_HOST}${path}`;
156
+ const built = new URL(url);
157
+ if (built.hostname !== EPA_HOST || built.protocol !== "https:") {
158
+ throw new ToolErrorCarrier({
159
+ kind: "invalid_input",
160
+ message: `Constructed EPA Envirofacts URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(EPA_HOST)} over https — refusing to fetch (SSRF safety).`,
161
+ retryable: false,
162
+ upstreamEndpoint: EPA_LABEL,
163
+ });
164
+ }
165
+ return getJson(built.toString(), { label: EPA_LABEL, redirect: "error" });
166
+ }
167
+
168
+ export type EpaTriFacilitiesArgs = {
169
+ state?: string;
170
+ facilityName?: string;
171
+ county?: string;
172
+ limit?: number;
173
+ offset?: number;
174
+ };
175
+
176
+ /** Validate + encode ONE user path-segment value (facilityName / county). */
177
+ function validateName(value: string, field: string): string {
178
+ // Reject `..` (path traversal) AND a lone `.` both are URL dot-segments that
179
+ // WHATWG `URL` silently COLLAPSES, which would drop the CONTAINING value from the
180
+ // path, slide the next literal into its place, and leave `_meta.filtersApplied`
181
+ // claiming a filter the wire request no longer carries (a false-filtersApplied bug).
182
+ if (
183
+ value.length > NAME_MAX ||
184
+ !NAME_RE.test(value) ||
185
+ value.includes("..") ||
186
+ value === "."
187
+ ) {
188
+ throw new ToolErrorCarrier({
189
+ kind: "invalid_input",
190
+ retryable: false,
191
+ message: `Invalid ${field} ${JSON.stringify(value)} allowed: letters, digits, space, & - . (≤${NAME_MAX} chars); '/' and '..' are rejected (it rides in the request PATH as a segment; it is strictly validated to prevent path injection).`,
192
+ upstreamEndpoint: EPA_LABEL,
193
+ });
194
+ }
195
+ return encodeURIComponent(value);
196
+ }
197
+
198
+ /**
199
+ * Look up EPA TRI reporting facilities by state / facilityName / county
200
+ * normalized facility rows + honest `_meta`. Requires at least `state` OR
201
+ * `facilityName` (an all-empty query is refused). Runs a count sub-query FIRST for
202
+ * the EXACT total (P1), then the data slice; a count failure degrades to
203
+ * totalAvailable:null + a note (never a length-faked total).
204
+ */
205
+ export async function triFacilities(
206
+ args: EpaTriFacilitiesArgs,
207
+ ): Promise<MetaBundle> {
208
+ // ── [input guard] require at least state OR facilityName (never scan the whole
209
+ // national table). ──
210
+ const hasState = args.state !== undefined && args.state !== "";
211
+ const hasName = args.facilityName !== undefined && args.facilityName !== "";
212
+ if (!hasState && !hasName) {
213
+ throw new ToolErrorCarrier({
214
+ kind: "invalid_input",
215
+ retryable: false,
216
+ message:
217
+ "epa_tri_facilities requires at least `state` (2-letter, e.g. 'VA') OR `facilityName` — an all-empty query would scan the entire national TRI table and is refused. Add a filter and retry.",
218
+ upstreamEndpoint: EPA_LABEL,
219
+ });
220
+ }
221
+
222
+ // ── Validate + build the filter path segments (SSRF: charclass + encode each). ──
223
+ const segments: string[] = ["efservice", EPA_TABLE];
224
+ const filtersApplied: string[] = [];
225
+
226
+ if (hasState) {
227
+ const state = args.state as string;
228
+ if (!STATE_RE.test(state)) {
229
+ throw new ToolErrorCarrier({
230
+ kind: "invalid_input",
231
+ retryable: false,
232
+ message: `Invalid state ${JSON.stringify(state)} expected a 2-letter state code (^[A-Za-z]{2}$), e.g. "VA" (it rides in the request PATH; it is strictly validated).`,
233
+ upstreamEndpoint: EPA_LABEL,
234
+ });
235
+ }
236
+ segments.push("state_abbr", encodeURIComponent(state));
237
+ filtersApplied.push(`state:${state}`);
238
+ }
239
+
240
+ if (hasName) {
241
+ const enc = validateName(args.facilityName as string, "facilityName");
242
+ segments.push("facility_name", "CONTAINING", enc);
243
+ filtersApplied.push(`facilityName~${args.facilityName}`);
244
+ }
245
+
246
+ if (args.county !== undefined && args.county !== "") {
247
+ const enc = validateName(args.county, "county");
248
+ segments.push("county_name", "CONTAINING", enc);
249
+ filtersApplied.push(`county~${args.county}`);
250
+ }
251
+
252
+ // ── Coerce limit / offset to non-negative integers (belt-and-suspenders behind
253
+ // the server Zod; a DIRECT handler call bypasses Zod). ──
254
+ let limit = LIMIT_DEFAULT;
255
+ if (typeof args.limit === "number" && Number.isFinite(args.limit)) {
256
+ limit = Math.trunc(args.limit);
257
+ if (limit < LIMIT_MIN) limit = LIMIT_MIN;
258
+ if (limit > LIMIT_MAX) limit = LIMIT_MAX;
259
+ }
260
+ let offset = 0;
261
+ if (typeof args.offset === "number" && Number.isFinite(args.offset) && args.offset > 0) {
262
+ offset = Math.trunc(args.offset);
263
+ }
264
+
265
+ const filterPath = "/" + segments.join("/");
266
+
267
+ // ── (1) The COUNT sub-query FIRST (best-effort — the EXACT total, P1). Any
268
+ // failure (network/5xx/drift/missing field) degrades to totalAvailable:null +
269
+ // a disclosing note; it NEVER throws and NEVER fakes the total from the slice
270
+ // length. ──
271
+ let totalAvailable: number | null = null;
272
+ let countFailed = false;
273
+ try {
274
+ const countBody = await getEpa(`${filterPath}/count/JSON`);
275
+ if (
276
+ Array.isArray(countBody) &&
277
+ countBody.length > 0 &&
278
+ countBody[0] !== null &&
279
+ typeof countBody[0] === "object"
280
+ ) {
281
+ const t = num((countBody[0] as Record<string, unknown>).TOTALQUERYRESULTS);
282
+ if (t !== null && t >= 0) {
283
+ totalAvailable = t;
284
+ } else {
285
+ countFailed = true; // present body but no usable TOTALQUERYRESULTS (P4)
286
+ }
287
+ } else {
288
+ countFailed = true; // count body not the expected [{...}] shape
289
+ }
290
+ } catch {
291
+ countFailed = true; // any count error degrade, never propagate (P1)
292
+ }
293
+
294
+ // ── (2) The DATA slice — the authoritative request. efservice rows are INCLUSIVE
295
+ // on both ends: rows/{offset}:{offset+limit-1}. Its errors follow P2. ──
296
+ const end = offset + limit - 1;
297
+ const dataPath = `${filterPath}/rows/${offset}:${end}/JSON`;
298
+
299
+ // Catch-ladder (datagov-catalog shape, VERBATIM): preserve the 4xx/5xx/timeout
300
+ // ToolErrorCarrier taxonomy FIRST; reclassify a 200 non-JSON SyntaxError to
301
+ // schema_drift SECOND; bare-rethrow LAST.
302
+ let body: unknown;
303
+ try {
304
+ body = await getEpa(dataPath);
305
+ } catch (e) {
306
+ if (e instanceof ToolErrorCarrier) throw e;
307
+ if (e instanceof SyntaxError)
308
+ throw driftError(
309
+ EPA_LABEL,
310
+ "EPA Envirofacts returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
311
+ );
312
+ throw e;
313
+ }
314
+
315
+ // [P4] the data body MUST be an array (a non-array 200 is drift, never a
316
+ // fabricated empty).
317
+ if (!Array.isArray(body)) {
318
+ throw driftError(
319
+ EPA_LABEL,
320
+ "EPA Envirofacts shape drift — the /rows/…/JSON response must be a JSON array of facilities.",
321
+ );
322
+ }
323
+
324
+ const facilities = (body as unknown[]).map(mapFacility);
325
+ const returned = facilities.length;
326
+
327
+ // ── [P1] pagination. total known ⇒ hasMore = offset+returned < total; total
328
+ // unknown (count degraded) ⇒ a full page is the honest heuristic for "more". ──
329
+ const hasMore =
330
+ totalAvailable !== null
331
+ ? offset + returned < totalAvailable
332
+ : returned === limit;
333
+ const nextOffset = hasMore ? offset + returned : null;
334
+
335
+ const notes: string[] = [CLOSED_NOTE, NOMINAL_NOTE];
336
+ if (countFailed) notes.push(COUNT_FALLBACK_NOTE);
337
+
338
+ // When the total is UNKNOWN (count sub-query degraded) AND this is not the first
339
+ // page, we cannot claim the response is the ENTIRE result set — rows 0..offset-1
340
+ // are absent and no total confirms coverage. Force truncated so buildMeta never
341
+ // derives complete:true on a partial last page fetched at offset>0 with null total.
342
+ const cannotProveComplete = totalAvailable === null && offset > 0;
343
+
344
+ const meta: Partial<ResponseMeta> = {
345
+ source: `${EPA_HOST} EPA Envirofacts /efservice/${EPA_TABLE} (TRI facilities; keyless)`,
346
+ keylessMode: true,
347
+ returned,
348
+ totalAvailable,
349
+ ...(cannotProveComplete ? { truncated: true } : {}),
350
+ filtersApplied,
351
+ filtersDropped: [],
352
+ fieldsUnavailable: [],
353
+ pagination: { offset, limit, hasMore, nextOffset },
354
+ notes,
355
+ };
356
+
357
+ return withMeta({ facilities }, meta);
358
+ }