@cliwant/mcp-sam-gov 1.5.0 → 1.7.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 (89) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +248 -231
  3. package/README.ko.md +248 -231
  4. package/README.md +733 -714
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js.map +1 -1
  8. package/dist/feedback.d.ts +64 -0
  9. package/dist/feedback.d.ts.map +1 -0
  10. package/dist/feedback.js +131 -0
  11. package/dist/feedback.js.map +1 -0
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +48 -2
  14. package/dist/server.js.map +1 -1
  15. package/dist/update-check.d.ts +38 -0
  16. package/dist/update-check.d.ts.map +1 -0
  17. package/dist/update-check.js +85 -0
  18. package/dist/update-check.js.map +1 -0
  19. package/package.json +111 -111
  20. package/src/attachments.ts +652 -652
  21. package/src/bea.ts +372 -372
  22. package/src/bls.ts +1943 -1943
  23. package/src/cache.ts +73 -73
  24. package/src/cbp-border.ts +177 -177
  25. package/src/census-economic.ts +431 -431
  26. package/src/census.ts +735 -735
  27. package/src/ckan.ts +495 -495
  28. package/src/clinicaltrials.ts +923 -923
  29. package/src/cms-facility.ts +379 -379
  30. package/src/cms-hospital.ts +344 -344
  31. package/src/cms-supplier.ts +527 -527
  32. package/src/cms-utilization.ts +389 -389
  33. package/src/cms.ts +634 -634
  34. package/src/coerce.ts +47 -47
  35. package/src/courtlistener.ts +465 -465
  36. package/src/cpsc.ts +333 -333
  37. package/src/datagov-catalog.ts +312 -312
  38. package/src/datagov.ts +907 -907
  39. package/src/datagovKey.ts +68 -68
  40. package/src/datasource.ts +721 -721
  41. package/src/disclosure.ts +61 -61
  42. package/src/dol.ts +515 -515
  43. package/src/ecfr.ts +248 -248
  44. package/src/echo.ts +496 -496
  45. package/src/edgar.ts +3046 -3046
  46. package/src/epa-envirofacts.ts +358 -358
  47. package/src/errors.ts +324 -314
  48. package/src/fac.ts +529 -529
  49. package/src/far.ts +1009 -1009
  50. package/src/fdic.ts +2052 -2052
  51. package/src/federal-register.ts +725 -725
  52. package/src/feedback.ts +160 -0
  53. package/src/fema.ts +680 -680
  54. package/src/fpds.ts +620 -620
  55. package/src/fred.ts +464 -464
  56. package/src/gao.ts +744 -744
  57. package/src/gov-domains.ts +237 -237
  58. package/src/govinfo.ts +497 -497
  59. package/src/grants.ts +290 -290
  60. package/src/gsa-csv.ts +992 -992
  61. package/src/gsa-perdiem.ts +361 -361
  62. package/src/integrity.ts +928 -928
  63. package/src/keys.ts +268 -268
  64. package/src/lda.ts +385 -385
  65. package/src/meta.ts +292 -292
  66. package/src/nhtsa.ts +352 -352
  67. package/src/nih.ts +375 -375
  68. package/src/nist-controls.ts +219 -219
  69. package/src/nonprofit.ts +460 -460
  70. package/src/nppes.ts +834 -834
  71. package/src/nsf.ts +706 -706
  72. package/src/nvd.ts +1124 -1124
  73. package/src/nws-weather.ts +167 -167
  74. package/src/ofac.ts +1166 -1166
  75. package/src/openfda-device.ts +356 -356
  76. package/src/openfda-drugsfda.ts +313 -313
  77. package/src/openfda.ts +518 -518
  78. package/src/pricing.ts +1075 -1075
  79. package/src/sam-gov/client.ts +774 -774
  80. package/src/sam-gov/index.ts +32 -32
  81. package/src/sam-gov/types.ts +152 -152
  82. package/src/sba.ts +357 -357
  83. package/src/server.ts +6692 -6639
  84. package/src/snapshot.ts +223 -223
  85. package/src/socrata.ts +532 -532
  86. package/src/treasury.ts +582 -582
  87. package/src/update-check.ts +88 -0
  88. package/src/usaspending.ts +2852 -2852
  89. package/src/usitc.ts +420 -420
package/src/nhtsa.ts CHANGED
@@ -1,352 +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
- }
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
+ }