@cliwant/mcp-sam-gov 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +279 -67
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +42 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +64 -0
- package/dist/cache.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +92 -17
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +247 -0
- package/dist/errors.js.map +1 -0
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +432 -15
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +114 -11
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3897 -295
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1664 -228
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +73 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +231 -127
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +303 -0
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +706 -191
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +290 -155
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4559 -327
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +2680 -925
- package/src/usitc.ts +420 -0
package/src/fema.ts
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenFEMA — keyless federal disaster declarations + emergency-assistance spend
|
|
3
|
+
* (ADR-0016). A NEW DOMAIN: federal disaster declarations + FEMA Public Assistance
|
|
4
|
+
* grants to state/local/tribal applicants (the SLED emergency-spend / B2G-adjacent
|
|
5
|
+
* angle). Fully PUBLIC, KEYLESS (no key, no token, no header — OpenFEMA is
|
|
6
|
+
* unauthenticated). Nth consumer of the fetch/map/meta shape after treasury /
|
|
7
|
+
* edgar / socrata / ckan.
|
|
8
|
+
* Row query: https://www.fema.gov/api/open/{version}/{EntityName}?{OData params}
|
|
9
|
+
*
|
|
10
|
+
* REUSE (writes ZERO fetch/coercion/error code): `getJson`+`redirect:"error"`
|
|
11
|
+
* (datasource.ts), `num`/`str` (coerce.ts), `driftError` (datasource.ts),
|
|
12
|
+
* `withMeta`/`buildMeta` (meta.ts), the errors.ts taxonomy. COPIES (does NOT
|
|
13
|
+
* import) the socrata/ckan fixed-host SSRF + honesty PATTERN. NEW (small): the
|
|
14
|
+
* OData `$filter` builder (structured args → escaped filter string) + the
|
|
15
|
+
* `FEMA_DATASETS` registry (entityName+version+per-tool field-whitelist pins).
|
|
16
|
+
*
|
|
17
|
+
* ★ THE TOTAL-HONESTY CRUX — `metadata.count` (`$inlinecount=allpages`).
|
|
18
|
+
* OpenFEMA reports the exact FILTERED total in `metadata.count` — a real JSON
|
|
19
|
+
* NUMBER — but ONLY when the query carries `$inlinecount=allpages`. WITHOUT it,
|
|
20
|
+
* `metadata.count` is `0` — a SENTINEL, not the real total (live-verified
|
|
21
|
+
* 2026-07-12: `?$top=1` → count:0; `?$top=1&$inlinecount=allpages` → count:70049).
|
|
22
|
+
* So `getOpenFema` ALWAYS sets `$inlinecount=allpages` (inside the fetch fn, not
|
|
23
|
+
* the caller — no code path can omit it) and reads `metadata.count` as
|
|
24
|
+
* `totalAvailable`. Reading count off a query that omitted inlinecount would
|
|
25
|
+
* report `totalAvailable:0` on a full page — a data-absence-as-zero lie. The count
|
|
26
|
+
* is EXACT (respects the filter), so `hasMore`/`complete` are exact — NO
|
|
27
|
+
* totalIsLowerBound / totalIsEstimated hedge.
|
|
28
|
+
*
|
|
29
|
+
* ★ Entity-keyed envelope (the shape quirk). Results live under `body[EntityName]`
|
|
30
|
+
* (e.g. `body.DisasterDeclarationsSummaries`), NOT a fixed `results`/`data`. The
|
|
31
|
+
* dataset registry carries the entity name, known ahead of the fetch. `metadata`
|
|
32
|
+
* carries the exact filtered `count`.
|
|
33
|
+
*
|
|
34
|
+
* ★ SSRF guard (policy① — mirror socrata.ts / ckan.ts fixed-host). Fixed host
|
|
35
|
+
* `www.fema.gov` + a CURATED dataset registry pinning `{entityName, version}` (no
|
|
36
|
+
* free host, no free path, no free version — HMA's live v2→v4 drift is exactly why
|
|
37
|
+
* version is pinned, never a caller param):
|
|
38
|
+
* 1. `datasetKey` ∈ the frozen `FEMA_DATASETS` registry (belt-and-suspenders
|
|
39
|
+
* recheck in the fetch fn → invalid_input before any fetch);
|
|
40
|
+
* 2. construct `https://www.fema.gov/api/open/${version}/${entityName}?${params}`
|
|
41
|
+
* (host + path segments come ONLY from the pinned registry entry — nothing
|
|
42
|
+
* caller-supplied interpolates into the path), then ASSERT the built URL's
|
|
43
|
+
* hostname === "www.fema.gov" and protocol === "https:" (else invalid_input);
|
|
44
|
+
* 3. OData params via URLSearchParams — `$inlinecount`/`$top`/`$skip` set by the
|
|
45
|
+
* module; `$filter` is MODULE-BUILT from structured args against a per-tool
|
|
46
|
+
* field whitelist (NO raw caller `$filter` — no tool exposes one). String
|
|
47
|
+
* values escape a single-quote by DOUBLING it (`'`→`''`, e.g. `O'Brien` →
|
|
48
|
+
* `'O''Brien'`) then wrap in `'...'`; numbers are unquoted; booleans render
|
|
49
|
+
* `true`/`false`. An un-whitelisted field is rejected (invalid_input) — zero
|
|
50
|
+
* filter-injection surface AND guaranteed-valid, live-verified field names;
|
|
51
|
+
* 4. `redirect:"error"` on every fetch — a direct-JSON endpoint 3xx is anomalous
|
|
52
|
+
* → fail closed, never follow off-host.
|
|
53
|
+
*
|
|
54
|
+
* ★ Silent-filter trap AVOIDED (unlike ECHO). A bad `$filter`/`$orderby` field is
|
|
55
|
+
* NOT silently ignored — OpenFEMA returns HTTP 400 (live-verified). We never
|
|
56
|
+
* present unfiltered-as-filtered. We still module-build `$filter` from a per-tool
|
|
57
|
+
* whitelist (belt-and-suspenders: no 400 in normal use + no injection surface).
|
|
58
|
+
*
|
|
59
|
+
* ★ 200-HTML maintenance-page guard (ADR-0016 OQ1). FEMA's Drupal can serve an
|
|
60
|
+
* "experiencing technical difficulties" HTML page. Observed failures were HTTP 404
|
|
61
|
+
* (→ not_found, thrown before r.json()); but IF an outage returns that page at
|
|
62
|
+
* HTTP 200, getJson's r.json() throws a SyntaxError. `getOpenFema` catches it →
|
|
63
|
+
* `driftError` (never a fake-empty, and a clean classification). Shared getJson
|
|
64
|
+
* stays byte-identical (the shape-drift check stays at the call site).
|
|
65
|
+
*
|
|
66
|
+
* ★ PER-DATASET FIELD NAMES DIFFER — live-verified (ADR-0016 M1). `state eq 'CA'`
|
|
67
|
+
* works on DisasterDeclarationsSummaries (1689) but `stateAbbreviation` → HTTP 400
|
|
68
|
+
* there; conversely `stateAbbreviation eq 'LA'` works on
|
|
69
|
+
* PublicAssistanceFundedProjectsDetails (39444) but `state` → HTTP 400 there. Each
|
|
70
|
+
* tool maps its user-facing `state` arg to ITS dataset's real OData field. Every
|
|
71
|
+
* shipped filter below NARROWS its dataset (live-verified 2026-07-12); a field
|
|
72
|
+
* that 400s or is silently ignored is NOT shipped.
|
|
73
|
+
*
|
|
74
|
+
* HMA v4 (HazardMitigationAssistanceProjects) is a 3rd-tool backlog item (per
|
|
75
|
+
* ADR-0016 §3) — slice 1 ships the 2 core tools.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
79
|
+
import { getJson, driftError } from "./datasource.js";
|
|
80
|
+
import { num } from "./coerce.js";
|
|
81
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
82
|
+
|
|
83
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js —
|
|
84
|
+
// ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
|
|
85
|
+
// `num` from this module (fema.num === coerce.num === socrata.num === ckan.num).
|
|
86
|
+
export { num };
|
|
87
|
+
|
|
88
|
+
// ─── Fixed host + curated dataset registry (SSRF core) ────────────
|
|
89
|
+
export const FEMA_HOST = "www.fema.gov";
|
|
90
|
+
|
|
91
|
+
/** A pinned dataset entry — the single source of truth for entityName + version
|
|
92
|
+
* + the per-tool $filter field whitelist + the amount fields to null-coerce. */
|
|
93
|
+
type FemaDatasetDef = {
|
|
94
|
+
/** The OData EntityName — ALSO the envelope's results key (`body[entityName]`). */
|
|
95
|
+
entityName: string;
|
|
96
|
+
/** The per-dataset API version — PINNED, never a caller param (HMA drift). */
|
|
97
|
+
version: string;
|
|
98
|
+
/** The live-verified $filter field whitelist for THIS dataset (each NARROWS;
|
|
99
|
+
* a bad field → HTTP 400 — see ADR-0016 M1). Belt-and-suspenders: the builder
|
|
100
|
+
* rejects any field not in this set (invalid_input). */
|
|
101
|
+
filterFields: ReadonlySet<string>;
|
|
102
|
+
/** Amount fields to run through `num` (null-never-0): a real 0 stays 0, absent/
|
|
103
|
+
* ""/"null" → null. Empty for datasets with no money columns. */
|
|
104
|
+
amountFields: readonly string[];
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The frozen dataset registry (SSRF core — no free host, no free path, no free
|
|
109
|
+
* version). Each entry's count + fields are LIVE-VERIFIED keyless (2026-07-12).
|
|
110
|
+
* Adding a dataset later = a registry edit + a live
|
|
111
|
+
* `$top=1&$inlinecount=allpages` verify (entity key, version, count) + a test
|
|
112
|
+
* note — NEVER a free runtime param. Re-verify periodically against
|
|
113
|
+
* `/api/open/v1/DataSets` (versions drift: HMA already moved v2→v4).
|
|
114
|
+
*/
|
|
115
|
+
export const FEMA_DATASETS = {
|
|
116
|
+
// SLED emergency spend — 803,904 rows (2026-07-12). stateAbbreviation is THIS
|
|
117
|
+
// dataset's state field (`state` → HTTP 400 here). NO `applicantName` field
|
|
118
|
+
// exists (only applicantId + applicationTitle).
|
|
119
|
+
public_assistance: {
|
|
120
|
+
entityName: "PublicAssistanceFundedProjectsDetails",
|
|
121
|
+
version: "v2",
|
|
122
|
+
filterFields: new Set([
|
|
123
|
+
"stateAbbreviation", // state → 39444 for 'LA'
|
|
124
|
+
"disasterNumber", // → 15 for 3638
|
|
125
|
+
"applicantId", // → 8 for '015-UF5E0-00'
|
|
126
|
+
"damageCategoryCode", // → 232398 for 'B'
|
|
127
|
+
"incidentType", // → 80407 for 'Flood'
|
|
128
|
+
"projectAmount", // ge 1e6 → 25284; le 10000 → 354953
|
|
129
|
+
"declarationDate", // ge '2024-01-01' → 30082
|
|
130
|
+
]),
|
|
131
|
+
amountFields: [
|
|
132
|
+
"projectAmount",
|
|
133
|
+
"federalShareObligated",
|
|
134
|
+
"totalObligated",
|
|
135
|
+
"mitigationAmount",
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
// Declared disasters — 70,049 rows (2026-07-12). `state` is THIS dataset's state
|
|
139
|
+
// field (`stateAbbreviation` → HTTP 400 here). Program flags are BOOLEAN
|
|
140
|
+
// (`eq true`; `eq 1` → HTTP 400).
|
|
141
|
+
disaster_declarations: {
|
|
142
|
+
entityName: "DisasterDeclarationsSummaries",
|
|
143
|
+
version: "v2",
|
|
144
|
+
filterFields: new Set([
|
|
145
|
+
"state", // → 1689 for 'CA'
|
|
146
|
+
"incidentType", // → 11346 for 'Flood'
|
|
147
|
+
"declarationType", // DR 46462 / EM 21471 / FM 2116
|
|
148
|
+
"fyDeclared", // → 2147 for 2024
|
|
149
|
+
"disasterNumber", // → 64 for 4611
|
|
150
|
+
"declarationDate", // ge '2024-01-01' → 5067
|
|
151
|
+
"paProgramDeclared", // eq true → 65534
|
|
152
|
+
"iaProgramDeclared", // eq true → 17187
|
|
153
|
+
]),
|
|
154
|
+
amountFields: [],
|
|
155
|
+
},
|
|
156
|
+
} satisfies Record<string, FemaDatasetDef>;
|
|
157
|
+
|
|
158
|
+
export type FemaDatasetKey = keyof typeof FEMA_DATASETS;
|
|
159
|
+
|
|
160
|
+
const SOURCE_LABEL = "openfema:" + FEMA_HOST;
|
|
161
|
+
|
|
162
|
+
// ─── OData $filter builder (module-built; per-tool whitelist; escaped) ──
|
|
163
|
+
type FilterOp = "eq" | "ge" | "le";
|
|
164
|
+
export type FilterClause =
|
|
165
|
+
| { field: string; op: FilterOp; type: "string"; value: string }
|
|
166
|
+
| { field: string; op: FilterOp; type: "number"; value: number }
|
|
167
|
+
| { field: string; op: FilterOp; type: "boolean"; value: boolean };
|
|
168
|
+
|
|
169
|
+
/** OData string-literal escaping: double every single-quote (`'` → `''`). This is
|
|
170
|
+
* the ONLY escaping OData needs for a `'...'` literal — zero injection surface. */
|
|
171
|
+
export function escapeODataString(v: string): string {
|
|
172
|
+
return v.replace(/'/g, "''");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Render ONE clause's value: strings quoted+escaped, numbers bare, booleans
|
|
176
|
+
* `true`/`false`. */
|
|
177
|
+
function renderValue(c: FilterClause): string {
|
|
178
|
+
switch (c.type) {
|
|
179
|
+
case "string":
|
|
180
|
+
return `'${escapeODataString(c.value)}'`;
|
|
181
|
+
case "number":
|
|
182
|
+
return String(c.value);
|
|
183
|
+
case "boolean":
|
|
184
|
+
return c.value ? "true" : "false";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build the `$filter` string from structured clauses against the dataset's
|
|
190
|
+
* whitelist. Each clause's field MUST be in `FEMA_DATASETS[datasetKey].filterFields`
|
|
191
|
+
* (belt-and-suspenders — the tool functions only ever pass whitelisted fields, but
|
|
192
|
+
* an un-whitelisted field is a hard invalid_input, never silently dropped). Returns
|
|
193
|
+
* null when there are no clauses (⇒ no `$filter` param).
|
|
194
|
+
*/
|
|
195
|
+
export function buildFilter(
|
|
196
|
+
datasetKey: FemaDatasetKey,
|
|
197
|
+
clauses: FilterClause[],
|
|
198
|
+
): string | null {
|
|
199
|
+
if (clauses.length === 0) return null;
|
|
200
|
+
const allowed = FEMA_DATASETS[datasetKey].filterFields;
|
|
201
|
+
return clauses
|
|
202
|
+
.map((c) => {
|
|
203
|
+
if (!allowed.has(c.field)) {
|
|
204
|
+
throw new ToolErrorCarrier({
|
|
205
|
+
kind: "invalid_input",
|
|
206
|
+
message: `OpenFEMA $filter field ${JSON.stringify(c.field)} is not in the ${datasetKey} whitelist — refusing to build an un-whitelisted filter (SSRF/injection safety).`,
|
|
207
|
+
retryable: false,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return `${c.field} ${c.op} ${renderValue(c)}`;
|
|
211
|
+
})
|
|
212
|
+
.join(" and ");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── fetch layer ──────────────────────────────────────────────────
|
|
216
|
+
export type FemaRow = Record<string, unknown>;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* GET one OpenFEMA dataset page. SSRF guard (belt-and-suspenders behind the pinned
|
|
220
|
+
* registry): datasetKey ∈ registry, and the CONSTRUCTED URL's hostname ===
|
|
221
|
+
* www.fema.gov (https). ALWAYS sets `$inlinecount=allpages` (the total-honesty
|
|
222
|
+
* crux — no code path can omit it) + `redirect:"error"` (B1); NO headers (keyless).
|
|
223
|
+
* Reuses errors.ts retry/timeout/taxonomy (429 → rate_limited; 5xx →
|
|
224
|
+
* upstream_unavailable; 404 → not_found; 400 → invalid_input). A 200-non-JSON
|
|
225
|
+
* (Drupal maintenance page) → SyntaxError → driftError (ADR-0016 OQ1). Returns the
|
|
226
|
+
* parsed body (unknown; the caller validates the entity-keyed shape).
|
|
227
|
+
*/
|
|
228
|
+
async function getOpenFema(
|
|
229
|
+
datasetKey: FemaDatasetKey,
|
|
230
|
+
params: URLSearchParams,
|
|
231
|
+
): Promise<{ body: unknown; entityName: string }> {
|
|
232
|
+
const def = FEMA_DATASETS[datasetKey];
|
|
233
|
+
if (!def) {
|
|
234
|
+
throw new ToolErrorCarrier({
|
|
235
|
+
kind: "invalid_input",
|
|
236
|
+
message: `OpenFEMA dataset ${JSON.stringify(datasetKey)} is not in the curated registry. Allowed: ${Object.keys(FEMA_DATASETS).join(", ")}.`,
|
|
237
|
+
retryable: false,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// ★ ALWAYS send $inlinecount=allpages — WITHOUT it metadata.count is a 0
|
|
241
|
+
// sentinel, not the real total (a data-absence-as-zero lie). Set HERE (not the
|
|
242
|
+
// caller) so no code path can omit it. Dropping this line turns a fault test RED.
|
|
243
|
+
params.set("$inlinecount", "allpages");
|
|
244
|
+
|
|
245
|
+
const url = `https://${FEMA_HOST}/api/open/${def.version}/${def.entityName}?${params.toString()}`;
|
|
246
|
+
// Belt-and-suspenders: host + path come ONLY from the pinned registry entry;
|
|
247
|
+
// assert the built URL cannot have been steered off-host.
|
|
248
|
+
const built = new URL(url);
|
|
249
|
+
if (built.hostname !== FEMA_HOST || built.protocol !== "https:") {
|
|
250
|
+
throw new ToolErrorCarrier({
|
|
251
|
+
kind: "invalid_input",
|
|
252
|
+
message: `Constructed OpenFEMA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${FEMA_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
253
|
+
retryable: false,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let body: unknown;
|
|
258
|
+
try {
|
|
259
|
+
// Keyless ⇒ NO headers key (byte-clean init); B1 redirect:"error"; host-only
|
|
260
|
+
// label → ToolError.upstreamEndpoint.
|
|
261
|
+
body = await getJson(url, { label: SOURCE_LABEL, redirect: "error" });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
// Preserve the structured taxonomy (404/429/5xx/400/timeout) unchanged.
|
|
264
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
265
|
+
// ★ OQ1 — a 200 non-JSON body (Drupal "technical difficulties" HTML) makes
|
|
266
|
+
// getJson's r.json() throw a SyntaxError. Classify it as schema_drift (an
|
|
267
|
+
// honest THROW + a clean classification), never a fake-empty. Shared getJson
|
|
268
|
+
// stays byte-identical (the check is at THIS call site).
|
|
269
|
+
if (e instanceof SyntaxError) {
|
|
270
|
+
throw driftError(
|
|
271
|
+
SOURCE_LABEL,
|
|
272
|
+
"OpenFEMA returned non-JSON at HTTP 200 — possible maintenance page",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
277
|
+
return { body, entityName: def.entityName };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── map + meta ───────────────────────────────────────────────────
|
|
281
|
+
const SHAPE_NOTE =
|
|
282
|
+
"OpenFEMA OData envelope: rows live under the entity key (e.g. body.DisasterDeclarationsSummaries) and totalAvailable is the EXACT filtered total from metadata.count (we always send $inlinecount=allpages). Never inferred from the page length.";
|
|
283
|
+
const AMOUNT_NOTE =
|
|
284
|
+
"Amount fields (projectAmount / federalShareObligated / totalObligated / mitigationAmount) are number|null — a real 0 stays 0; absent/empty is null (never 0).";
|
|
285
|
+
const DATE_NOTE =
|
|
286
|
+
"Date filters use OData ISO compares on declarationDate; a bare 'YYYY-MM-DD' means midnight-UTC start of that day (pass a full ISO datetime for finer bounds; a 'to' bound is exclusive of intra-day times after midnight).";
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Coerce the declared amount fields through `num` (null-never-0): a real 0 stays
|
|
290
|
+
* 0; absent/""/"null" → null. Every declared amount field is always present as
|
|
291
|
+
* number|null in the output (honest "unknown", never a fabricated 0). Datasets
|
|
292
|
+
* with no amount fields pass rows through verbatim.
|
|
293
|
+
*/
|
|
294
|
+
function coerceAmounts(
|
|
295
|
+
rows: FemaRow[],
|
|
296
|
+
amountFields: readonly string[],
|
|
297
|
+
): FemaRow[] {
|
|
298
|
+
if (amountFields.length === 0) return rows;
|
|
299
|
+
return rows.map((row) => {
|
|
300
|
+
const out: FemaRow = { ...row };
|
|
301
|
+
for (const f of amountFields) out[f] = num(out[f]);
|
|
302
|
+
return out;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Validate the entity-keyed envelope + build the honest `_meta`. PRIMARY response:
|
|
308
|
+
* - `body[entityName]` MUST be an array → else driftError (nothing valid to return).
|
|
309
|
+
* - `metadata.count` MUST be a number (with $inlinecount it is the exact filtered
|
|
310
|
+
* total) → else driftError. `totalAvailable = num(metadata.count)` — the EXACT
|
|
311
|
+
* total, NEVER the page length (a wide page byte-truncates below $top).
|
|
312
|
+
* - pagination: offset=$skip, limit=$top, hasMore = offset+returned <
|
|
313
|
+
* totalAvailable (exact — no hedge), nextOffset. genuine-empty (count:0, []) →
|
|
314
|
+
* complete:true/0 (via buildMeta). outage/5xx/timeout/400/404 already THREW in
|
|
315
|
+
* getOpenFema (never a fake empty).
|
|
316
|
+
*/
|
|
317
|
+
function shapeResponse(args: {
|
|
318
|
+
body: unknown;
|
|
319
|
+
datasetKey: FemaDatasetKey;
|
|
320
|
+
offset: number;
|
|
321
|
+
limit: number;
|
|
322
|
+
filtersApplied: string[];
|
|
323
|
+
}): MetaBundle {
|
|
324
|
+
const def = FEMA_DATASETS[args.datasetKey];
|
|
325
|
+
const b = (args.body ?? {}) as {
|
|
326
|
+
metadata?: { count?: unknown };
|
|
327
|
+
} & Record<string, unknown>;
|
|
328
|
+
|
|
329
|
+
const rawRows = b[def.entityName];
|
|
330
|
+
if (!Array.isArray(rawRows)) {
|
|
331
|
+
throw driftError(
|
|
332
|
+
SOURCE_LABEL,
|
|
333
|
+
`OpenFEMA ${def.entityName} returned an unexpected shape (body[${JSON.stringify(def.entityName)}] must be an array of rows).`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
// ★ The single most important honesty line: totalAvailable is the EXACT filtered
|
|
337
|
+
// metadata.count, NEVER rows.length. typeof-check BEFORE num() (num cannot tell a
|
|
338
|
+
// non-number from an absent one). Mutating this to rows.length must turn a test RED.
|
|
339
|
+
if (typeof b.metadata?.count !== "number") {
|
|
340
|
+
throw driftError(
|
|
341
|
+
SOURCE_LABEL,
|
|
342
|
+
`OpenFEMA ${def.entityName} returned a non-number metadata.count — with $inlinecount=allpages it must be the exact filtered total; treating as schema drift.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const totalAvailable = num(b.metadata.count);
|
|
346
|
+
|
|
347
|
+
const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
|
|
348
|
+
const returned = rows.length;
|
|
349
|
+
const hasMore =
|
|
350
|
+
totalAvailable !== null && args.offset + returned < totalAvailable;
|
|
351
|
+
const nextOffset = hasMore ? args.offset + returned : null;
|
|
352
|
+
|
|
353
|
+
const notes: string[] = [SHAPE_NOTE];
|
|
354
|
+
if (def.amountFields.length > 0) notes.push(AMOUNT_NOTE);
|
|
355
|
+
if (args.filtersApplied.some((f) => /date/i.test(f))) notes.push(DATE_NOTE);
|
|
356
|
+
// Byte-cap disclosure: a wide page can byte-truncate below $top while more rows
|
|
357
|
+
// remain — metadata.count is authoritative, so page via $skip.
|
|
358
|
+
if (returned < args.limit && hasMore) {
|
|
359
|
+
notes.push(
|
|
360
|
+
"This page returned fewer rows than the requested limit while more remain (OpenFEMA byte-truncates a wide page below $top); metadata.count is authoritative — page with a larger offset ($skip).",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
// Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
|
|
364
|
+
if (args.offset > 100000) {
|
|
365
|
+
notes.push(
|
|
366
|
+
"Deep offset (>100000): very deep $skip into a large dataset may degrade upstream; prefer narrowing the filter over paging deep.",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return withMeta(
|
|
371
|
+
{
|
|
372
|
+
dataset: def.entityName,
|
|
373
|
+
rows,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
source: `OpenFEMA ${def.entityName} (keyless)`,
|
|
377
|
+
keylessMode: true,
|
|
378
|
+
returned,
|
|
379
|
+
totalAvailable,
|
|
380
|
+
filtersApplied: args.filtersApplied,
|
|
381
|
+
filtersDropped: [],
|
|
382
|
+
fieldsUnavailable: [],
|
|
383
|
+
pagination: { offset: args.offset, limit: args.limit, hasMore, nextOffset },
|
|
384
|
+
notes,
|
|
385
|
+
} satisfies Partial<ResponseMeta>,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Tool 1: fema_search_public_assistance ────────────────────────
|
|
390
|
+
/**
|
|
391
|
+
* Search FEMA Public Assistance funded projects (SLED emergency spend to state /
|
|
392
|
+
* local / tribal applicants). Dataset PublicAssistanceFundedProjectsDetails v2.
|
|
393
|
+
* Structured filters → module-built `$filter` (each field LIVE-VERIFIED to narrow):
|
|
394
|
+
* state → stateAbbreviation eq · disasterNumber eq · applicantId eq ·
|
|
395
|
+
* damageCategoryCode eq (e.g. "B" = Emergency Protective Measures) ·
|
|
396
|
+
* incidentType eq · minProjectAmount → projectAmount ge · maxProjectAmount →
|
|
397
|
+
* projectAmount le · declaredDateFrom/To → declarationDate ge/le.
|
|
398
|
+
* Rows carry projectAmount / federalShareObligated / totalObligated /
|
|
399
|
+
* mitigationAmount as number|null. Honest `_meta` (totalAvailable = exact filtered
|
|
400
|
+
* metadata.count).
|
|
401
|
+
*/
|
|
402
|
+
export async function searchPublicAssistance(args: {
|
|
403
|
+
state?: string;
|
|
404
|
+
disasterNumber?: number;
|
|
405
|
+
applicantId?: string;
|
|
406
|
+
damageCategoryCode?: string;
|
|
407
|
+
incidentType?: string;
|
|
408
|
+
minProjectAmount?: number;
|
|
409
|
+
maxProjectAmount?: number;
|
|
410
|
+
declaredDateFrom?: string;
|
|
411
|
+
declaredDateTo?: string;
|
|
412
|
+
limit?: number;
|
|
413
|
+
offset?: number;
|
|
414
|
+
}): Promise<MetaBundle> {
|
|
415
|
+
const limit = args.limit ?? 100;
|
|
416
|
+
const offset = args.offset ?? 0;
|
|
417
|
+
|
|
418
|
+
const clauses: FilterClause[] = [];
|
|
419
|
+
const filtersApplied: string[] = [];
|
|
420
|
+
if (args.state !== undefined) {
|
|
421
|
+
clauses.push({ field: "stateAbbreviation", op: "eq", type: "string", value: args.state });
|
|
422
|
+
filtersApplied.push("state");
|
|
423
|
+
}
|
|
424
|
+
if (args.disasterNumber !== undefined) {
|
|
425
|
+
clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
|
|
426
|
+
filtersApplied.push("disasterNumber");
|
|
427
|
+
}
|
|
428
|
+
if (args.applicantId !== undefined) {
|
|
429
|
+
clauses.push({ field: "applicantId", op: "eq", type: "string", value: args.applicantId });
|
|
430
|
+
filtersApplied.push("applicantId");
|
|
431
|
+
}
|
|
432
|
+
if (args.damageCategoryCode !== undefined) {
|
|
433
|
+
clauses.push({ field: "damageCategoryCode", op: "eq", type: "string", value: args.damageCategoryCode });
|
|
434
|
+
filtersApplied.push("damageCategoryCode");
|
|
435
|
+
}
|
|
436
|
+
if (args.incidentType !== undefined) {
|
|
437
|
+
clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
|
|
438
|
+
filtersApplied.push("incidentType");
|
|
439
|
+
}
|
|
440
|
+
if (args.minProjectAmount !== undefined) {
|
|
441
|
+
clauses.push({ field: "projectAmount", op: "ge", type: "number", value: args.minProjectAmount });
|
|
442
|
+
filtersApplied.push("minProjectAmount");
|
|
443
|
+
}
|
|
444
|
+
if (args.maxProjectAmount !== undefined) {
|
|
445
|
+
clauses.push({ field: "projectAmount", op: "le", type: "number", value: args.maxProjectAmount });
|
|
446
|
+
filtersApplied.push("maxProjectAmount");
|
|
447
|
+
}
|
|
448
|
+
if (args.declaredDateFrom !== undefined) {
|
|
449
|
+
clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
|
|
450
|
+
filtersApplied.push("declaredDateFrom");
|
|
451
|
+
}
|
|
452
|
+
if (args.declaredDateTo !== undefined) {
|
|
453
|
+
clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
|
|
454
|
+
filtersApplied.push("declaredDateTo");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const params = new URLSearchParams();
|
|
458
|
+
params.set("$top", String(limit));
|
|
459
|
+
params.set("$skip", String(offset));
|
|
460
|
+
const filter = buildFilter("public_assistance", clauses);
|
|
461
|
+
if (filter) params.set("$filter", filter);
|
|
462
|
+
|
|
463
|
+
const { body } = await getOpenFema("public_assistance", params);
|
|
464
|
+
return shapeResponse({ body, datasetKey: "public_assistance", offset, limit, filtersApplied });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── Tool 2: fema_disaster_declarations ───────────────────────────
|
|
468
|
+
/**
|
|
469
|
+
* Look up FEMA disaster / emergency declarations. Dataset
|
|
470
|
+
* DisasterDeclarationsSummaries v2. Structured filters → module-built `$filter`
|
|
471
|
+
* (each field LIVE-VERIFIED to narrow):
|
|
472
|
+
* state → state eq · incidentType eq (e.g. "Flood") · declarationType eq
|
|
473
|
+
* (DR/EM/FM) · fyDeclared eq · disasterNumber eq · declaredDateFrom/To →
|
|
474
|
+
* declarationDate ge/le · paProgramDeclared / iaProgramDeclared → eq true/false
|
|
475
|
+
* (BOOLEAN — `eq 1` 400s upstream).
|
|
476
|
+
* Honest `_meta` (totalAvailable = exact filtered metadata.count).
|
|
477
|
+
*/
|
|
478
|
+
export async function disasterDeclarations(args: {
|
|
479
|
+
state?: string;
|
|
480
|
+
incidentType?: string;
|
|
481
|
+
declarationType?: string;
|
|
482
|
+
fyDeclared?: number;
|
|
483
|
+
disasterNumber?: number;
|
|
484
|
+
declaredDateFrom?: string;
|
|
485
|
+
declaredDateTo?: string;
|
|
486
|
+
paProgramDeclared?: boolean;
|
|
487
|
+
iaProgramDeclared?: boolean;
|
|
488
|
+
limit?: number;
|
|
489
|
+
offset?: number;
|
|
490
|
+
}): Promise<MetaBundle> {
|
|
491
|
+
const limit = args.limit ?? 100;
|
|
492
|
+
const offset = args.offset ?? 0;
|
|
493
|
+
|
|
494
|
+
const clauses: FilterClause[] = [];
|
|
495
|
+
const filtersApplied: string[] = [];
|
|
496
|
+
if (args.state !== undefined) {
|
|
497
|
+
clauses.push({ field: "state", op: "eq", type: "string", value: args.state });
|
|
498
|
+
filtersApplied.push("state");
|
|
499
|
+
}
|
|
500
|
+
if (args.incidentType !== undefined) {
|
|
501
|
+
clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
|
|
502
|
+
filtersApplied.push("incidentType");
|
|
503
|
+
}
|
|
504
|
+
if (args.declarationType !== undefined) {
|
|
505
|
+
clauses.push({ field: "declarationType", op: "eq", type: "string", value: args.declarationType });
|
|
506
|
+
filtersApplied.push("declarationType");
|
|
507
|
+
}
|
|
508
|
+
if (args.fyDeclared !== undefined) {
|
|
509
|
+
clauses.push({ field: "fyDeclared", op: "eq", type: "number", value: args.fyDeclared });
|
|
510
|
+
filtersApplied.push("fyDeclared");
|
|
511
|
+
}
|
|
512
|
+
if (args.disasterNumber !== undefined) {
|
|
513
|
+
clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
|
|
514
|
+
filtersApplied.push("disasterNumber");
|
|
515
|
+
}
|
|
516
|
+
if (args.declaredDateFrom !== undefined) {
|
|
517
|
+
clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
|
|
518
|
+
filtersApplied.push("declaredDateFrom");
|
|
519
|
+
}
|
|
520
|
+
if (args.declaredDateTo !== undefined) {
|
|
521
|
+
clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
|
|
522
|
+
filtersApplied.push("declaredDateTo");
|
|
523
|
+
}
|
|
524
|
+
if (args.paProgramDeclared !== undefined) {
|
|
525
|
+
clauses.push({ field: "paProgramDeclared", op: "eq", type: "boolean", value: args.paProgramDeclared });
|
|
526
|
+
filtersApplied.push("paProgramDeclared");
|
|
527
|
+
}
|
|
528
|
+
if (args.iaProgramDeclared !== undefined) {
|
|
529
|
+
clauses.push({ field: "iaProgramDeclared", op: "eq", type: "boolean", value: args.iaProgramDeclared });
|
|
530
|
+
filtersApplied.push("iaProgramDeclared");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const params = new URLSearchParams();
|
|
534
|
+
params.set("$top", String(limit));
|
|
535
|
+
params.set("$skip", String(offset));
|
|
536
|
+
const filter = buildFilter("disaster_declarations", clauses);
|
|
537
|
+
if (filter) params.set("$filter", filter);
|
|
538
|
+
|
|
539
|
+
const { body } = await getOpenFema("disaster_declarations", params);
|
|
540
|
+
return shapeResponse({ body, datasetKey: "disaster_declarations", offset, limit, filtersApplied });
|
|
541
|
+
}
|