@cliwant/mcp-sam-gov 1.4.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.
- package/README.ja.md +11 -11
- package/README.ko.md +11 -11
- package/README.md +21 -13
- package/dist/cbp-border.d.ts +51 -0
- package/dist/cbp-border.d.ts.map +1 -0
- package/dist/cbp-border.js +123 -0
- package/dist/cbp-border.js.map +1 -0
- package/dist/datagov-catalog.d.ts.map +1 -1
- package/dist/datagov-catalog.js +16 -2
- package/dist/datagov-catalog.js.map +1 -1
- package/dist/ecfr.d.ts +2 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +24 -10
- package/dist/ecfr.js.map +1 -1
- package/dist/edgar.d.ts.map +1 -1
- package/dist/edgar.js +26 -6
- package/dist/edgar.js.map +1 -1
- package/dist/epa-envirofacts.d.ts.map +1 -1
- package/dist/epa-envirofacts.js +14 -1
- package/dist/epa-envirofacts.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -1
- package/dist/far.d.ts.map +1 -1
- package/dist/far.js +3 -1
- package/dist/far.js.map +1 -1
- package/dist/federal-register.d.ts +2 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +26 -10
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +36 -0
- package/dist/fema.d.ts.map +1 -1
- package/dist/fema.js +124 -0
- package/dist/fema.js.map +1 -1
- package/dist/gov-domains.d.ts +66 -0
- package/dist/gov-domains.d.ts.map +1 -0
- package/dist/gov-domains.js +211 -0
- package/dist/gov-domains.js.map +1 -0
- package/dist/nist-controls.d.ts +48 -0
- package/dist/nist-controls.d.ts.map +1 -0
- package/dist/nist-controls.js +174 -0
- package/dist/nist-controls.js.map +1 -0
- package/dist/nws-weather.d.ts +57 -0
- package/dist/nws-weather.d.ts.map +1 -0
- package/dist/nws-weather.js +131 -0
- package/dist/nws-weather.js.map +1 -0
- package/dist/openfda-drugsfda.d.ts +72 -0
- package/dist/openfda-drugsfda.d.ts.map +1 -0
- package/dist/openfda-drugsfda.js +230 -0
- package/dist/openfda-drugsfda.js.map +1 -0
- package/dist/openfda.d.ts.map +1 -1
- package/dist/openfda.js +31 -8
- package/dist/openfda.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +331 -10
- package/dist/server.js.map +1 -1
- package/dist/treasury.d.ts +2 -0
- package/dist/treasury.d.ts.map +1 -1
- package/dist/treasury.js +7 -0
- package/dist/treasury.js.map +1 -1
- package/dist/usaspending.d.ts +32 -1
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +143 -16
- package/dist/usaspending.js.map +1 -1
- package/package.json +2 -2
- package/src/cbp-border.ts +177 -0
- package/src/datagov-catalog.ts +18 -2
- package/src/ecfr.ts +27 -10
- package/src/edgar.ts +39 -7
- package/src/epa-envirofacts.ts +17 -1
- package/src/errors.ts +11 -0
- package/src/far.ts +3 -1
- package/src/federal-register.ts +29 -10
- package/src/fema.ts +139 -0
- package/src/gov-domains.ts +237 -0
- package/src/nist-controls.ts +219 -0
- package/src/nws-weather.ts +167 -0
- package/src/openfda-drugsfda.ts +313 -0
- package/src/openfda.ts +30 -7
- package/src/server.ts +352 -10
- package/src/treasury.ts +7 -0
- package/src/usaspending.ts +189 -17
package/src/epa-envirofacts.ts
CHANGED
|
@@ -175,7 +175,16 @@ export type EpaTriFacilitiesArgs = {
|
|
|
175
175
|
|
|
176
176
|
/** Validate + encode ONE user path-segment value (facilityName / county). */
|
|
177
177
|
function validateName(value: string, field: string): string {
|
|
178
|
-
|
|
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
|
+
) {
|
|
179
188
|
throw new ToolErrorCarrier({
|
|
180
189
|
kind: "invalid_input",
|
|
181
190
|
retryable: false,
|
|
@@ -326,11 +335,18 @@ export async function triFacilities(
|
|
|
326
335
|
const notes: string[] = [CLOSED_NOTE, NOMINAL_NOTE];
|
|
327
336
|
if (countFailed) notes.push(COUNT_FALLBACK_NOTE);
|
|
328
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
|
+
|
|
329
344
|
const meta: Partial<ResponseMeta> = {
|
|
330
345
|
source: `${EPA_HOST} EPA Envirofacts /efservice/${EPA_TABLE} (TRI facilities; keyless)`,
|
|
331
346
|
keylessMode: true,
|
|
332
347
|
returned,
|
|
333
348
|
totalAvailable,
|
|
349
|
+
...(cannotProveComplete ? { truncated: true } : {}),
|
|
334
350
|
filtersApplied,
|
|
335
351
|
filtersDropped: [],
|
|
336
352
|
fieldsUnavailable: [],
|
package/src/errors.ts
CHANGED
|
@@ -208,6 +208,17 @@ export async function fetchWithRetry(
|
|
|
208
208
|
e instanceof Error &&
|
|
209
209
|
(e.name === "TimeoutError" || e.name === "AbortError")
|
|
210
210
|
) {
|
|
211
|
+
// HONESTY (dogfooding 2026-07-16): if a PRIOR attempt already classified a
|
|
212
|
+
// real upstream signal — a 429 rate_limit — do NOT mask it as a generic
|
|
213
|
+
// "timed out". The abort here is a DOWNSTREAM artifact of waiting out that
|
|
214
|
+
// rate limit (the retry-after wait outran getJson's AbortSignal, so the
|
|
215
|
+
// next fetch hits the already-aborted signal). Surfacing "timed out" hides
|
|
216
|
+
// the true cause (rate-limited) AND its remedy (wait / supply an API key)
|
|
217
|
+
// and drops the retryable+retryAfterSeconds guidance. Prefer the real
|
|
218
|
+
// rate_limited error. (A pure timeout with no prior 429 keeps "timed out".)
|
|
219
|
+
if (lastErr && lastErr.kind === "rate_limited") {
|
|
220
|
+
throw new ToolErrorCarrier(lastErr);
|
|
221
|
+
}
|
|
211
222
|
throw new ToolErrorCarrier({
|
|
212
223
|
kind: "upstream_unavailable",
|
|
213
224
|
message: `Request to ${endpointLabel} timed out.`,
|
package/src/far.ts
CHANGED
|
@@ -156,7 +156,9 @@ async function title48Currency(): Promise<{
|
|
|
156
156
|
latestAmendedOn: string | null;
|
|
157
157
|
}> {
|
|
158
158
|
return memoize("far:title48-currency", async () => {
|
|
159
|
-
|
|
159
|
+
// listTitles now returns a MetaBundle (honesty envelope); the titles live on
|
|
160
|
+
// `.data.titles`.
|
|
161
|
+
const { titles } = (await listTitles()).data;
|
|
160
162
|
const t48 = titles.find((t) => t.number === 48);
|
|
161
163
|
return {
|
|
162
164
|
upToDateAsOf: t48?.upToDateAsOf ?? null,
|
package/src/federal-register.ts
CHANGED
|
@@ -261,16 +261,35 @@ export async function listAgencies(args: { perPage?: number }) {
|
|
|
261
261
|
const json = await fetchJson<Resp>(
|
|
262
262
|
`${FED_REG}/agencies.json?per_page=${args.perPage ?? 100}`,
|
|
263
263
|
);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
264
|
+
const agencies = (json ?? []).map((a) => ({
|
|
265
|
+
id: a.id ?? 0,
|
|
266
|
+
name: a.name ?? "",
|
|
267
|
+
shortName: a.short_name,
|
|
268
|
+
slug: a.slug ?? "",
|
|
269
|
+
description: a.description ?? "",
|
|
270
|
+
parentId: a.parent_id,
|
|
271
|
+
}));
|
|
272
|
+
// Carry the honesty envelope every other tool has (dogfooding 2026-07-15: this
|
|
273
|
+
// list tool previously returned a bare {agencies} with no _meta). The
|
|
274
|
+
// agencies.json endpoint returns the COMPLETE canonical agency set in one
|
|
275
|
+
// response and IGNORES per_page upstream (live-verified 2026-07-15: per_page
|
|
276
|
+
// 10/100/1000 all return the full 472) — so the count IS the total, perPage is
|
|
277
|
+
// not honored, and complete derives true.
|
|
278
|
+
return withMeta(
|
|
279
|
+
{ agencies },
|
|
280
|
+
{
|
|
281
|
+
source: "federalregister.gov/api/v1 (agencies.json)",
|
|
282
|
+
keylessMode: true,
|
|
283
|
+
returned: agencies.length,
|
|
284
|
+
totalAvailable: agencies.length,
|
|
285
|
+
truncated: false,
|
|
286
|
+
filtersApplied: [],
|
|
287
|
+
filtersDropped: [],
|
|
288
|
+
notes: [
|
|
289
|
+
"Complete canonical list of all Federal Register agencies — the endpoint returns every agency in a single response and IGNORES the perPage argument upstream (the full set is always returned).",
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
);
|
|
274
293
|
});
|
|
275
294
|
}
|
|
276
295
|
|
package/src/fema.ts
CHANGED
|
@@ -88,6 +88,40 @@ export { num };
|
|
|
88
88
|
// ─── Fixed host + curated dataset registry (SSRF core) ────────────
|
|
89
89
|
export const FEMA_HOST = "www.fema.gov";
|
|
90
90
|
|
|
91
|
+
// The HazardMitigationAssistanceProjects dataset filters on the FULL state NAME
|
|
92
|
+
// ('Alabama'), NOT the 2-letter code the SIBLING FEMA tools (PA/declarations) use —
|
|
93
|
+
// so a caller who naturally passes 'AL' (as those tools accept) would get a
|
|
94
|
+
// confidently-wrong empty (total:0). Map a 2-letter USPS code → the canonical FEMA
|
|
95
|
+
// full name so the HMA tool accepts EITHER form; a full name (or an unknown token)
|
|
96
|
+
// passes through unchanged. (Same confidently-wrong-empty class this codebase guards
|
|
97
|
+
// elsewhere — don't ship a new tool with that foot-gun.)
|
|
98
|
+
const US_STATE_ABBR_TO_NAME: Readonly<Record<string, string>> = {
|
|
99
|
+
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", CA: "California",
|
|
100
|
+
CO: "Colorado", CT: "Connecticut", DE: "Delaware", DC: "District of Columbia",
|
|
101
|
+
FL: "Florida", GA: "Georgia", HI: "Hawaii", ID: "Idaho", IL: "Illinois",
|
|
102
|
+
IN: "Indiana", IA: "Iowa", KS: "Kansas", KY: "Kentucky", LA: "Louisiana",
|
|
103
|
+
ME: "Maine", MD: "Maryland", MA: "Massachusetts", MI: "Michigan", MN: "Minnesota",
|
|
104
|
+
MS: "Mississippi", MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
|
|
105
|
+
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
|
|
106
|
+
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma", OR: "Oregon",
|
|
107
|
+
PA: "Pennsylvania", RI: "Rhode Island", SC: "South Carolina", SD: "South Dakota",
|
|
108
|
+
TN: "Tennessee", TX: "Texas", UT: "Utah", VT: "Vermont", VA: "Virginia",
|
|
109
|
+
WA: "Washington", WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
|
|
110
|
+
PR: "Puerto Rico", VI: "Virgin Islands", GU: "Guam", AS: "American Samoa",
|
|
111
|
+
MP: "Northern Mariana Islands",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Resolve a HMA `state` arg: a 2-letter USPS code → the FEMA full name; a full
|
|
115
|
+
* name (or any non-2-letter token) is returned unchanged. */
|
|
116
|
+
export function resolveHmaState(state: string): string {
|
|
117
|
+
const t = state.trim();
|
|
118
|
+
if (/^[A-Za-z]{2}$/.test(t)) {
|
|
119
|
+
const full = US_STATE_ABBR_TO_NAME[t.toUpperCase()];
|
|
120
|
+
if (full) return full;
|
|
121
|
+
}
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
/** A pinned dataset entry — the single source of truth for entityName + version
|
|
92
126
|
* + the per-tool $filter field whitelist + the amount fields to null-coerce. */
|
|
93
127
|
type FemaDatasetDef = {
|
|
@@ -153,6 +187,33 @@ export const FEMA_DATASETS = {
|
|
|
153
187
|
]),
|
|
154
188
|
amountFields: [],
|
|
155
189
|
},
|
|
190
|
+
// Hazard Mitigation Assistance projects — 56,034 rows (LIVE-VERIFIED 2026-07-16).
|
|
191
|
+
// The disaster-RESILIENCE grant axis (HMGP/FMA/PDM/BRIC mitigation grants to
|
|
192
|
+
// state/local/tribal subrecipients) — distinct from PA's disaster-RECOVERY spend.
|
|
193
|
+
// The planned "3rd tool" of ADR-0016 §3. THIS dataset's `state` is the FULL state
|
|
194
|
+
// NAME ('Alabama'; `stateAbbreviation` → HTTP 400). Every field below LIVE-VERIFIED
|
|
195
|
+
// to NARROW (2026-07-16): state 2457/'Alabama', programArea 42657/'HMGP',
|
|
196
|
+
// disasterNumber 330/1605, status 36123/'Closed', programFy 2780/2005, region
|
|
197
|
+
// 15922/4, projectAmount ge 1e6 → 10257.
|
|
198
|
+
hazard_mitigation: {
|
|
199
|
+
entityName: "HazardMitigationAssistanceProjects",
|
|
200
|
+
version: "v4",
|
|
201
|
+
filterFields: new Set([
|
|
202
|
+
"state",
|
|
203
|
+
"programArea",
|
|
204
|
+
"disasterNumber",
|
|
205
|
+
"status",
|
|
206
|
+
"programFy",
|
|
207
|
+
"region",
|
|
208
|
+
"projectAmount",
|
|
209
|
+
]),
|
|
210
|
+
amountFields: [
|
|
211
|
+
"projectAmount",
|
|
212
|
+
"federalShareObligated",
|
|
213
|
+
"initialObligationAmount",
|
|
214
|
+
"netValueBenefits",
|
|
215
|
+
],
|
|
216
|
+
},
|
|
156
217
|
} satisfies Record<string, FemaDatasetDef>;
|
|
157
218
|
|
|
158
219
|
export type FemaDatasetKey = keyof typeof FEMA_DATASETS;
|
|
@@ -539,3 +600,81 @@ export async function disasterDeclarations(args: {
|
|
|
539
600
|
const { body } = await getOpenFema("disaster_declarations", params);
|
|
540
601
|
return shapeResponse({ body, datasetKey: "disaster_declarations", offset, limit, filtersApplied });
|
|
541
602
|
}
|
|
603
|
+
|
|
604
|
+
// ─── Tool 3: fema_search_hazard_mitigation ────────────────────────
|
|
605
|
+
/**
|
|
606
|
+
* Search FEMA Hazard Mitigation Assistance projects (the disaster-RESILIENCE grant
|
|
607
|
+
* axis — HMGP/FMA/PDM/BRIC mitigation grants to state/local/tribal subrecipients,
|
|
608
|
+
* distinct from Public Assistance's disaster-RECOVERY spend). Dataset
|
|
609
|
+
* HazardMitigationAssistanceProjects v4. Structured filters → module-built `$filter`
|
|
610
|
+
* (each field LIVE-VERIFIED to narrow):
|
|
611
|
+
* state → state eq (FULL state NAME, e.g. "Alabama" — NOT the 2-letter code;
|
|
612
|
+
* stateAbbreviation 400s here) · programArea eq (HMGP / FMA / PDM / BRIC / LPDM /
|
|
613
|
+
* FMA-SL) · disasterNumber eq · status eq (e.g. "Closed") · programFy eq ·
|
|
614
|
+
* region eq (FEMA region number 1–10) · minProjectAmount → projectAmount ge ·
|
|
615
|
+
* maxProjectAmount → projectAmount le.
|
|
616
|
+
* Rows carry projectAmount / federalShareObligated / initialObligationAmount /
|
|
617
|
+
* netValueBenefits as number|null. Honest `_meta` (totalAvailable = exact filtered
|
|
618
|
+
* metadata.count).
|
|
619
|
+
*/
|
|
620
|
+
export async function searchHazardMitigation(args: {
|
|
621
|
+
state?: string;
|
|
622
|
+
programArea?: string;
|
|
623
|
+
disasterNumber?: number;
|
|
624
|
+
status?: string;
|
|
625
|
+
programFy?: number;
|
|
626
|
+
region?: number;
|
|
627
|
+
minProjectAmount?: number;
|
|
628
|
+
maxProjectAmount?: number;
|
|
629
|
+
limit?: number;
|
|
630
|
+
offset?: number;
|
|
631
|
+
}): Promise<MetaBundle> {
|
|
632
|
+
const limit = args.limit ?? 100;
|
|
633
|
+
const offset = args.offset ?? 0;
|
|
634
|
+
|
|
635
|
+
const clauses: FilterClause[] = [];
|
|
636
|
+
const filtersApplied: string[] = [];
|
|
637
|
+
if (args.state !== undefined) {
|
|
638
|
+
// Accept a 2-letter code (as the sibling FEMA tools do) OR a full name — HMA's
|
|
639
|
+
// upstream filters on the FULL name, so a bare 'AL' would silently return 0.
|
|
640
|
+
clauses.push({ field: "state", op: "eq", type: "string", value: resolveHmaState(args.state) });
|
|
641
|
+
filtersApplied.push("state");
|
|
642
|
+
}
|
|
643
|
+
if (args.programArea !== undefined) {
|
|
644
|
+
clauses.push({ field: "programArea", op: "eq", type: "string", value: args.programArea });
|
|
645
|
+
filtersApplied.push("programArea");
|
|
646
|
+
}
|
|
647
|
+
if (args.disasterNumber !== undefined) {
|
|
648
|
+
clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
|
|
649
|
+
filtersApplied.push("disasterNumber");
|
|
650
|
+
}
|
|
651
|
+
if (args.status !== undefined) {
|
|
652
|
+
clauses.push({ field: "status", op: "eq", type: "string", value: args.status });
|
|
653
|
+
filtersApplied.push("status");
|
|
654
|
+
}
|
|
655
|
+
if (args.programFy !== undefined) {
|
|
656
|
+
clauses.push({ field: "programFy", op: "eq", type: "number", value: args.programFy });
|
|
657
|
+
filtersApplied.push("programFy");
|
|
658
|
+
}
|
|
659
|
+
if (args.region !== undefined) {
|
|
660
|
+
clauses.push({ field: "region", op: "eq", type: "number", value: args.region });
|
|
661
|
+
filtersApplied.push("region");
|
|
662
|
+
}
|
|
663
|
+
if (args.minProjectAmount !== undefined) {
|
|
664
|
+
clauses.push({ field: "projectAmount", op: "ge", type: "number", value: args.minProjectAmount });
|
|
665
|
+
filtersApplied.push("minProjectAmount");
|
|
666
|
+
}
|
|
667
|
+
if (args.maxProjectAmount !== undefined) {
|
|
668
|
+
clauses.push({ field: "projectAmount", op: "le", type: "number", value: args.maxProjectAmount });
|
|
669
|
+
filtersApplied.push("maxProjectAmount");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const params = new URLSearchParams();
|
|
673
|
+
params.set("$top", String(limit));
|
|
674
|
+
params.set("$skip", String(offset));
|
|
675
|
+
const filter = buildFilter("hazard_mitigation", clauses);
|
|
676
|
+
if (filter) params.set("$filter", filter);
|
|
677
|
+
|
|
678
|
+
const { body } = await getOpenFema("hazard_mitigation", params);
|
|
679
|
+
return shapeResponse({ body, datasetKey: "hazard_mitigation", offset, limit, filtersApplied });
|
|
680
|
+
}
|
|
@@ -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
|
+
}
|