@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/fema.ts CHANGED
@@ -1,680 +1,680 @@
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
- // 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
-
125
- /** A pinned dataset entry — the single source of truth for entityName + version
126
- * + the per-tool $filter field whitelist + the amount fields to null-coerce. */
127
- type FemaDatasetDef = {
128
- /** The OData EntityName — ALSO the envelope's results key (`body[entityName]`). */
129
- entityName: string;
130
- /** The per-dataset API version — PINNED, never a caller param (HMA drift). */
131
- version: string;
132
- /** The live-verified $filter field whitelist for THIS dataset (each NARROWS;
133
- * a bad field → HTTP 400 — see ADR-0016 M1). Belt-and-suspenders: the builder
134
- * rejects any field not in this set (invalid_input). */
135
- filterFields: ReadonlySet<string>;
136
- /** Amount fields to run through `num` (null-never-0): a real 0 stays 0, absent/
137
- * ""/"null" → null. Empty for datasets with no money columns. */
138
- amountFields: readonly string[];
139
- };
140
-
141
- /**
142
- * The frozen dataset registry (SSRF core — no free host, no free path, no free
143
- * version). Each entry's count + fields are LIVE-VERIFIED keyless (2026-07-12).
144
- * Adding a dataset later = a registry edit + a live
145
- * `$top=1&$inlinecount=allpages` verify (entity key, version, count) + a test
146
- * note — NEVER a free runtime param. Re-verify periodically against
147
- * `/api/open/v1/DataSets` (versions drift: HMA already moved v2→v4).
148
- */
149
- export const FEMA_DATASETS = {
150
- // SLED emergency spend — 803,904 rows (2026-07-12). stateAbbreviation is THIS
151
- // dataset's state field (`state` → HTTP 400 here). NO `applicantName` field
152
- // exists (only applicantId + applicationTitle).
153
- public_assistance: {
154
- entityName: "PublicAssistanceFundedProjectsDetails",
155
- version: "v2",
156
- filterFields: new Set([
157
- "stateAbbreviation", // state → 39444 for 'LA'
158
- "disasterNumber", // → 15 for 3638
159
- "applicantId", // → 8 for '015-UF5E0-00'
160
- "damageCategoryCode", // → 232398 for 'B'
161
- "incidentType", // → 80407 for 'Flood'
162
- "projectAmount", // ge 1e6 → 25284; le 10000 → 354953
163
- "declarationDate", // ge '2024-01-01' → 30082
164
- ]),
165
- amountFields: [
166
- "projectAmount",
167
- "federalShareObligated",
168
- "totalObligated",
169
- "mitigationAmount",
170
- ],
171
- },
172
- // Declared disasters — 70,049 rows (2026-07-12). `state` is THIS dataset's state
173
- // field (`stateAbbreviation` → HTTP 400 here). Program flags are BOOLEAN
174
- // (`eq true`; `eq 1` → HTTP 400).
175
- disaster_declarations: {
176
- entityName: "DisasterDeclarationsSummaries",
177
- version: "v2",
178
- filterFields: new Set([
179
- "state", // → 1689 for 'CA'
180
- "incidentType", // → 11346 for 'Flood'
181
- "declarationType", // DR 46462 / EM 21471 / FM 2116
182
- "fyDeclared", // → 2147 for 2024
183
- "disasterNumber", // → 64 for 4611
184
- "declarationDate", // ge '2024-01-01' → 5067
185
- "paProgramDeclared", // eq true → 65534
186
- "iaProgramDeclared", // eq true → 17187
187
- ]),
188
- amountFields: [],
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
- },
217
- } satisfies Record<string, FemaDatasetDef>;
218
-
219
- export type FemaDatasetKey = keyof typeof FEMA_DATASETS;
220
-
221
- const SOURCE_LABEL = "openfema:" + FEMA_HOST;
222
-
223
- // ─── OData $filter builder (module-built; per-tool whitelist; escaped) ──
224
- type FilterOp = "eq" | "ge" | "le";
225
- export type FilterClause =
226
- | { field: string; op: FilterOp; type: "string"; value: string }
227
- | { field: string; op: FilterOp; type: "number"; value: number }
228
- | { field: string; op: FilterOp; type: "boolean"; value: boolean };
229
-
230
- /** OData string-literal escaping: double every single-quote (`'` → `''`). This is
231
- * the ONLY escaping OData needs for a `'...'` literal — zero injection surface. */
232
- export function escapeODataString(v: string): string {
233
- return v.replace(/'/g, "''");
234
- }
235
-
236
- /** Render ONE clause's value: strings quoted+escaped, numbers bare, booleans
237
- * `true`/`false`. */
238
- function renderValue(c: FilterClause): string {
239
- switch (c.type) {
240
- case "string":
241
- return `'${escapeODataString(c.value)}'`;
242
- case "number":
243
- return String(c.value);
244
- case "boolean":
245
- return c.value ? "true" : "false";
246
- }
247
- }
248
-
249
- /**
250
- * Build the `$filter` string from structured clauses against the dataset's
251
- * whitelist. Each clause's field MUST be in `FEMA_DATASETS[datasetKey].filterFields`
252
- * (belt-and-suspenders — the tool functions only ever pass whitelisted fields, but
253
- * an un-whitelisted field is a hard invalid_input, never silently dropped). Returns
254
- * null when there are no clauses (⇒ no `$filter` param).
255
- */
256
- export function buildFilter(
257
- datasetKey: FemaDatasetKey,
258
- clauses: FilterClause[],
259
- ): string | null {
260
- if (clauses.length === 0) return null;
261
- const allowed = FEMA_DATASETS[datasetKey].filterFields;
262
- return clauses
263
- .map((c) => {
264
- if (!allowed.has(c.field)) {
265
- throw new ToolErrorCarrier({
266
- kind: "invalid_input",
267
- message: `OpenFEMA $filter field ${JSON.stringify(c.field)} is not in the ${datasetKey} whitelist — refusing to build an un-whitelisted filter (SSRF/injection safety).`,
268
- retryable: false,
269
- });
270
- }
271
- return `${c.field} ${c.op} ${renderValue(c)}`;
272
- })
273
- .join(" and ");
274
- }
275
-
276
- // ─── fetch layer ──────────────────────────────────────────────────
277
- export type FemaRow = Record<string, unknown>;
278
-
279
- /**
280
- * GET one OpenFEMA dataset page. SSRF guard (belt-and-suspenders behind the pinned
281
- * registry): datasetKey ∈ registry, and the CONSTRUCTED URL's hostname ===
282
- * www.fema.gov (https). ALWAYS sets `$inlinecount=allpages` (the total-honesty
283
- * crux — no code path can omit it) + `redirect:"error"` (B1); NO headers (keyless).
284
- * Reuses errors.ts retry/timeout/taxonomy (429 → rate_limited; 5xx →
285
- * upstream_unavailable; 404 → not_found; 400 → invalid_input). A 200-non-JSON
286
- * (Drupal maintenance page) → SyntaxError → driftError (ADR-0016 OQ1). Returns the
287
- * parsed body (unknown; the caller validates the entity-keyed shape).
288
- */
289
- async function getOpenFema(
290
- datasetKey: FemaDatasetKey,
291
- params: URLSearchParams,
292
- ): Promise<{ body: unknown; entityName: string }> {
293
- const def = FEMA_DATASETS[datasetKey];
294
- if (!def) {
295
- throw new ToolErrorCarrier({
296
- kind: "invalid_input",
297
- message: `OpenFEMA dataset ${JSON.stringify(datasetKey)} is not in the curated registry. Allowed: ${Object.keys(FEMA_DATASETS).join(", ")}.`,
298
- retryable: false,
299
- });
300
- }
301
- // ★ ALWAYS send $inlinecount=allpages — WITHOUT it metadata.count is a 0
302
- // sentinel, not the real total (a data-absence-as-zero lie). Set HERE (not the
303
- // caller) so no code path can omit it. Dropping this line turns a fault test RED.
304
- params.set("$inlinecount", "allpages");
305
-
306
- const url = `https://${FEMA_HOST}/api/open/${def.version}/${def.entityName}?${params.toString()}`;
307
- // Belt-and-suspenders: host + path come ONLY from the pinned registry entry;
308
- // assert the built URL cannot have been steered off-host.
309
- const built = new URL(url);
310
- if (built.hostname !== FEMA_HOST || built.protocol !== "https:") {
311
- throw new ToolErrorCarrier({
312
- kind: "invalid_input",
313
- message: `Constructed OpenFEMA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${FEMA_HOST} over https — refusing to fetch (SSRF safety).`,
314
- retryable: false,
315
- });
316
- }
317
-
318
- let body: unknown;
319
- try {
320
- // Keyless ⇒ NO headers key (byte-clean init); B1 redirect:"error"; host-only
321
- // label → ToolError.upstreamEndpoint.
322
- body = await getJson(url, { label: SOURCE_LABEL, redirect: "error" });
323
- } catch (e) {
324
- // Preserve the structured taxonomy (404/429/5xx/400/timeout) unchanged.
325
- if (e instanceof ToolErrorCarrier) throw e;
326
- // ★ OQ1 — a 200 non-JSON body (Drupal "technical difficulties" HTML) makes
327
- // getJson's r.json() throw a SyntaxError. Classify it as schema_drift (an
328
- // honest THROW + a clean classification), never a fake-empty. Shared getJson
329
- // stays byte-identical (the check is at THIS call site).
330
- if (e instanceof SyntaxError) {
331
- throw driftError(
332
- SOURCE_LABEL,
333
- "OpenFEMA returned non-JSON at HTTP 200 — possible maintenance page",
334
- );
335
- }
336
- throw e;
337
- }
338
- return { body, entityName: def.entityName };
339
- }
340
-
341
- // ─── map + meta ───────────────────────────────────────────────────
342
- const SHAPE_NOTE =
343
- "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.";
344
- const AMOUNT_NOTE =
345
- "Amount fields (projectAmount / federalShareObligated / totalObligated / mitigationAmount) are number|null — a real 0 stays 0; absent/empty is null (never 0).";
346
- const DATE_NOTE =
347
- "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).";
348
-
349
- /**
350
- * Coerce the declared amount fields through `num` (null-never-0): a real 0 stays
351
- * 0; absent/""/"null" → null. Every declared amount field is always present as
352
- * number|null in the output (honest "unknown", never a fabricated 0). Datasets
353
- * with no amount fields pass rows through verbatim.
354
- */
355
- function coerceAmounts(
356
- rows: FemaRow[],
357
- amountFields: readonly string[],
358
- ): FemaRow[] {
359
- if (amountFields.length === 0) return rows;
360
- return rows.map((row) => {
361
- const out: FemaRow = { ...row };
362
- for (const f of amountFields) out[f] = num(out[f]);
363
- return out;
364
- });
365
- }
366
-
367
- /**
368
- * Validate the entity-keyed envelope + build the honest `_meta`. PRIMARY response:
369
- * - `body[entityName]` MUST be an array → else driftError (nothing valid to return).
370
- * - `metadata.count` MUST be a number (with $inlinecount it is the exact filtered
371
- * total) → else driftError. `totalAvailable = num(metadata.count)` — the EXACT
372
- * total, NEVER the page length (a wide page byte-truncates below $top).
373
- * - pagination: offset=$skip, limit=$top, hasMore = offset+returned <
374
- * totalAvailable (exact — no hedge), nextOffset. genuine-empty (count:0, []) →
375
- * complete:true/0 (via buildMeta). outage/5xx/timeout/400/404 already THREW in
376
- * getOpenFema (never a fake empty).
377
- */
378
- function shapeResponse(args: {
379
- body: unknown;
380
- datasetKey: FemaDatasetKey;
381
- offset: number;
382
- limit: number;
383
- filtersApplied: string[];
384
- }): MetaBundle {
385
- const def = FEMA_DATASETS[args.datasetKey];
386
- const b = (args.body ?? {}) as {
387
- metadata?: { count?: unknown };
388
- } & Record<string, unknown>;
389
-
390
- const rawRows = b[def.entityName];
391
- if (!Array.isArray(rawRows)) {
392
- throw driftError(
393
- SOURCE_LABEL,
394
- `OpenFEMA ${def.entityName} returned an unexpected shape (body[${JSON.stringify(def.entityName)}] must be an array of rows).`,
395
- );
396
- }
397
- // ★ The single most important honesty line: totalAvailable is the EXACT filtered
398
- // metadata.count, NEVER rows.length. typeof-check BEFORE num() (num cannot tell a
399
- // non-number from an absent one). Mutating this to rows.length must turn a test RED.
400
- if (typeof b.metadata?.count !== "number") {
401
- throw driftError(
402
- SOURCE_LABEL,
403
- `OpenFEMA ${def.entityName} returned a non-number metadata.count — with $inlinecount=allpages it must be the exact filtered total; treating as schema drift.`,
404
- );
405
- }
406
- const totalAvailable = num(b.metadata.count);
407
-
408
- const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
409
- const returned = rows.length;
410
- const hasMore =
411
- totalAvailable !== null && args.offset + returned < totalAvailable;
412
- const nextOffset = hasMore ? args.offset + returned : null;
413
-
414
- const notes: string[] = [SHAPE_NOTE];
415
- if (def.amountFields.length > 0) notes.push(AMOUNT_NOTE);
416
- if (args.filtersApplied.some((f) => /date/i.test(f))) notes.push(DATE_NOTE);
417
- // Byte-cap disclosure: a wide page can byte-truncate below $top while more rows
418
- // remain — metadata.count is authoritative, so page via $skip.
419
- if (returned < args.limit && hasMore) {
420
- notes.push(
421
- "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).",
422
- );
423
- }
424
- // Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
425
- if (args.offset > 100000) {
426
- notes.push(
427
- "Deep offset (>100000): very deep $skip into a large dataset may degrade upstream; prefer narrowing the filter over paging deep.",
428
- );
429
- }
430
-
431
- return withMeta(
432
- {
433
- dataset: def.entityName,
434
- rows,
435
- },
436
- {
437
- source: `OpenFEMA ${def.entityName} (keyless)`,
438
- keylessMode: true,
439
- returned,
440
- totalAvailable,
441
- filtersApplied: args.filtersApplied,
442
- filtersDropped: [],
443
- fieldsUnavailable: [],
444
- pagination: { offset: args.offset, limit: args.limit, hasMore, nextOffset },
445
- notes,
446
- } satisfies Partial<ResponseMeta>,
447
- );
448
- }
449
-
450
- // ─── Tool 1: fema_search_public_assistance ────────────────────────
451
- /**
452
- * Search FEMA Public Assistance funded projects (SLED emergency spend to state /
453
- * local / tribal applicants). Dataset PublicAssistanceFundedProjectsDetails v2.
454
- * Structured filters → module-built `$filter` (each field LIVE-VERIFIED to narrow):
455
- * state → stateAbbreviation eq · disasterNumber eq · applicantId eq ·
456
- * damageCategoryCode eq (e.g. "B" = Emergency Protective Measures) ·
457
- * incidentType eq · minProjectAmount → projectAmount ge · maxProjectAmount →
458
- * projectAmount le · declaredDateFrom/To → declarationDate ge/le.
459
- * Rows carry projectAmount / federalShareObligated / totalObligated /
460
- * mitigationAmount as number|null. Honest `_meta` (totalAvailable = exact filtered
461
- * metadata.count).
462
- */
463
- export async function searchPublicAssistance(args: {
464
- state?: string;
465
- disasterNumber?: number;
466
- applicantId?: string;
467
- damageCategoryCode?: string;
468
- incidentType?: string;
469
- minProjectAmount?: number;
470
- maxProjectAmount?: number;
471
- declaredDateFrom?: string;
472
- declaredDateTo?: string;
473
- limit?: number;
474
- offset?: number;
475
- }): Promise<MetaBundle> {
476
- const limit = args.limit ?? 100;
477
- const offset = args.offset ?? 0;
478
-
479
- const clauses: FilterClause[] = [];
480
- const filtersApplied: string[] = [];
481
- if (args.state !== undefined) {
482
- clauses.push({ field: "stateAbbreviation", op: "eq", type: "string", value: args.state });
483
- filtersApplied.push("state");
484
- }
485
- if (args.disasterNumber !== undefined) {
486
- clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
487
- filtersApplied.push("disasterNumber");
488
- }
489
- if (args.applicantId !== undefined) {
490
- clauses.push({ field: "applicantId", op: "eq", type: "string", value: args.applicantId });
491
- filtersApplied.push("applicantId");
492
- }
493
- if (args.damageCategoryCode !== undefined) {
494
- clauses.push({ field: "damageCategoryCode", op: "eq", type: "string", value: args.damageCategoryCode });
495
- filtersApplied.push("damageCategoryCode");
496
- }
497
- if (args.incidentType !== undefined) {
498
- clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
499
- filtersApplied.push("incidentType");
500
- }
501
- if (args.minProjectAmount !== undefined) {
502
- clauses.push({ field: "projectAmount", op: "ge", type: "number", value: args.minProjectAmount });
503
- filtersApplied.push("minProjectAmount");
504
- }
505
- if (args.maxProjectAmount !== undefined) {
506
- clauses.push({ field: "projectAmount", op: "le", type: "number", value: args.maxProjectAmount });
507
- filtersApplied.push("maxProjectAmount");
508
- }
509
- if (args.declaredDateFrom !== undefined) {
510
- clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
511
- filtersApplied.push("declaredDateFrom");
512
- }
513
- if (args.declaredDateTo !== undefined) {
514
- clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
515
- filtersApplied.push("declaredDateTo");
516
- }
517
-
518
- const params = new URLSearchParams();
519
- params.set("$top", String(limit));
520
- params.set("$skip", String(offset));
521
- const filter = buildFilter("public_assistance", clauses);
522
- if (filter) params.set("$filter", filter);
523
-
524
- const { body } = await getOpenFema("public_assistance", params);
525
- return shapeResponse({ body, datasetKey: "public_assistance", offset, limit, filtersApplied });
526
- }
527
-
528
- // ─── Tool 2: fema_disaster_declarations ───────────────────────────
529
- /**
530
- * Look up FEMA disaster / emergency declarations. Dataset
531
- * DisasterDeclarationsSummaries v2. Structured filters → module-built `$filter`
532
- * (each field LIVE-VERIFIED to narrow):
533
- * state → state eq · incidentType eq (e.g. "Flood") · declarationType eq
534
- * (DR/EM/FM) · fyDeclared eq · disasterNumber eq · declaredDateFrom/To →
535
- * declarationDate ge/le · paProgramDeclared / iaProgramDeclared → eq true/false
536
- * (BOOLEAN — `eq 1` 400s upstream).
537
- * Honest `_meta` (totalAvailable = exact filtered metadata.count).
538
- */
539
- export async function disasterDeclarations(args: {
540
- state?: string;
541
- incidentType?: string;
542
- declarationType?: string;
543
- fyDeclared?: number;
544
- disasterNumber?: number;
545
- declaredDateFrom?: string;
546
- declaredDateTo?: string;
547
- paProgramDeclared?: boolean;
548
- iaProgramDeclared?: boolean;
549
- limit?: number;
550
- offset?: number;
551
- }): Promise<MetaBundle> {
552
- const limit = args.limit ?? 100;
553
- const offset = args.offset ?? 0;
554
-
555
- const clauses: FilterClause[] = [];
556
- const filtersApplied: string[] = [];
557
- if (args.state !== undefined) {
558
- clauses.push({ field: "state", op: "eq", type: "string", value: args.state });
559
- filtersApplied.push("state");
560
- }
561
- if (args.incidentType !== undefined) {
562
- clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
563
- filtersApplied.push("incidentType");
564
- }
565
- if (args.declarationType !== undefined) {
566
- clauses.push({ field: "declarationType", op: "eq", type: "string", value: args.declarationType });
567
- filtersApplied.push("declarationType");
568
- }
569
- if (args.fyDeclared !== undefined) {
570
- clauses.push({ field: "fyDeclared", op: "eq", type: "number", value: args.fyDeclared });
571
- filtersApplied.push("fyDeclared");
572
- }
573
- if (args.disasterNumber !== undefined) {
574
- clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
575
- filtersApplied.push("disasterNumber");
576
- }
577
- if (args.declaredDateFrom !== undefined) {
578
- clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
579
- filtersApplied.push("declaredDateFrom");
580
- }
581
- if (args.declaredDateTo !== undefined) {
582
- clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
583
- filtersApplied.push("declaredDateTo");
584
- }
585
- if (args.paProgramDeclared !== undefined) {
586
- clauses.push({ field: "paProgramDeclared", op: "eq", type: "boolean", value: args.paProgramDeclared });
587
- filtersApplied.push("paProgramDeclared");
588
- }
589
- if (args.iaProgramDeclared !== undefined) {
590
- clauses.push({ field: "iaProgramDeclared", op: "eq", type: "boolean", value: args.iaProgramDeclared });
591
- filtersApplied.push("iaProgramDeclared");
592
- }
593
-
594
- const params = new URLSearchParams();
595
- params.set("$top", String(limit));
596
- params.set("$skip", String(offset));
597
- const filter = buildFilter("disaster_declarations", clauses);
598
- if (filter) params.set("$filter", filter);
599
-
600
- const { body } = await getOpenFema("disaster_declarations", params);
601
- return shapeResponse({ body, datasetKey: "disaster_declarations", offset, limit, filtersApplied });
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
- }
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
+ // 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
+
125
+ /** A pinned dataset entry — the single source of truth for entityName + version
126
+ * + the per-tool $filter field whitelist + the amount fields to null-coerce. */
127
+ type FemaDatasetDef = {
128
+ /** The OData EntityName — ALSO the envelope's results key (`body[entityName]`). */
129
+ entityName: string;
130
+ /** The per-dataset API version — PINNED, never a caller param (HMA drift). */
131
+ version: string;
132
+ /** The live-verified $filter field whitelist for THIS dataset (each NARROWS;
133
+ * a bad field → HTTP 400 — see ADR-0016 M1). Belt-and-suspenders: the builder
134
+ * rejects any field not in this set (invalid_input). */
135
+ filterFields: ReadonlySet<string>;
136
+ /** Amount fields to run through `num` (null-never-0): a real 0 stays 0, absent/
137
+ * ""/"null" → null. Empty for datasets with no money columns. */
138
+ amountFields: readonly string[];
139
+ };
140
+
141
+ /**
142
+ * The frozen dataset registry (SSRF core — no free host, no free path, no free
143
+ * version). Each entry's count + fields are LIVE-VERIFIED keyless (2026-07-12).
144
+ * Adding a dataset later = a registry edit + a live
145
+ * `$top=1&$inlinecount=allpages` verify (entity key, version, count) + a test
146
+ * note — NEVER a free runtime param. Re-verify periodically against
147
+ * `/api/open/v1/DataSets` (versions drift: HMA already moved v2→v4).
148
+ */
149
+ export const FEMA_DATASETS = {
150
+ // SLED emergency spend — 803,904 rows (2026-07-12). stateAbbreviation is THIS
151
+ // dataset's state field (`state` → HTTP 400 here). NO `applicantName` field
152
+ // exists (only applicantId + applicationTitle).
153
+ public_assistance: {
154
+ entityName: "PublicAssistanceFundedProjectsDetails",
155
+ version: "v2",
156
+ filterFields: new Set([
157
+ "stateAbbreviation", // state → 39444 for 'LA'
158
+ "disasterNumber", // → 15 for 3638
159
+ "applicantId", // → 8 for '015-UF5E0-00'
160
+ "damageCategoryCode", // → 232398 for 'B'
161
+ "incidentType", // → 80407 for 'Flood'
162
+ "projectAmount", // ge 1e6 → 25284; le 10000 → 354953
163
+ "declarationDate", // ge '2024-01-01' → 30082
164
+ ]),
165
+ amountFields: [
166
+ "projectAmount",
167
+ "federalShareObligated",
168
+ "totalObligated",
169
+ "mitigationAmount",
170
+ ],
171
+ },
172
+ // Declared disasters — 70,049 rows (2026-07-12). `state` is THIS dataset's state
173
+ // field (`stateAbbreviation` → HTTP 400 here). Program flags are BOOLEAN
174
+ // (`eq true`; `eq 1` → HTTP 400).
175
+ disaster_declarations: {
176
+ entityName: "DisasterDeclarationsSummaries",
177
+ version: "v2",
178
+ filterFields: new Set([
179
+ "state", // → 1689 for 'CA'
180
+ "incidentType", // → 11346 for 'Flood'
181
+ "declarationType", // DR 46462 / EM 21471 / FM 2116
182
+ "fyDeclared", // → 2147 for 2024
183
+ "disasterNumber", // → 64 for 4611
184
+ "declarationDate", // ge '2024-01-01' → 5067
185
+ "paProgramDeclared", // eq true → 65534
186
+ "iaProgramDeclared", // eq true → 17187
187
+ ]),
188
+ amountFields: [],
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
+ },
217
+ } satisfies Record<string, FemaDatasetDef>;
218
+
219
+ export type FemaDatasetKey = keyof typeof FEMA_DATASETS;
220
+
221
+ const SOURCE_LABEL = "openfema:" + FEMA_HOST;
222
+
223
+ // ─── OData $filter builder (module-built; per-tool whitelist; escaped) ──
224
+ type FilterOp = "eq" | "ge" | "le";
225
+ export type FilterClause =
226
+ | { field: string; op: FilterOp; type: "string"; value: string }
227
+ | { field: string; op: FilterOp; type: "number"; value: number }
228
+ | { field: string; op: FilterOp; type: "boolean"; value: boolean };
229
+
230
+ /** OData string-literal escaping: double every single-quote (`'` → `''`). This is
231
+ * the ONLY escaping OData needs for a `'...'` literal — zero injection surface. */
232
+ export function escapeODataString(v: string): string {
233
+ return v.replace(/'/g, "''");
234
+ }
235
+
236
+ /** Render ONE clause's value: strings quoted+escaped, numbers bare, booleans
237
+ * `true`/`false`. */
238
+ function renderValue(c: FilterClause): string {
239
+ switch (c.type) {
240
+ case "string":
241
+ return `'${escapeODataString(c.value)}'`;
242
+ case "number":
243
+ return String(c.value);
244
+ case "boolean":
245
+ return c.value ? "true" : "false";
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Build the `$filter` string from structured clauses against the dataset's
251
+ * whitelist. Each clause's field MUST be in `FEMA_DATASETS[datasetKey].filterFields`
252
+ * (belt-and-suspenders — the tool functions only ever pass whitelisted fields, but
253
+ * an un-whitelisted field is a hard invalid_input, never silently dropped). Returns
254
+ * null when there are no clauses (⇒ no `$filter` param).
255
+ */
256
+ export function buildFilter(
257
+ datasetKey: FemaDatasetKey,
258
+ clauses: FilterClause[],
259
+ ): string | null {
260
+ if (clauses.length === 0) return null;
261
+ const allowed = FEMA_DATASETS[datasetKey].filterFields;
262
+ return clauses
263
+ .map((c) => {
264
+ if (!allowed.has(c.field)) {
265
+ throw new ToolErrorCarrier({
266
+ kind: "invalid_input",
267
+ message: `OpenFEMA $filter field ${JSON.stringify(c.field)} is not in the ${datasetKey} whitelist — refusing to build an un-whitelisted filter (SSRF/injection safety).`,
268
+ retryable: false,
269
+ });
270
+ }
271
+ return `${c.field} ${c.op} ${renderValue(c)}`;
272
+ })
273
+ .join(" and ");
274
+ }
275
+
276
+ // ─── fetch layer ──────────────────────────────────────────────────
277
+ export type FemaRow = Record<string, unknown>;
278
+
279
+ /**
280
+ * GET one OpenFEMA dataset page. SSRF guard (belt-and-suspenders behind the pinned
281
+ * registry): datasetKey ∈ registry, and the CONSTRUCTED URL's hostname ===
282
+ * www.fema.gov (https). ALWAYS sets `$inlinecount=allpages` (the total-honesty
283
+ * crux — no code path can omit it) + `redirect:"error"` (B1); NO headers (keyless).
284
+ * Reuses errors.ts retry/timeout/taxonomy (429 → rate_limited; 5xx →
285
+ * upstream_unavailable; 404 → not_found; 400 → invalid_input). A 200-non-JSON
286
+ * (Drupal maintenance page) → SyntaxError → driftError (ADR-0016 OQ1). Returns the
287
+ * parsed body (unknown; the caller validates the entity-keyed shape).
288
+ */
289
+ async function getOpenFema(
290
+ datasetKey: FemaDatasetKey,
291
+ params: URLSearchParams,
292
+ ): Promise<{ body: unknown; entityName: string }> {
293
+ const def = FEMA_DATASETS[datasetKey];
294
+ if (!def) {
295
+ throw new ToolErrorCarrier({
296
+ kind: "invalid_input",
297
+ message: `OpenFEMA dataset ${JSON.stringify(datasetKey)} is not in the curated registry. Allowed: ${Object.keys(FEMA_DATASETS).join(", ")}.`,
298
+ retryable: false,
299
+ });
300
+ }
301
+ // ★ ALWAYS send $inlinecount=allpages — WITHOUT it metadata.count is a 0
302
+ // sentinel, not the real total (a data-absence-as-zero lie). Set HERE (not the
303
+ // caller) so no code path can omit it. Dropping this line turns a fault test RED.
304
+ params.set("$inlinecount", "allpages");
305
+
306
+ const url = `https://${FEMA_HOST}/api/open/${def.version}/${def.entityName}?${params.toString()}`;
307
+ // Belt-and-suspenders: host + path come ONLY from the pinned registry entry;
308
+ // assert the built URL cannot have been steered off-host.
309
+ const built = new URL(url);
310
+ if (built.hostname !== FEMA_HOST || built.protocol !== "https:") {
311
+ throw new ToolErrorCarrier({
312
+ kind: "invalid_input",
313
+ message: `Constructed OpenFEMA URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${FEMA_HOST} over https — refusing to fetch (SSRF safety).`,
314
+ retryable: false,
315
+ });
316
+ }
317
+
318
+ let body: unknown;
319
+ try {
320
+ // Keyless ⇒ NO headers key (byte-clean init); B1 redirect:"error"; host-only
321
+ // label → ToolError.upstreamEndpoint.
322
+ body = await getJson(url, { label: SOURCE_LABEL, redirect: "error" });
323
+ } catch (e) {
324
+ // Preserve the structured taxonomy (404/429/5xx/400/timeout) unchanged.
325
+ if (e instanceof ToolErrorCarrier) throw e;
326
+ // ★ OQ1 — a 200 non-JSON body (Drupal "technical difficulties" HTML) makes
327
+ // getJson's r.json() throw a SyntaxError. Classify it as schema_drift (an
328
+ // honest THROW + a clean classification), never a fake-empty. Shared getJson
329
+ // stays byte-identical (the check is at THIS call site).
330
+ if (e instanceof SyntaxError) {
331
+ throw driftError(
332
+ SOURCE_LABEL,
333
+ "OpenFEMA returned non-JSON at HTTP 200 — possible maintenance page",
334
+ );
335
+ }
336
+ throw e;
337
+ }
338
+ return { body, entityName: def.entityName };
339
+ }
340
+
341
+ // ─── map + meta ───────────────────────────────────────────────────
342
+ const SHAPE_NOTE =
343
+ "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.";
344
+ const AMOUNT_NOTE =
345
+ "Amount fields (projectAmount / federalShareObligated / totalObligated / mitigationAmount) are number|null — a real 0 stays 0; absent/empty is null (never 0).";
346
+ const DATE_NOTE =
347
+ "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).";
348
+
349
+ /**
350
+ * Coerce the declared amount fields through `num` (null-never-0): a real 0 stays
351
+ * 0; absent/""/"null" → null. Every declared amount field is always present as
352
+ * number|null in the output (honest "unknown", never a fabricated 0). Datasets
353
+ * with no amount fields pass rows through verbatim.
354
+ */
355
+ function coerceAmounts(
356
+ rows: FemaRow[],
357
+ amountFields: readonly string[],
358
+ ): FemaRow[] {
359
+ if (amountFields.length === 0) return rows;
360
+ return rows.map((row) => {
361
+ const out: FemaRow = { ...row };
362
+ for (const f of amountFields) out[f] = num(out[f]);
363
+ return out;
364
+ });
365
+ }
366
+
367
+ /**
368
+ * Validate the entity-keyed envelope + build the honest `_meta`. PRIMARY response:
369
+ * - `body[entityName]` MUST be an array → else driftError (nothing valid to return).
370
+ * - `metadata.count` MUST be a number (with $inlinecount it is the exact filtered
371
+ * total) → else driftError. `totalAvailable = num(metadata.count)` — the EXACT
372
+ * total, NEVER the page length (a wide page byte-truncates below $top).
373
+ * - pagination: offset=$skip, limit=$top, hasMore = offset+returned <
374
+ * totalAvailable (exact — no hedge), nextOffset. genuine-empty (count:0, []) →
375
+ * complete:true/0 (via buildMeta). outage/5xx/timeout/400/404 already THREW in
376
+ * getOpenFema (never a fake empty).
377
+ */
378
+ function shapeResponse(args: {
379
+ body: unknown;
380
+ datasetKey: FemaDatasetKey;
381
+ offset: number;
382
+ limit: number;
383
+ filtersApplied: string[];
384
+ }): MetaBundle {
385
+ const def = FEMA_DATASETS[args.datasetKey];
386
+ const b = (args.body ?? {}) as {
387
+ metadata?: { count?: unknown };
388
+ } & Record<string, unknown>;
389
+
390
+ const rawRows = b[def.entityName];
391
+ if (!Array.isArray(rawRows)) {
392
+ throw driftError(
393
+ SOURCE_LABEL,
394
+ `OpenFEMA ${def.entityName} returned an unexpected shape (body[${JSON.stringify(def.entityName)}] must be an array of rows).`,
395
+ );
396
+ }
397
+ // ★ The single most important honesty line: totalAvailable is the EXACT filtered
398
+ // metadata.count, NEVER rows.length. typeof-check BEFORE num() (num cannot tell a
399
+ // non-number from an absent one). Mutating this to rows.length must turn a test RED.
400
+ if (typeof b.metadata?.count !== "number") {
401
+ throw driftError(
402
+ SOURCE_LABEL,
403
+ `OpenFEMA ${def.entityName} returned a non-number metadata.count — with $inlinecount=allpages it must be the exact filtered total; treating as schema drift.`,
404
+ );
405
+ }
406
+ const totalAvailable = num(b.metadata.count);
407
+
408
+ const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
409
+ const returned = rows.length;
410
+ const hasMore =
411
+ totalAvailable !== null && args.offset + returned < totalAvailable;
412
+ const nextOffset = hasMore ? args.offset + returned : null;
413
+
414
+ const notes: string[] = [SHAPE_NOTE];
415
+ if (def.amountFields.length > 0) notes.push(AMOUNT_NOTE);
416
+ if (args.filtersApplied.some((f) => /date/i.test(f))) notes.push(DATE_NOTE);
417
+ // Byte-cap disclosure: a wide page can byte-truncate below $top while more rows
418
+ // remain — metadata.count is authoritative, so page via $skip.
419
+ if (returned < args.limit && hasMore) {
420
+ notes.push(
421
+ "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).",
422
+ );
423
+ }
424
+ // Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
425
+ if (args.offset > 100000) {
426
+ notes.push(
427
+ "Deep offset (>100000): very deep $skip into a large dataset may degrade upstream; prefer narrowing the filter over paging deep.",
428
+ );
429
+ }
430
+
431
+ return withMeta(
432
+ {
433
+ dataset: def.entityName,
434
+ rows,
435
+ },
436
+ {
437
+ source: `OpenFEMA ${def.entityName} (keyless)`,
438
+ keylessMode: true,
439
+ returned,
440
+ totalAvailable,
441
+ filtersApplied: args.filtersApplied,
442
+ filtersDropped: [],
443
+ fieldsUnavailable: [],
444
+ pagination: { offset: args.offset, limit: args.limit, hasMore, nextOffset },
445
+ notes,
446
+ } satisfies Partial<ResponseMeta>,
447
+ );
448
+ }
449
+
450
+ // ─── Tool 1: fema_search_public_assistance ────────────────────────
451
+ /**
452
+ * Search FEMA Public Assistance funded projects (SLED emergency spend to state /
453
+ * local / tribal applicants). Dataset PublicAssistanceFundedProjectsDetails v2.
454
+ * Structured filters → module-built `$filter` (each field LIVE-VERIFIED to narrow):
455
+ * state → stateAbbreviation eq · disasterNumber eq · applicantId eq ·
456
+ * damageCategoryCode eq (e.g. "B" = Emergency Protective Measures) ·
457
+ * incidentType eq · minProjectAmount → projectAmount ge · maxProjectAmount →
458
+ * projectAmount le · declaredDateFrom/To → declarationDate ge/le.
459
+ * Rows carry projectAmount / federalShareObligated / totalObligated /
460
+ * mitigationAmount as number|null. Honest `_meta` (totalAvailable = exact filtered
461
+ * metadata.count).
462
+ */
463
+ export async function searchPublicAssistance(args: {
464
+ state?: string;
465
+ disasterNumber?: number;
466
+ applicantId?: string;
467
+ damageCategoryCode?: string;
468
+ incidentType?: string;
469
+ minProjectAmount?: number;
470
+ maxProjectAmount?: number;
471
+ declaredDateFrom?: string;
472
+ declaredDateTo?: string;
473
+ limit?: number;
474
+ offset?: number;
475
+ }): Promise<MetaBundle> {
476
+ const limit = args.limit ?? 100;
477
+ const offset = args.offset ?? 0;
478
+
479
+ const clauses: FilterClause[] = [];
480
+ const filtersApplied: string[] = [];
481
+ if (args.state !== undefined) {
482
+ clauses.push({ field: "stateAbbreviation", op: "eq", type: "string", value: args.state });
483
+ filtersApplied.push("state");
484
+ }
485
+ if (args.disasterNumber !== undefined) {
486
+ clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
487
+ filtersApplied.push("disasterNumber");
488
+ }
489
+ if (args.applicantId !== undefined) {
490
+ clauses.push({ field: "applicantId", op: "eq", type: "string", value: args.applicantId });
491
+ filtersApplied.push("applicantId");
492
+ }
493
+ if (args.damageCategoryCode !== undefined) {
494
+ clauses.push({ field: "damageCategoryCode", op: "eq", type: "string", value: args.damageCategoryCode });
495
+ filtersApplied.push("damageCategoryCode");
496
+ }
497
+ if (args.incidentType !== undefined) {
498
+ clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
499
+ filtersApplied.push("incidentType");
500
+ }
501
+ if (args.minProjectAmount !== undefined) {
502
+ clauses.push({ field: "projectAmount", op: "ge", type: "number", value: args.minProjectAmount });
503
+ filtersApplied.push("minProjectAmount");
504
+ }
505
+ if (args.maxProjectAmount !== undefined) {
506
+ clauses.push({ field: "projectAmount", op: "le", type: "number", value: args.maxProjectAmount });
507
+ filtersApplied.push("maxProjectAmount");
508
+ }
509
+ if (args.declaredDateFrom !== undefined) {
510
+ clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
511
+ filtersApplied.push("declaredDateFrom");
512
+ }
513
+ if (args.declaredDateTo !== undefined) {
514
+ clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
515
+ filtersApplied.push("declaredDateTo");
516
+ }
517
+
518
+ const params = new URLSearchParams();
519
+ params.set("$top", String(limit));
520
+ params.set("$skip", String(offset));
521
+ const filter = buildFilter("public_assistance", clauses);
522
+ if (filter) params.set("$filter", filter);
523
+
524
+ const { body } = await getOpenFema("public_assistance", params);
525
+ return shapeResponse({ body, datasetKey: "public_assistance", offset, limit, filtersApplied });
526
+ }
527
+
528
+ // ─── Tool 2: fema_disaster_declarations ───────────────────────────
529
+ /**
530
+ * Look up FEMA disaster / emergency declarations. Dataset
531
+ * DisasterDeclarationsSummaries v2. Structured filters → module-built `$filter`
532
+ * (each field LIVE-VERIFIED to narrow):
533
+ * state → state eq · incidentType eq (e.g. "Flood") · declarationType eq
534
+ * (DR/EM/FM) · fyDeclared eq · disasterNumber eq · declaredDateFrom/To →
535
+ * declarationDate ge/le · paProgramDeclared / iaProgramDeclared → eq true/false
536
+ * (BOOLEAN — `eq 1` 400s upstream).
537
+ * Honest `_meta` (totalAvailable = exact filtered metadata.count).
538
+ */
539
+ export async function disasterDeclarations(args: {
540
+ state?: string;
541
+ incidentType?: string;
542
+ declarationType?: string;
543
+ fyDeclared?: number;
544
+ disasterNumber?: number;
545
+ declaredDateFrom?: string;
546
+ declaredDateTo?: string;
547
+ paProgramDeclared?: boolean;
548
+ iaProgramDeclared?: boolean;
549
+ limit?: number;
550
+ offset?: number;
551
+ }): Promise<MetaBundle> {
552
+ const limit = args.limit ?? 100;
553
+ const offset = args.offset ?? 0;
554
+
555
+ const clauses: FilterClause[] = [];
556
+ const filtersApplied: string[] = [];
557
+ if (args.state !== undefined) {
558
+ clauses.push({ field: "state", op: "eq", type: "string", value: args.state });
559
+ filtersApplied.push("state");
560
+ }
561
+ if (args.incidentType !== undefined) {
562
+ clauses.push({ field: "incidentType", op: "eq", type: "string", value: args.incidentType });
563
+ filtersApplied.push("incidentType");
564
+ }
565
+ if (args.declarationType !== undefined) {
566
+ clauses.push({ field: "declarationType", op: "eq", type: "string", value: args.declarationType });
567
+ filtersApplied.push("declarationType");
568
+ }
569
+ if (args.fyDeclared !== undefined) {
570
+ clauses.push({ field: "fyDeclared", op: "eq", type: "number", value: args.fyDeclared });
571
+ filtersApplied.push("fyDeclared");
572
+ }
573
+ if (args.disasterNumber !== undefined) {
574
+ clauses.push({ field: "disasterNumber", op: "eq", type: "number", value: args.disasterNumber });
575
+ filtersApplied.push("disasterNumber");
576
+ }
577
+ if (args.declaredDateFrom !== undefined) {
578
+ clauses.push({ field: "declarationDate", op: "ge", type: "string", value: args.declaredDateFrom });
579
+ filtersApplied.push("declaredDateFrom");
580
+ }
581
+ if (args.declaredDateTo !== undefined) {
582
+ clauses.push({ field: "declarationDate", op: "le", type: "string", value: args.declaredDateTo });
583
+ filtersApplied.push("declaredDateTo");
584
+ }
585
+ if (args.paProgramDeclared !== undefined) {
586
+ clauses.push({ field: "paProgramDeclared", op: "eq", type: "boolean", value: args.paProgramDeclared });
587
+ filtersApplied.push("paProgramDeclared");
588
+ }
589
+ if (args.iaProgramDeclared !== undefined) {
590
+ clauses.push({ field: "iaProgramDeclared", op: "eq", type: "boolean", value: args.iaProgramDeclared });
591
+ filtersApplied.push("iaProgramDeclared");
592
+ }
593
+
594
+ const params = new URLSearchParams();
595
+ params.set("$top", String(limit));
596
+ params.set("$skip", String(offset));
597
+ const filter = buildFilter("disaster_declarations", clauses);
598
+ if (filter) params.set("$filter", filter);
599
+
600
+ const { body } = await getOpenFema("disaster_declarations", params);
601
+ return shapeResponse({ body, datasetKey: "disaster_declarations", offset, limit, filtersApplied });
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
+ }