@cliwant/mcp-sam-gov 1.3.0 → 1.5.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 (149) hide show
  1. package/README.ja.md +20 -12
  2. package/README.ko.md +20 -12
  3. package/README.md +62 -14
  4. package/dist/bea.d.ts +1 -1
  5. package/dist/bea.js +1 -1
  6. package/dist/cbp-border.d.ts +51 -0
  7. package/dist/cbp-border.d.ts.map +1 -0
  8. package/dist/cbp-border.js +123 -0
  9. package/dist/cbp-border.js.map +1 -0
  10. package/dist/census-economic.d.ts +1 -1
  11. package/dist/census-economic.d.ts.map +1 -1
  12. package/dist/census-economic.js +12 -6
  13. package/dist/census-economic.js.map +1 -1
  14. package/dist/cms-facility.d.ts +112 -0
  15. package/dist/cms-facility.d.ts.map +1 -0
  16. package/dist/cms-facility.js +311 -0
  17. package/dist/cms-facility.js.map +1 -0
  18. package/dist/cms-hospital.d.ts +105 -0
  19. package/dist/cms-hospital.d.ts.map +1 -0
  20. package/dist/cms-hospital.js +290 -0
  21. package/dist/cms-hospital.js.map +1 -0
  22. package/dist/cms-supplier.d.ts +133 -0
  23. package/dist/cms-supplier.d.ts.map +1 -0
  24. package/dist/cms-supplier.js +414 -0
  25. package/dist/cms-supplier.js.map +1 -0
  26. package/dist/cms-utilization.d.ts +113 -0
  27. package/dist/cms-utilization.d.ts.map +1 -0
  28. package/dist/cms-utilization.js +328 -0
  29. package/dist/cms-utilization.js.map +1 -0
  30. package/dist/courtlistener.d.ts +115 -0
  31. package/dist/courtlistener.d.ts.map +1 -0
  32. package/dist/courtlistener.js +398 -0
  33. package/dist/courtlistener.js.map +1 -0
  34. package/dist/cpsc.d.ts +81 -0
  35. package/dist/cpsc.d.ts.map +1 -0
  36. package/dist/cpsc.js +283 -0
  37. package/dist/cpsc.js.map +1 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -1
  39. package/dist/datagov-catalog.js +16 -2
  40. package/dist/datagov-catalog.js.map +1 -1
  41. package/dist/dol.d.ts +2 -2
  42. package/dist/dol.js +5 -5
  43. package/dist/dol.js.map +1 -1
  44. package/dist/ecfr.d.ts +2 -2
  45. package/dist/ecfr.d.ts.map +1 -1
  46. package/dist/ecfr.js +24 -10
  47. package/dist/ecfr.js.map +1 -1
  48. package/dist/edgar.d.ts.map +1 -1
  49. package/dist/edgar.js +26 -6
  50. package/dist/edgar.js.map +1 -1
  51. package/dist/epa-envirofacts.d.ts +97 -0
  52. package/dist/epa-envirofacts.d.ts.map +1 -0
  53. package/dist/epa-envirofacts.js +305 -0
  54. package/dist/epa-envirofacts.js.map +1 -0
  55. package/dist/errors.d.ts.map +1 -1
  56. package/dist/errors.js +11 -0
  57. package/dist/errors.js.map +1 -1
  58. package/dist/far.d.ts.map +1 -1
  59. package/dist/far.js +3 -1
  60. package/dist/far.js.map +1 -1
  61. package/dist/federal-register.d.ts +2 -2
  62. package/dist/federal-register.d.ts.map +1 -1
  63. package/dist/federal-register.js +26 -10
  64. package/dist/federal-register.js.map +1 -1
  65. package/dist/fema.d.ts +36 -0
  66. package/dist/fema.d.ts.map +1 -1
  67. package/dist/fema.js +124 -0
  68. package/dist/fema.js.map +1 -1
  69. package/dist/fred.d.ts +1 -1
  70. package/dist/fred.js +1 -1
  71. package/dist/gov-domains.d.ts +66 -0
  72. package/dist/gov-domains.d.ts.map +1 -0
  73. package/dist/gov-domains.js +211 -0
  74. package/dist/gov-domains.js.map +1 -0
  75. package/dist/keys.d.ts +6 -5
  76. package/dist/keys.d.ts.map +1 -1
  77. package/dist/keys.js +25 -6
  78. package/dist/keys.js.map +1 -1
  79. package/dist/nhtsa.d.ts +91 -0
  80. package/dist/nhtsa.d.ts.map +1 -0
  81. package/dist/nhtsa.js +263 -0
  82. package/dist/nhtsa.js.map +1 -0
  83. package/dist/nist-controls.d.ts +48 -0
  84. package/dist/nist-controls.d.ts.map +1 -0
  85. package/dist/nist-controls.js +174 -0
  86. package/dist/nist-controls.js.map +1 -0
  87. package/dist/nonprofit.d.ts +116 -0
  88. package/dist/nonprofit.d.ts.map +1 -0
  89. package/dist/nonprofit.js +342 -0
  90. package/dist/nonprofit.js.map +1 -0
  91. package/dist/nws-weather.d.ts +57 -0
  92. package/dist/nws-weather.d.ts.map +1 -0
  93. package/dist/nws-weather.js +131 -0
  94. package/dist/nws-weather.js.map +1 -0
  95. package/dist/openfda-device.d.ts +85 -0
  96. package/dist/openfda-device.d.ts.map +1 -0
  97. package/dist/openfda-device.js +277 -0
  98. package/dist/openfda-device.js.map +1 -0
  99. package/dist/openfda-drugsfda.d.ts +72 -0
  100. package/dist/openfda-drugsfda.d.ts.map +1 -0
  101. package/dist/openfda-drugsfda.js +230 -0
  102. package/dist/openfda-drugsfda.js.map +1 -0
  103. package/dist/openfda.d.ts +133 -0
  104. package/dist/openfda.d.ts.map +1 -0
  105. package/dist/openfda.js +425 -0
  106. package/dist/openfda.js.map +1 -0
  107. package/dist/server.d.ts.map +1 -1
  108. package/dist/server.js +996 -16
  109. package/dist/server.js.map +1 -1
  110. package/dist/treasury.d.ts +2 -0
  111. package/dist/treasury.d.ts.map +1 -1
  112. package/dist/treasury.js +7 -0
  113. package/dist/treasury.js.map +1 -1
  114. package/dist/usaspending.d.ts +32 -1
  115. package/dist/usaspending.d.ts.map +1 -1
  116. package/dist/usaspending.js +143 -16
  117. package/dist/usaspending.js.map +1 -1
  118. package/package.json +3 -2
  119. package/src/bea.ts +1 -1
  120. package/src/cbp-border.ts +177 -0
  121. package/src/census-economic.ts +12 -6
  122. package/src/cms-facility.ts +379 -0
  123. package/src/cms-hospital.ts +344 -0
  124. package/src/cms-supplier.ts +527 -0
  125. package/src/cms-utilization.ts +389 -0
  126. package/src/courtlistener.ts +465 -0
  127. package/src/cpsc.ts +333 -0
  128. package/src/datagov-catalog.ts +18 -2
  129. package/src/dol.ts +5 -5
  130. package/src/ecfr.ts +27 -10
  131. package/src/edgar.ts +39 -7
  132. package/src/epa-envirofacts.ts +358 -0
  133. package/src/errors.ts +11 -0
  134. package/src/far.ts +3 -1
  135. package/src/federal-register.ts +29 -10
  136. package/src/fema.ts +139 -0
  137. package/src/fred.ts +1 -1
  138. package/src/gov-domains.ts +237 -0
  139. package/src/keys.ts +27 -6
  140. package/src/nhtsa.ts +352 -0
  141. package/src/nist-controls.ts +219 -0
  142. package/src/nonprofit.ts +460 -0
  143. package/src/nws-weather.ts +167 -0
  144. package/src/openfda-device.ts +356 -0
  145. package/src/openfda-drugsfda.ts +313 -0
  146. package/src/openfda.ts +518 -0
  147. package/src/server.ts +1127 -27
  148. package/src/treasury.ts +7 -0
  149. package/src/usaspending.ts +189 -17
@@ -0,0 +1,237 @@
1
+ /**
2
+ * get.gov — the authoritative US .gov domain registry (CISA). KEYLESS.
3
+ *
4
+ * The .gov program (run by CISA) publishes the COMPLETE registry as CSVs in its
5
+ * official repo github.com/cisagov/dotgov-data — the canonical published location
6
+ * (get.gov links there; there is no query API for the full set). It is NOT a .gov
7
+ * API host, so provenance is disclosed on every response (the ProPublica /
8
+ * CourtListener republisher pattern — except here CISA is the first-party registrar).
9
+ *
10
+ * B2G value: resolve WHICH organization owns a .gov domain, enumerate federal
11
+ * agencies, and MAP SLED entities (state / county / city / school-district /
12
+ * special-district / tribal) for market targeting — a distinct authoritative
13
+ * gov-org registry no other tool here exposes.
14
+ *
15
+ * SSRF: fixed host `raw.githubusercontent.com` + fixed path prefix
16
+ * `/cisagov/dotgov-data/main/` + a scope-selected FIXED filename (federal | full) —
17
+ * no free host, path, or filename. `redirect:"error"` on the fetch.
18
+ *
19
+ * PII: the CSV carries a "Security contact email" column (an ORG security mailbox,
20
+ * e.g. security@agency.gov). We DROP it — this tool resolves ORGANIZATIONS, not
21
+ * contacts, and excluding it keeps the output free of contact info.
22
+ *
23
+ * Filtering is CLIENT-SIDE over the full published CSV (the registry has no query
24
+ * API) — disclosed in `_meta.notes`. `totalAvailable` is the EXACT match count.
25
+ */
26
+
27
+ import { getText } from "./datasource.js";
28
+ import { driftError } from "./datasource.js";
29
+ import { memoize } from "./cache.js";
30
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
31
+
32
+ export const DOTGOV_HOST = "raw.githubusercontent.com";
33
+ const DOTGOV_PATH_PREFIX = "https://raw.githubusercontent.com/cisagov/dotgov-data/main/";
34
+
35
+ // scope → the pinned filename (no free path). "all" = federal + SLED (~16k rows),
36
+ // "federal" = federal only (~1.3k rows).
37
+ const DOTGOV_FILES = {
38
+ all: "current-full.csv",
39
+ federal: "current-federal.csv",
40
+ } as const;
41
+ export type GovDomainScope = keyof typeof DOTGOV_FILES;
42
+
43
+ const SOURCE_LABEL = "getgov:cisagov/dotgov-data";
44
+ const PROVENANCE_NOTE =
45
+ "Source: the CISA get.gov OFFICIAL .gov domain registry, published as CSV at github.com/cisagov/dotgov-data (the canonical location; get.gov links there). CISA is the first-party .gov registrar; this is authoritative public data served from GitHub, not a .gov API host.";
46
+ const CLIENT_FILTER_NOTE =
47
+ "The registry has no query API — the full published CSV is fetched and filtered CLIENT-SIDE (organization/domain/city are case-insensitive SUBSTRING matches; state/domainType are case-insensitive). totalAvailable is the EXACT count of matching rows.";
48
+ const FRESHNESS_NOTE =
49
+ "The CISA dotgov-data CSV is refreshed ~daily; this response reflects the currently-published snapshot (served from a 6-hour cache).";
50
+ const EMAIL_DROP_NOTE =
51
+ "The registry's 'Security contact email' column (an organization security mailbox) is intentionally EXCLUDED — this tool resolves organizations, not contacts.";
52
+
53
+ export type GovDomainRow = {
54
+ domain: string;
55
+ domainType: string;
56
+ organization: string;
57
+ suborganization: string | null;
58
+ city: string | null;
59
+ state: string | null;
60
+ };
61
+
62
+ // ─── Minimal RFC-4180 CSV parser (quoted fields + embedded commas/newlines) ──
63
+ /**
64
+ * Parse a full CSV document into rows of string fields. Handles double-quoted
65
+ * fields, escaped `""` quotes, and commas/newlines INSIDE quotes. Self-contained
66
+ * (no external dep) — the get.gov CSV is small (~1.4 MB) so a whole-string parse is
67
+ * fine. Returns every record's raw field array (including the header row).
68
+ */
69
+ export function parseCsv(text: string): string[][] {
70
+ const rows: string[][] = [];
71
+ let field = "";
72
+ let row: string[] = [];
73
+ let inQuotes = false;
74
+ for (let i = 0; i < text.length; i++) {
75
+ const c = text[i];
76
+ if (inQuotes) {
77
+ if (c === '"') {
78
+ if (text[i + 1] === '"') {
79
+ field += '"';
80
+ i++; // consume the escaped quote
81
+ } else {
82
+ inQuotes = false;
83
+ }
84
+ } else {
85
+ field += c;
86
+ }
87
+ } else if (c === '"') {
88
+ inQuotes = true;
89
+ } else if (c === ",") {
90
+ row.push(field);
91
+ field = "";
92
+ } else if (c === "\n") {
93
+ row.push(field);
94
+ rows.push(row);
95
+ row = [];
96
+ field = "";
97
+ } else if (c === "\r") {
98
+ // CRLF: the \n case pushes the record; ignore the stray CR.
99
+ } else {
100
+ field += c;
101
+ }
102
+ }
103
+ // Flush a trailing field/row if the file did not end with a newline.
104
+ if (field.length > 0 || row.length > 0) {
105
+ row.push(field);
106
+ rows.push(row);
107
+ }
108
+ return rows;
109
+ }
110
+
111
+ /** Coerce a CSV field to a trimmed non-empty string, else null (never ""). */
112
+ function s(v: string | undefined): string | null {
113
+ if (v === undefined) return null;
114
+ const t = v.trim();
115
+ return t.length > 0 ? t : null;
116
+ }
117
+
118
+ /**
119
+ * Fetch + parse ONE scope's registry CSV into rows, memoized ~6h. Header-mapped by
120
+ * COLUMN NAME (not fixed index) so an upstream column reorder does not silently
121
+ * mis-map; a missing required header ⇒ schema_drift (never a fake-empty). The
122
+ * "Security contact email" column is dropped at the map step.
123
+ */
124
+ async function loadRegistry(scope: GovDomainScope): Promise<GovDomainRow[]> {
125
+ return memoize(`getgov:${scope}`, async () => {
126
+ const url = `${DOTGOV_PATH_PREFIX}${DOTGOV_FILES[scope]}`;
127
+ // Belt-and-suspenders SSRF: the URL is built only from the pinned prefix +
128
+ // pinned filename, but assert it before the fetch.
129
+ const built = new URL(url);
130
+ if (built.hostname !== DOTGOV_HOST || built.protocol !== "https:") {
131
+ throw driftError(
132
+ SOURCE_LABEL,
133
+ `Constructed get.gov URL host ${JSON.stringify(built.hostname)} is not ${DOTGOV_HOST} over https — refusing to fetch (SSRF safety).`,
134
+ );
135
+ }
136
+ const text = await getText(url, { label: SOURCE_LABEL, redirect: "error", timeoutMs: 20_000 });
137
+ const rows = parseCsv(text);
138
+ const headerRow = rows[0];
139
+ if (!headerRow) {
140
+ throw driftError(SOURCE_LABEL, "get.gov registry CSV was empty — treating as schema drift, never a fake-empty result.");
141
+ }
142
+ const header = headerRow.map((h) => h.trim());
143
+ const idx = (name: string) => header.indexOf(name);
144
+ const iDomain = idx("Domain name");
145
+ const iType = idx("Domain type");
146
+ const iOrg = idx("Organization name");
147
+ const iSub = idx("Suborganization name");
148
+ const iCity = idx("City");
149
+ const iState = idx("State");
150
+ // The four load-bearing columns MUST be present (a rename ⇒ schema drift, not a
151
+ // silently mis-mapped/empty result).
152
+ if (iDomain < 0 || iType < 0 || iOrg < 0) {
153
+ throw driftError(
154
+ SOURCE_LABEL,
155
+ `get.gov registry CSV header is missing a required column (Domain name / Domain type / Organization name) — schema drift. Got: ${header.join(", ")}.`,
156
+ );
157
+ }
158
+ return rows.slice(1).map((r) => ({
159
+ domain: (r[iDomain] ?? "").trim(),
160
+ domainType: (r[iType] ?? "").trim(),
161
+ organization: (r[iOrg] ?? "").trim(),
162
+ suborganization: iSub >= 0 ? s(r[iSub]) : null,
163
+ city: iCity >= 0 ? s(r[iCity]) : null,
164
+ state: iState >= 0 ? s(r[iState]) : null,
165
+ // NOTE: "Security contact email" is deliberately NOT read/emitted.
166
+ }));
167
+ });
168
+ }
169
+
170
+ // ─── Tool: search_gov_domains ─────────────────────────────────────
171
+ /**
172
+ * Search the CISA get.gov .gov domain registry. Client-side filters over the
173
+ * published CSV: organization/domain/city are case-insensitive SUBSTRING matches;
174
+ * state (2-letter) and domainType are case-insensitive. scope 'all' (federal + SLED,
175
+ * default) | 'federal'. Honest `_meta` (exact match total; provenance + client-side
176
+ * disclosure).
177
+ */
178
+ export async function searchGovDomains(args: {
179
+ scope?: GovDomainScope;
180
+ organization?: string;
181
+ domain?: string;
182
+ domainType?: string;
183
+ state?: string;
184
+ city?: string;
185
+ limit?: number;
186
+ offset?: number;
187
+ }): Promise<MetaBundle> {
188
+ const scope: GovDomainScope = args.scope ?? "all";
189
+ const limit = args.limit ?? 50;
190
+ const offset = args.offset ?? 0;
191
+
192
+ const all = await loadRegistry(scope);
193
+
194
+ const filtersApplied: string[] = [];
195
+ const ci = (v: string | undefined) => (v ?? "").toLowerCase();
196
+ const orgQ = ci(args.organization);
197
+ const domQ = ci(args.domain);
198
+ const typeQ = ci(args.domainType);
199
+ const stateQ = ci(args.state);
200
+ const cityQ = ci(args.city);
201
+ if (args.organization !== undefined) filtersApplied.push("organization");
202
+ if (args.domain !== undefined) filtersApplied.push("domain");
203
+ if (args.domainType !== undefined) filtersApplied.push("domainType");
204
+ if (args.state !== undefined) filtersApplied.push("state");
205
+ if (args.city !== undefined) filtersApplied.push("city");
206
+
207
+ const matched = all.filter((row) => {
208
+ if (orgQ && !row.organization.toLowerCase().includes(orgQ)) return false;
209
+ if (domQ && !row.domain.toLowerCase().includes(domQ)) return false;
210
+ if (typeQ && !row.domainType.toLowerCase().includes(typeQ)) return false;
211
+ if (stateQ && (row.state ?? "").toLowerCase() !== stateQ) return false;
212
+ if (cityQ && !(row.city ?? "").toLowerCase().includes(cityQ)) return false;
213
+ return true;
214
+ });
215
+
216
+ const totalAvailable = matched.length;
217
+ const page = matched.slice(offset, offset + limit);
218
+ const returned = page.length;
219
+ const hasMore = offset + returned < totalAvailable;
220
+ const nextOffset = hasMore ? offset + returned : null;
221
+
222
+ return withMeta(
223
+ { scope, domains: page },
224
+ {
225
+ source: `getgov ${DOTGOV_FILES[scope]} (CISA .gov registry, keyless)`,
226
+ keylessMode: true,
227
+ returned,
228
+ totalAvailable,
229
+ truncated: hasMore,
230
+ filtersApplied,
231
+ filtersDropped: [],
232
+ fieldsUnavailable: ["securityContactEmail (org mailbox — intentionally excluded)"],
233
+ pagination: { offset, limit, hasMore, nextOffset },
234
+ notes: [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, FRESHNESS_NOTE, EMAIL_DROP_NOTE],
235
+ } satisfies Partial<ResponseMeta>,
236
+ );
237
+ }
package/src/keys.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Why this exists
5
5
  * ----------------
6
- * The server rides 31 federal sources. MOST are fully keyless. But the set of
6
+ * The server rides dozens of federal data sources. MOST are fully keyless. But the set of
7
7
  * *optional* keys (raise a rate limit, unlock one filter) plus the four *required*
8
8
  * keys (Census business-patterns, FRED, BEA Regional, DOL data) has grown to the point where a user — or
9
9
  * the AI driving the server — cannot tell, without reading source code:
@@ -20,8 +20,9 @@
20
20
  * `process.env.<NAME>` — DATA_GOV_API_KEY (datagovKey.ts), SAM_GOV_API_KEY
21
21
  * (server.ts), BLS_API_KEY (bls.ts), NVD_API_KEY (nvd.ts), SOCRATA_APP_TOKEN
22
22
  * (socrata.ts), CENSUS_API_KEY (census-economic.ts), FRED_API_KEY (fred.ts),
23
- * BEA_API_KEY (bea.ts), DOL_API_KEY (dol.ts), LDA_API_KEY (lda.ts). No invented
24
- * keys, sources, or signup URLs.
23
+ * BEA_API_KEY (bea.ts), DOL_API_KEY (dol.ts), LDA_API_KEY (lda.ts),
24
+ * OPENFDA_API_KEY (openfda.ts), COURTLISTENER_API_TOKEN (courtlistener.ts).
25
+ * No invented keys, sources, or signup URLs.
25
26
  */
26
27
 
27
28
  import { readFileSync } from "node:fs";
@@ -44,11 +45,11 @@ export type KeyRegistryEntry = {
44
45
  };
45
46
 
46
47
  /**
47
- * The 10 keys the server reads — code-grounded, no inventions.
48
+ * The 12 keys the server reads — code-grounded, no inventions.
48
49
  *
49
50
  * REQUIRED (4): CENSUS_API_KEY, FRED_API_KEY, BEA_API_KEY, DOL_API_KEY — those sources
50
51
  * have no keyless tier, so the tool throws without them (DOL_API_KEY gates ONLY
51
- * dol_get_dataset; the DOL catalog, dol_list_datasets, is keyless). OPTIONAL (6):
52
+ * dol_get_dataset; the DOL catalog, dol_list_datasets, is keyless). OPTIONAL (8):
52
53
  * everything else works keyless; a key only raises a rate limit or unlocks a single filter.
53
54
  */
54
55
  export const KEY_REGISTRY: readonly KeyRegistryEntry[] = [
@@ -131,7 +132,7 @@ export const KEY_REGISTRY: readonly KeyRegistryEntry[] = [
131
132
  "US DOL enforcement data (dol_get_dataset; dol_list_datasets is keyless)",
132
133
  ],
133
134
  required: true,
134
- signupUrl: "https://dol.gov/developer",
135
+ signupUrl: "https://dataportal.dol.gov/registration",
135
136
  unlocks:
136
137
  "the dol_get_dataset tool (dataset records need a free key; dol_list_datasets works keyless)",
137
138
  note: "The DOL data endpoint needs a free key; the dataset CATALOG (dol_list_datasets) and agency list are keyless.",
@@ -145,6 +146,26 @@ export const KEY_REGISTRY: readonly KeyRegistryEntry[] = [
145
146
  "higher LDA API rate limits (anonymous access already works without it)",
146
147
  note: "Keyless by default (anonymous 200); a free token only raises the rate limit.",
147
148
  },
149
+ {
150
+ envVar: "OPENFDA_API_KEY",
151
+ sources: ["openFDA enforcement (openfda_enforcement)"],
152
+ required: false,
153
+ signupUrl: "https://open.fda.gov/apis/authentication/",
154
+ unlocks:
155
+ "higher openFDA rate limits (keyless works without it — ~1000/day)",
156
+ note: "Keyless by default; a free key raises the rate limit.",
157
+ },
158
+ {
159
+ envVar: "COURTLISTENER_API_TOKEN",
160
+ sources: [
161
+ "CourtListener federal court opinions (courtlistener_search_opinions)",
162
+ ],
163
+ required: false,
164
+ signupUrl: "https://www.courtlistener.com/help/api/rest/",
165
+ unlocks:
166
+ "higher CourtListener rate limits (anonymous search works without it)",
167
+ note: "Keyless by default (anonymous 200); a free token only raises the rate limit. Data = US federal court public records via CourtListener/Free Law Project.",
168
+ },
148
169
  ] as const;
149
170
 
150
171
  /** true iff the env var is set to a non-empty (after-trim) string. */
package/src/nhtsa.ts ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * nhtsa.ts — NHTSA VEHICLE SAFETY (api.nhtsa.gov) — the vehicle / parts /
3
+ * fleet supplier PRODUCT-SAFETY vetting lane (ADR-0057). Two keyless tools:
4
+ * • nhtsa_recalls — /recalls/recallsByVehicle?make=&model=&modelYear=
5
+ * • nhtsa_complaints — /complaints/complaintsByVehicle?make=&model=&modelYear=
6
+ * The cross-agency product-safety family alongside openFDA (medical) / CPSC
7
+ * (consumer goods): a manufacturer/component/safety-signal history for B2G
8
+ * supplier vetting.
9
+ *
10
+ * ★ KEYLESS — there is NO API key at all (no parameter, no header). This module
11
+ * touches NO key seam (no KEY_REGISTRY / keys.ts / API_KEYS.md). It REUSES the
12
+ * shared `getJson` (redirect:"error") / `driftError` fetch envelope, the `num`/
13
+ * `str` coercions (null-never-empty-string; a genuine 0 stays 0), and
14
+ * `withMeta`/`buildMeta` — and mirrors datagov-catalog.ts's fixed-host SSRF
15
+ * idiom + schema_drift catch-ladder verbatim.
16
+ *
17
+ * ★ PII — the complaints upstream response carries a `vin` field (an individual
18
+ * vehicle identifier). It is DELIBERATELY OMITTED from the curated output
19
+ * entirely — never surfaced, logged, or stored. The B2G value is the
20
+ * manufacturer / component / safety signal, NOT the VIN.
21
+ *
22
+ * ★ THE HONESTY PILLARS (P1-P4, live-verified 2026-07-15):
23
+ * P1: totalAvailable = `Count` (recalls) / `count` (complaints) — the REAL total.
24
+ * NHTSA returns the COMPLETE filtered set (no pagination), so in the normal
25
+ * case Count === results.length ⇒ complete:true. totalAvailable is NEVER
26
+ * fabricated: a PRESENT numeric Count is trusted verbatim; a MISSING Count
27
+ * falls back to results.length WITH an honest note (never invented).
28
+ * P2: results:[] (Count 0) ⇒ an HONEST EMPTY (returned:0, complete:true) — a bad
29
+ * make/model that returns 200+Count 0 is an honest no-match, NOT an error. A
30
+ * 4xx ⇒ invalid_input; a 5xx/timeout ⇒ THROW (never a fake empty); a 200
31
+ * non-JSON body ⇒ schema_drift.
32
+ * P3: booleans (crash/fire/parkIt/parkOutSide/overTheAirUpdate) preserved AS
33
+ * booleans (a non-boolean ⇒ null, never a fabricated false); counts
34
+ * (numberOfInjuries/numberOfDeaths) via `num` (a genuine 0 stays 0, NEVER
35
+ * null-for-0); dates as strings via `str`; Count/count via `num`.
36
+ * P4: `results` non-array ⇒ driftError; a Count/count that is PRESENT but a
37
+ * non-number ⇒ driftError (a broken total contract, never a fabricated empty).
38
+ * SSRF: fixed host `api.nhtsa.gov` (compile-time literal) + post-construction
39
+ * hostname/protocol assertion + redirect:"error"; make/model ride
40
+ * URLSearchParams (module-built, no raw passthrough); modelYear is
41
+ * ^\d{4}$; make/model are charclass-validated (letters/digits/space/hyphen,
42
+ * so a `../` or `%` can never reach the fixed path).
43
+ */
44
+
45
+ import { ToolErrorCarrier } from "./errors.js";
46
+ import { getJson, driftError } from "./datasource.js";
47
+ import { num, str } from "./coerce.js";
48
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
49
+
50
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
51
+ export const NHTSA_HOST = "api.nhtsa.gov";
52
+ const RECALLS_PATH = "/recalls/recallsByVehicle";
53
+ const COMPLAINTS_PATH = "/complaints/complaintsByVehicle";
54
+ // HOST+path-only labels (→ ToolError.upstreamEndpoint). Keyless ⇒ no token can
55
+ // ever appear here regardless, but the labels stay host+path for consistency.
56
+ const RECALLS_LABEL = "nhtsa:/recalls/recallsByVehicle";
57
+ const COMPLAINTS_LABEL = "nhtsa:/complaints/complaintsByVehicle";
58
+
59
+ // ─── Input validation grammar (SSRF + injection guard) ────────────
60
+ // modelYear: exactly 4 digits. make/model: letters/digits/space/hyphen only —
61
+ // rejects `../`, `%`, `/`, `.`, quotes, so a value can never break out of the
62
+ // URLSearchParams-encoded query onto the fixed host/path.
63
+ export const NHTSA_MODEL_YEAR_RE = /^\d{4}$/;
64
+ export const NHTSA_MAKE_MODEL_RE = /^[A-Za-z0-9 -]+$/;
65
+
66
+ const KEYLESS_NOTE =
67
+ "NHTSA is a keyless public API (api.nhtsa.gov) — no API key is required or accepted.";
68
+ const COMPLETE_SET_NOTE =
69
+ "NHTSA returns the COMPLETE set of matching records for this make/model/modelYear (no pagination) — totalAvailable is the upstream Count, and returned should equal it.";
70
+
71
+ // ─── Shared coercions ─────────────────────────────────────────────
72
+ /** A genuine boolean preserved; anything else ⇒ null (never a fabricated false). */
73
+ function bool(x: unknown): boolean | null {
74
+ return typeof x === "boolean" ? x : null;
75
+ }
76
+
77
+ // ─── Shared input validation ──────────────────────────────────────
78
+ export type NhtsaVehicleArgs = {
79
+ make: string;
80
+ model: string;
81
+ modelYear: string;
82
+ };
83
+
84
+ /**
85
+ * Validate the shared make/model/modelYear inputs (belt-and-suspenders behind the
86
+ * server Zod; a DIRECT handler call bypasses Zod). Rejects a bad value PRE-fetch
87
+ * (0 network call) so a `../`/`%` can never reach the fixed host/path.
88
+ */
89
+ function validateVehicleArgs(args: NhtsaVehicleArgs, label: string): void {
90
+ const checks: Array<[string, string, RegExp]> = [
91
+ ["make", args.make, NHTSA_MAKE_MODEL_RE],
92
+ ["model", args.model, NHTSA_MAKE_MODEL_RE],
93
+ ["modelYear", args.modelYear, NHTSA_MODEL_YEAR_RE],
94
+ ];
95
+ for (const [name, value, re] of checks) {
96
+ if (typeof value !== "string" || !re.test(value)) {
97
+ throw new ToolErrorCarrier({
98
+ kind: "invalid_input",
99
+ retryable: false,
100
+ message:
101
+ name === "modelYear"
102
+ ? `Invalid modelYear ${JSON.stringify(value)} — expected a 4-digit year (^\\d{4}$), e.g. "2020".`
103
+ : `Invalid ${name} ${JSON.stringify(value)} — expected letters/digits/space/hyphen only (^[A-Za-z0-9 -]+$), e.g. "honda".`,
104
+ upstreamEndpoint: label,
105
+ });
106
+ }
107
+ }
108
+ }
109
+
110
+ // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
111
+ /**
112
+ * GET one NHTSA JSON resource on the FIXED host. Builds
113
+ * `https://api.nhtsa.gov${path}?${params}`, asserts the CONSTRUCTED URL's
114
+ * hostname === the fixed host over https (belt-and-suspenders), and sets
115
+ * `redirect:"error"` (fail closed on any off-host 3xx). Keyless — no header/token.
116
+ */
117
+ async function getNhtsa(
118
+ path: string,
119
+ label: string,
120
+ params: URLSearchParams,
121
+ ): Promise<unknown> {
122
+ const url = `https://${NHTSA_HOST}${path}?${params.toString()}`;
123
+ const built = new URL(url);
124
+ if (built.hostname !== NHTSA_HOST || built.protocol !== "https:") {
125
+ throw new ToolErrorCarrier({
126
+ kind: "invalid_input",
127
+ retryable: false,
128
+ message: `Constructed NHTSA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(NHTSA_HOST)} over https — refusing to fetch (SSRF safety).`,
129
+ upstreamEndpoint: label,
130
+ });
131
+ }
132
+ return getJson(url, { label, redirect: "error" });
133
+ }
134
+
135
+ /** Build the shared make/model/modelYear query (module-built; no raw passthrough). */
136
+ function vehicleParams(args: NhtsaVehicleArgs): URLSearchParams {
137
+ const params = new URLSearchParams();
138
+ params.set("make", args.make);
139
+ params.set("model", args.model);
140
+ params.set("modelYear", args.modelYear);
141
+ return params;
142
+ }
143
+
144
+ /**
145
+ * Fetch + parse a NHTSA resource, mirroring datagov-catalog's catch-ladder: a
146
+ * ToolErrorCarrier (host-assert / 4xx-5xx taxonomy) rethrows FIRST (preserving its
147
+ * kind); a 200 non-JSON `.json()` SyntaxError reclassifies to schema_drift; a bare
148
+ * error rethrows LAST.
149
+ */
150
+ async function fetchNhtsa(
151
+ path: string,
152
+ label: string,
153
+ args: NhtsaVehicleArgs,
154
+ ): Promise<unknown> {
155
+ try {
156
+ return await getNhtsa(path, label, vehicleParams(args));
157
+ } catch (e) {
158
+ if (e instanceof ToolErrorCarrier) throw e;
159
+ if (e instanceof SyntaxError)
160
+ throw driftError(
161
+ label,
162
+ `NHTSA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
163
+ );
164
+ throw e;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Resolve the total from a Count/count field (P1/P4). A PRESENT numeric value is
170
+ * trusted verbatim; a MISSING (undefined/null) value falls back to results.length
171
+ * WITH an honest note (never fabricated); a PRESENT non-number ⇒ driftError (a
172
+ * broken total contract). Returns the total + the fallback flag.
173
+ */
174
+ function resolveTotal(
175
+ rawCount: unknown,
176
+ returned: number,
177
+ label: string,
178
+ ): { total: number; fellBack: boolean } {
179
+ if (rawCount === undefined || rawCount === null) {
180
+ // P1 fallback — missing Count ⇒ results.length + an honest note.
181
+ return { total: returned, fellBack: true };
182
+ }
183
+ const n = num(rawCount);
184
+ if (n === null) {
185
+ // P4 — a PRESENT non-number Count is a broken contract, never a fake empty.
186
+ throw driftError(
187
+ label,
188
+ `NHTSA ${label} shape drift — the total count field is present but non-numeric.`,
189
+ );
190
+ }
191
+ return { total: n, fellBack: false };
192
+ }
193
+
194
+ // ─── Curated row shapes ───────────────────────────────────────────
195
+ export type NhtsaRecall = {
196
+ campaignNumber: string | null;
197
+ manufacturer: string | null;
198
+ component: string | null;
199
+ summary: string | null;
200
+ consequence: string | null;
201
+ remedy: string | null;
202
+ reportReceivedDate: string | null;
203
+ parkIt: boolean | null;
204
+ parkOutside: boolean | null;
205
+ overTheAirUpdate: boolean | null;
206
+ };
207
+
208
+ export type NhtsaComplaint = {
209
+ odiNumber: string | null;
210
+ manufacturer: string | null;
211
+ component: string | null;
212
+ summary: string | null;
213
+ crash: boolean | null;
214
+ fire: boolean | null;
215
+ numberOfInjuries: number | null;
216
+ numberOfDeaths: number | null;
217
+ dateOfIncident: string | null;
218
+ dateComplaintFiled: string | null;
219
+ };
220
+
221
+ /** Map ONE /recallsByVehicle row → the curated recall shape. Booleans via `bool`. */
222
+ function mapRecall(row: unknown): NhtsaRecall {
223
+ const r = (row ?? {}) as Record<string, unknown>;
224
+ return {
225
+ campaignNumber: str(r.NHTSACampaignNumber),
226
+ manufacturer: str(r.Manufacturer),
227
+ component: str(r.Component),
228
+ summary: str(r.Summary),
229
+ consequence: str(r.Consequence),
230
+ remedy: str(r.Remedy),
231
+ reportReceivedDate: str(r.ReportReceivedDate),
232
+ parkIt: bool(r.parkIt),
233
+ parkOutside: bool(r.parkOutSide),
234
+ overTheAirUpdate: bool(r.overTheAirUpdate),
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Map ONE /complaintsByVehicle row → the curated complaint shape. ★The `vin` field
240
+ * is DELIBERATELY OMITTED (PII — never read into the output). Counts via `num` (a
241
+ * genuine 0 stays 0); booleans via `bool`.
242
+ */
243
+ function mapComplaint(row: unknown): NhtsaComplaint {
244
+ const r = (row ?? {}) as Record<string, unknown>;
245
+ return {
246
+ odiNumber: str(r.odiNumber),
247
+ manufacturer: str(r.manufacturer),
248
+ component: str(r.components),
249
+ summary: str(r.summary),
250
+ crash: bool(r.crash),
251
+ fire: bool(r.fire),
252
+ numberOfInjuries: num(r.numberOfInjuries),
253
+ numberOfDeaths: num(r.numberOfDeaths),
254
+ dateOfIncident: str(r.dateOfIncident),
255
+ dateComplaintFiled: str(r.dateComplaintFiled),
256
+ // ★ NO vin — the PII field is never surfaced, logged, or stored.
257
+ };
258
+ }
259
+
260
+ const FILTERS_APPLIED = ["make", "model", "modelYear"];
261
+
262
+ // ─── Tool: nhtsa_recalls ──────────────────────────────────────────
263
+ /**
264
+ * Fetch NHTSA safety RECALLS for a make/model/modelYear → curated recall rows +
265
+ * honest `_meta`. KEYLESS. totalAvailable = the upstream `Count` (the REAL total —
266
+ * NHTSA returns the complete set, no pagination). A no-match (Count 0) ⇒ an honest
267
+ * empty; a 4xx ⇒ invalid_input; a 5xx/timeout ⇒ THROW; a 200 non-JSON ⇒ drift.
268
+ */
269
+ export async function recalls(args: NhtsaVehicleArgs): Promise<MetaBundle> {
270
+ validateVehicleArgs(args, RECALLS_LABEL);
271
+ const body = await fetchNhtsa(RECALLS_PATH, RECALLS_LABEL, args);
272
+
273
+ const b = (body ?? {}) as { Count?: unknown; results?: unknown };
274
+ // P4 — results MUST be an array (a missing/string/null results is drift).
275
+ if (!Array.isArray(b.results)) {
276
+ throw driftError(
277
+ RECALLS_LABEL,
278
+ `NHTSA ${RECALLS_LABEL} shape drift — results must be an array.`,
279
+ );
280
+ }
281
+ const recalls = (b.results as unknown[]).map(mapRecall);
282
+ const returned = recalls.length;
283
+ const { total, fellBack } = resolveTotal(b.Count, returned, RECALLS_LABEL);
284
+
285
+ const notes: string[] = [KEYLESS_NOTE, COMPLETE_SET_NOTE];
286
+ if (fellBack)
287
+ notes.push(
288
+ "NHTSA did not report a Count field — totalAvailable falls back to the number of returned rows (results.length); the true total may differ.",
289
+ );
290
+
291
+ return withMeta(
292
+ { recalls },
293
+ {
294
+ source: `${NHTSA_HOST} /recalls/recallsByVehicle (NHTSA vehicle safety recalls; keyless)`,
295
+ keylessMode: true,
296
+ returned,
297
+ totalAvailable: total,
298
+ filtersApplied: FILTERS_APPLIED,
299
+ filtersDropped: [],
300
+ fieldsUnavailable: [],
301
+ notes,
302
+ } satisfies Partial<ResponseMeta>,
303
+ );
304
+ }
305
+
306
+ // ─── Tool: nhtsa_complaints ───────────────────────────────────────
307
+ /**
308
+ * Fetch NHTSA consumer COMPLAINTS for a make/model/modelYear → curated complaint
309
+ * rows (★NO vin — PII omitted) + honest `_meta`. KEYLESS. totalAvailable = the
310
+ * upstream `count` (the REAL total). A no-match ⇒ honest empty; a 4xx ⇒
311
+ * invalid_input; a 5xx/timeout ⇒ THROW; a 200 non-JSON ⇒ drift.
312
+ */
313
+ export async function complaints(args: NhtsaVehicleArgs): Promise<MetaBundle> {
314
+ validateVehicleArgs(args, COMPLAINTS_LABEL);
315
+ const body = await fetchNhtsa(COMPLAINTS_PATH, COMPLAINTS_LABEL, args);
316
+
317
+ const b = (body ?? {}) as { count?: unknown; results?: unknown };
318
+ // P4 — results MUST be an array (a missing/string/null results is drift).
319
+ if (!Array.isArray(b.results)) {
320
+ throw driftError(
321
+ COMPLAINTS_LABEL,
322
+ `NHTSA ${COMPLAINTS_LABEL} shape drift — results must be an array.`,
323
+ );
324
+ }
325
+ const complaints = (b.results as unknown[]).map(mapComplaint);
326
+ const returned = complaints.length;
327
+ const { total, fellBack } = resolveTotal(b.count, returned, COMPLAINTS_LABEL);
328
+
329
+ const notes: string[] = [
330
+ KEYLESS_NOTE,
331
+ COMPLETE_SET_NOTE,
332
+ "The NHTSA complaint VIN (an individual vehicle identifier) is intentionally EXCLUDED from this output (PII). The B2G signal is the manufacturer/component/crash/fire/injury/death safety history.",
333
+ ];
334
+ if (fellBack)
335
+ notes.push(
336
+ "NHTSA did not report a count field — totalAvailable falls back to the number of returned rows (results.length); the true total may differ.",
337
+ );
338
+
339
+ return withMeta(
340
+ { complaints },
341
+ {
342
+ source: `${NHTSA_HOST} /complaints/complaintsByVehicle (NHTSA vehicle safety complaints; keyless)`,
343
+ keylessMode: true,
344
+ returned,
345
+ totalAvailable: total,
346
+ filtersApplied: FILTERS_APPLIED,
347
+ filtersDropped: [],
348
+ fieldsUnavailable: [],
349
+ notes,
350
+ } satisfies Partial<ResponseMeta>,
351
+ );
352
+ }