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