@cliwant/mcp-sam-gov 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +5 -5
- package/README.ko.md +5 -5
- package/README.md +19 -7
- package/dist/arcgis-feature.d.ts +68 -0
- package/dist/arcgis-feature.d.ts.map +1 -0
- package/dist/arcgis-feature.js +206 -0
- package/dist/arcgis-feature.js.map +1 -0
- package/dist/arcgis-hub.d.ts +90 -0
- package/dist/arcgis-hub.d.ts.map +1 -0
- package/dist/arcgis-hub.js +210 -0
- package/dist/arcgis-hub.js.map +1 -0
- package/dist/bonfire.d.ts +69 -0
- package/dist/bonfire.d.ts.map +1 -0
- package/dist/bonfire.js +212 -0
- package/dist/bonfire.js.map +1 -0
- package/dist/cbp-border.d.ts +9 -5
- package/dist/cbp-border.d.ts.map +1 -1
- package/dist/cbp-border.js +31 -11
- package/dist/cbp-border.js.map +1 -1
- package/dist/gao.d.ts.map +1 -1
- package/dist/gao.js +25 -0
- package/dist/gao.js.map +1 -1
- package/dist/grants.js +1 -1
- package/dist/grants.js.map +1 -1
- package/dist/gsa-perdiem.d.ts +16 -2
- package/dist/gsa-perdiem.d.ts.map +1 -1
- package/dist/gsa-perdiem.js +25 -4
- package/dist/gsa-perdiem.js.map +1 -1
- package/dist/integrity.d.ts.map +1 -1
- package/dist/integrity.js +18 -3
- package/dist/integrity.js.map +1 -1
- package/dist/lda.d.ts.map +1 -1
- package/dist/lda.js +17 -4
- package/dist/lda.js.map +1 -1
- package/dist/nhtsa.d.ts +9 -5
- package/dist/nhtsa.d.ts.map +1 -1
- package/dist/nhtsa.js +90 -26
- package/dist/nhtsa.js.map +1 -1
- package/dist/nist-controls.d.ts +3 -1
- package/dist/nist-controls.d.ts.map +1 -1
- package/dist/nist-controls.js +34 -12
- package/dist/nist-controls.js.map +1 -1
- package/dist/ofac.d.ts.map +1 -1
- package/dist/ofac.js +10 -1
- package/dist/ofac.js.map +1 -1
- package/dist/opengov.d.ts +96 -0
- package/dist/opengov.d.ts.map +1 -0
- package/dist/opengov.js +244 -0
- package/dist/opengov.js.map +1 -0
- package/dist/pricing.d.ts +1 -1
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +13 -4
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +199 -8
- package/dist/server.js.map +1 -1
- package/dist/socrata.d.ts +27 -16
- package/dist/socrata.d.ts.map +1 -1
- package/dist/socrata.js +149 -18
- package/dist/socrata.js.map +1 -1
- package/package.json +11 -3
- package/src/arcgis-feature.ts +232 -0
- package/src/arcgis-hub.ts +270 -0
- package/src/bonfire.ts +249 -0
- package/src/cbp-border.ts +30 -9
- package/src/gao.ts +27 -0
- package/src/grants.ts +1 -1
- package/src/gsa-perdiem.ts +28 -4
- package/src/integrity.ts +18 -3
- package/src/lda.ts +21 -5
- package/src/nhtsa.ts +92 -27
- package/src/nist-controls.ts +58 -9
- package/src/ofac.ts +12 -1
- package/src/opengov.ts +309 -0
- package/src/sam-gov/client.ts +13 -4
- package/src/server.ts +213 -8
- package/src/socrata.ts +153 -18
package/src/bonfire.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bonfire.ts — Bonfire (Euna Solutions) per-organization open-solicitation RSS,
|
|
3
|
+
* a keyless-first SLED bid source (loop — SLED bid campaign, 2026-07-19).
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT ADDS: Bonfire hosts the open-bid portals of thousands of US state/local
|
|
6
|
+
* governments; each org exposes a KEYLESS RSS 2.0 feed of its currently-open
|
|
7
|
+
* opportunities at `https://{org}.bonfirehub.com/opportunities/rss` (live-verified
|
|
8
|
+
* on Dallas/Harris County/Utah/Bernalillo/…). One of the two highest-reach keyless
|
|
9
|
+
* SLED bid feeds (with OpenGov Procurement).
|
|
10
|
+
*
|
|
11
|
+
* ★ NO keyless directory API: Bonfire's authoritative org list
|
|
12
|
+
* (`GET common-production-api-global.bonfirehub.com/v1.0/organizations/external`)
|
|
13
|
+
* is AUTH-GATED (a free vendor-account token) — OUT OF BOUNDS (we never sign in).
|
|
14
|
+
* So this ships a CURATED, live-verified SEED directory (187 US orgs; §BONFIRE_
|
|
15
|
+
* ORGS) as `bonfire_list_organizations`, and documents the keyless RSS-probe
|
|
16
|
+
* refresh method (no catch-all: `{slug}.bonfirehub.com/opportunities/rss` returns
|
|
17
|
+
* 200 <rss> for a real org, a connection failure for a non-provisioned slug). The
|
|
18
|
+
* seed is a PARTIAL directory (Euna markets up to ~900 US orgs) — disclosed.
|
|
19
|
+
*
|
|
20
|
+
* The module REUSES `getText` (shared XML/RSS fetch, redirect:"error") /
|
|
21
|
+
* `driftError` / `str`·`num` / `withMeta`·`buildMeta`, mirroring fpds/gao. KEYLESS.
|
|
22
|
+
*
|
|
23
|
+
* ★ SSRF: org is charclass-validated (`^[a-z0-9-]{1,64}$` — no dots), the URL is
|
|
24
|
+
* built on the FIXED `.bonfirehub.com` suffix, and a post-construction assertion
|
|
25
|
+
* requires `hostname === {org}.bonfirehub.com` (over https) BEFORE the fetch;
|
|
26
|
+
* `redirect:"error"`.
|
|
27
|
+
*
|
|
28
|
+
* ★ HONESTY PILLARS:
|
|
29
|
+
* P1: the RSS is the COMPLETE current open-opportunity set for the org (no server
|
|
30
|
+
* pagination), so totalAvailable = the parsed item count (the true total, NOT a
|
|
31
|
+
* page length); client-side limit/offset page over it.
|
|
32
|
+
* P2: getText THROWS on 429 / 5xx / 404 / timeout — NEVER a fake empty. An empty
|
|
33
|
+
* channel (0 items) ⇒ honest empty (the org has no open opportunities now).
|
|
34
|
+
* P3: every field via `str` (null-never-empty); dates surfaced verbatim.
|
|
35
|
+
* P4: a 200 body that is not RSS (no `<rss`/`<channel`) ⇒ schema_drift (never
|
|
36
|
+
* parsed as an empty set).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
40
|
+
import { getText, driftError } from "./datasource.js";
|
|
41
|
+
import { num, str } from "./coerce.js";
|
|
42
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
43
|
+
|
|
44
|
+
export { num };
|
|
45
|
+
|
|
46
|
+
// ─── Fixed suffix (SSRF core) + org grammar ───────────────────────
|
|
47
|
+
const BONFIRE_SUFFIX = ".bonfirehub.com";
|
|
48
|
+
export const BONFIRE_ORG_RE = /^[a-z0-9-]{1,64}$/;
|
|
49
|
+
const bonfireLabel = (org: string) => `bonfire:${org}/opportunities/rss`;
|
|
50
|
+
const BONFIRE_SOURCE = (org: string) =>
|
|
51
|
+
`${org}.bonfirehub.com via Bonfire (Euna) open-opportunities RSS (keyless)`;
|
|
52
|
+
|
|
53
|
+
const BONFIRE_SEED_NOTE =
|
|
54
|
+
"This directory is a CURATED, live-verified SEED (Bonfire has NO keyless org-list API; the authoritative list is auth-gated and out of bounds). Euna markets up to ~900 US orgs, so the seed is partial — probe `{slug}.bonfirehub.com/opportunities/rss` (200 <rss> = real org) to extend. Feed a result's `org` to bonfire_search_opportunities.";
|
|
55
|
+
|
|
56
|
+
// ─── The curated 187-org US seed directory (live-verified 2026-07-19) ──
|
|
57
|
+
// "slug|Entity|ST" — the slug is the RSS subdomain. Non-US (.ca / cayman / etc.)
|
|
58
|
+
// deliberately excluded.
|
|
59
|
+
const BONFIRE_SEED_RAW: readonly string[] = [
|
|
60
|
+
"littlerock|City of Little Rock|AR",
|
|
61
|
+
"buckeyeaz|City of Buckeye|AZ", "goodyearaz|City of Goodyear|AZ", "peoriaaz|City of Peoria|AZ", "scottsdaleaz|City of Scottsdale|AZ", "yumaaz|City of Yuma|AZ", "pinalcountyaz|Pinal County|AZ", "susd|Scottsdale USD|AZ", "tollesonuhsd|Tolleson Union HSD|AZ",
|
|
62
|
+
"cityofirvine|City of Irvine|CA", "oaklandca|City of Oakland|CA", "alamedacounty|Alameda County|CA", "solanocounty|Solano County|CA", "ventura|County of Ventura|CA", "yolocounty|Yolo County|CA", "mtc|Metropolitan Transportation Commission|CA", "azusa|Azusa USD|CA", "stocktonusd|Stockton USD|CA", "weta|SF Bay Area Water Emergency Transportation Authority|CA", "acwd|Alameda County Water District|CA", "wrd|Water Replenishment District of Southern CA|CA", "actransit|AC Transit|CA", "marintransit|Marin Transit|CA", "mst|Monterey-Salinas Transit|CA", "omnitrans|Omnitrans|CA", "smctd|San Mateo County Transit District|CA", "sonomamarintrain|Sonoma-Marin Area Rail Transit|CA", "ggbhtd|Golden Gate Bridge Highway & Transportation District|CA",
|
|
63
|
+
"bouldercounty|Boulder County|CO",
|
|
64
|
+
"crcog|Capitol Region Council of Governments|CT", "easternct|Eastern Connecticut State University|CT",
|
|
65
|
+
"dfm|DE OMB - Division of Facility Management|DE", "gss|DE OMB - Government Support Services|DE",
|
|
66
|
+
"daviefl|Town of Davie|FL", "ocoee|City of Ocoee|FL", "broward|Broward County|FL", "hillsboroughcounty|Hillsborough County|FL", "marionfl|Marion County|FL", "monroecounty-fl|Monroe County|FL", "pascocountyfl|Pasco County|FL", "famu|Florida A&M University|FL", "fau|Florida Atlantic University|FL", "fgcu|Florida Gulf Coast University|FL", "floridapoly|Florida Polytechnic University|FL", "ucf|University of Central Florida|FL", "tampabaywater|Tampa Bay Water|FL", "gohart|Hillsborough Transit Authority|FL", "psta|Pinellas Suncoast Transit Authority|FL",
|
|
67
|
+
"brookhavenga|City of Brookhaven|GA", "sandysprings|City of Sandy Springs|GA", "chathamcountyga|Chatham County|GA", "columbiacountyga|Columbia County|GA", "gwinnett|Gwinnett County Public Schools|GA",
|
|
68
|
+
"cityofnampa|City of Nampa|ID", "adacounty|Ada County|ID", "bannockcounty|Bannock County|ID",
|
|
69
|
+
"cookcountyil|Cook County|IL", "thecha|Chicago Housing Authority|IL", "cps|Chicago Public Schools|IL", "u-46|School District U-46|IL", "chicagoparkdistrict|Chicago Park District|IL", "mwrd|Metro Water Reclamation District of Greater Chicago|IL", "transitchicago|Chicago Transit Authority|IL", "metra|Metra|IL",
|
|
70
|
+
"indygo|Indianapolis Public Transportation Corp|IN",
|
|
71
|
+
"olatheks|City of Olathe|KS", "wichita|City of Wichita|KS",
|
|
72
|
+
"covingtonky|City of Covington|KY", "louisvilleky|City of Louisville|KY", "owensboro|City of Owensboro|KY", "lexingtonky|Lexington-Fayette|KY", "kyhousing|Kentucky Housing Corporation|KY", "tarc|Transit Authority of River City|KY",
|
|
73
|
+
"umass|University of Massachusetts|MA",
|
|
74
|
+
"harfordcountymd|Harford County|MD", "habc|Housing Authority of Baltimore City|MD", "hcpss|Howard County Public School System|MD", "menv|Maryland Environmental Service|MD", "mdcourts|Maryland Judiciary|MD",
|
|
75
|
+
"maine|State of Maine|ME",
|
|
76
|
+
"detroit|City of Detroit|MI",
|
|
77
|
+
"ramseycountymn|Ramsey County|MN", "sourcewell|Sourcewell|MN",
|
|
78
|
+
"jeffersoncitymo|Jefferson City|MO", "stlouiscountymo|St. Louis County|MO", "stlcc|St. Louis Community College|MO",
|
|
79
|
+
"apexnc|Town of Apex|NC", "charlottenc|City of Charlotte|NC", "wake|Wake County|NC", "ncat|NC A&T State University|NC", "ncsu|NC State University|NC",
|
|
80
|
+
"rutgers|Rutgers University|NJ",
|
|
81
|
+
"cabq|City of Albuquerque|NM", "mckinleycounty|McKinley County|NM",
|
|
82
|
+
"clarkcountynv|Clark County|NV", "ccsd|Clark County School District|NV",
|
|
83
|
+
"suffolkcountyny|Suffolk County|NY", "stonybrook|Stony Brook University|NY", "healthsolutions|Public Health Solutions|NY", "centro|Central NY Regional Transportation Authority|NY", "nfta|Niagara Frontier Transportation Authority|NY", "panynj|Port Authority of New York & New Jersey|NY",
|
|
84
|
+
"akronohio|City of Akron|OH", "cincinnati-oh|City of Cincinnati|OH", "columbus|City of Columbus|OH", "equalisgroup|Equalis Group|OH",
|
|
85
|
+
"pdx|Portland State University|OR",
|
|
86
|
+
"pennbid|PennBid|PA", "alleghenycounty|Allegheny County|PA",
|
|
87
|
+
"charlestoncounty|Charleston County|SC", "tridenttech|Trident Technical College|SC",
|
|
88
|
+
"apsu|Austin Peay State University|TN",
|
|
89
|
+
"dfwairport|DFW International Airport|TX", "amarillo|City of Amarillo|TX", "arlingtontx|City of Arlington|TX", "brownsvilletx|City of Brownsville|TX", "burlesontx|City of Burleson|TX", "cityoflewisville|City of Lewisville|TX", "dallascityhall|City of Dallas|TX", "fortworthtexas|City of Fort Worth|TX", "friscotexas|City of Frisco|TX", "leandertx|City of Leander|TX", "mckinneytexas|City of McKinney|TX", "midlandtexas|City of Midland|TX", "roundrocktexas|City of Round Rock|TX", "sanantonio|City of San Antonio|TX", "schertz|City of Schertz|TX", "southlake|City of Southlake|TX", "templetx|City of Temple|TX", "waco-texas|City of Waco|TX", "mansfield|Mansfield Council of Governments|TX", "brazoriacounty|Brazoria County|TX", "dentoncounty|Denton County|TX", "galvestoncountytx|Galveston County|TX", "harriscountytx|Harris County|TX", "johnsoncountytx|Johnson County|TX", "lubbock|Lubbock County|TX", "parkercountytx|Parker County|TX", "smithcounty|Smith County|TX", "wilco|Williamson County|TX", "hccs|Houston Community College|TX", "rice-edu|Rice University|TX", "tccd|Tarrant County College District|TX", "utdallas|UT Dallas|TX", "utexas|UT Austin|TX", "utrgv|UT Rio Grande Valley|TX", "uttyler|UT Tyler|TX", "uthscsa|UT Health San Antonio|TX", "saha|Opportunity Home San Antonio|TX", "universityhealth|University Health (hospital district)|TX", "allenisd|Allen ISD|TX", "austinisd|Austin ISD|TX", "comalisd|Comal ISD|TX", "dallasisd|Dallas ISD|TX", "fortbendisd|Fort Bend ISD|TX", "kleinisd|Klein ISD|TX", "laredoisd|Laredo ISD|TX", "magnoliaisd|Magnolia ISD|TX", "mesquiteisd|Mesquite ISD|TX", "tomballisd|Tomball ISD|TX", "twc-texas-gov|Texas Workforce Commission|TX", "txdot|Texas Department of Transportation|TX", "dart|Dallas Area Rapid Transit|TX", "ridemetro|Harris County METRO|TX",
|
|
90
|
+
"ccog|The Cooperative Council of Governments|US", "omniapartners|OMNIA Partners|US", "utah|U3P / Utah Public Procurement Place|UT",
|
|
91
|
+
"fairfaxcounty|Fairfax County|VA", "cnu|Christopher Newport University|VA", "gmu|George Mason University|VA", "nsu|Norfolk State University|VA", "fcps|Fairfax County Public Schools|VA",
|
|
92
|
+
"cityofvancouver|City of Vancouver WA|WA", "federalwaywa|City of Federal Way|WA", "clarkcountywa|Clark County WA|WA", "kingcounty|King County|WA", "portolympia|Port of Olympia|WA", "lwsd|Lake Washington School District|WA", "tacoma|Tacoma Public Schools|WA",
|
|
93
|
+
"cityofmilwaukee|City of Milwaukee|WI", "westalliswi|City of West Allis|WI", "racinecounty|Racine County|WI", "waukeshacounty|Waukesha County|WI", "cvtc|Chippewa Valley Technical College|WI", "mmsd|Milwaukee Metropolitan Sewerage District|WI",
|
|
94
|
+
"marshall|Marshall University|WV",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
export type BonfireOrg = { org: string; name: string; state: string };
|
|
98
|
+
export const BONFIRE_ORGS: readonly BonfireOrg[] = BONFIRE_SEED_RAW.map((r) => {
|
|
99
|
+
const [org, name, state] = r.split("|");
|
|
100
|
+
return { org: org ?? "", name: name ?? "", state: state ?? "" };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── XML helpers (bespoke regex parser, mirrors fpds/gao) ─────────
|
|
104
|
+
function decodeEntities(s: string): string {
|
|
105
|
+
return s
|
|
106
|
+
.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"')
|
|
107
|
+
.replace(/�?39;/g, "'").replace(/'/g, "'").replace(/&/g, "&");
|
|
108
|
+
}
|
|
109
|
+
function tag(block: string, name: string): string | null {
|
|
110
|
+
const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`).exec(block);
|
|
111
|
+
if (!m) return null;
|
|
112
|
+
return decodeEntities((m[1] ?? "").replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").trim());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type BonfireOpportunity = {
|
|
116
|
+
referenceNumber: string | null;
|
|
117
|
+
name: string | null;
|
|
118
|
+
description: string | null;
|
|
119
|
+
closeDate: string | null;
|
|
120
|
+
link: string | null;
|
|
121
|
+
pubDate: string | null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Parse a Bonfire RSS item block → curated opportunity. Title is
|
|
125
|
+
* "Reference #: <ref>. Name: <name>"; description embeds "closes <date>". */
|
|
126
|
+
function mapItem(block: string): BonfireOpportunity {
|
|
127
|
+
const title = tag(block, "title");
|
|
128
|
+
const description = tag(block, "description");
|
|
129
|
+
let referenceNumber: string | null = null;
|
|
130
|
+
let name: string | null = str(title);
|
|
131
|
+
if (title) {
|
|
132
|
+
const m = /Reference #:\s*(.*?)\.\s*Name:\s*([\s\S]*)$/.exec(title);
|
|
133
|
+
if (m) { referenceNumber = str(m[1]); name = str(m[2]); }
|
|
134
|
+
}
|
|
135
|
+
let closeDate: string | null = null;
|
|
136
|
+
if (description) {
|
|
137
|
+
const c = /\bcloses\s+([A-Za-z0-9:,\s]+?(?:AM|PM)[A-Za-z0-9 ]*)$/i.exec(description) || /\bcloses\s+(.+)$/i.exec(description);
|
|
138
|
+
if (c && c[1]) closeDate = str(c[1].trim());
|
|
139
|
+
}
|
|
140
|
+
return { referenceNumber, name, description: str(description), closeDate, link: str(tag(block, "link")), pubDate: str(tag(block, "pubDate")) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── SSRF-guarded fetch ───────────────────────────────────────────
|
|
144
|
+
async function getBonfireRss(org: string): Promise<string> {
|
|
145
|
+
const host = `${org}${BONFIRE_SUFFIX}`;
|
|
146
|
+
const url = `https://${host}/opportunities/rss`;
|
|
147
|
+
const built = new URL(url);
|
|
148
|
+
if (built.hostname !== host || !built.hostname.endsWith(BONFIRE_SUFFIX) || built.protocol !== "https:") {
|
|
149
|
+
throw new ToolErrorCarrier({
|
|
150
|
+
kind: "invalid_input",
|
|
151
|
+
message: `Constructed Bonfire URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${JSON.stringify(host)} over https — refusing to fetch (SSRF safety).`,
|
|
152
|
+
retryable: false,
|
|
153
|
+
upstreamEndpoint: bonfireLabel(org),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return getText(url, { label: bonfireLabel(org), redirect: "error" });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Tool 1: bonfire_list_organizations (curated seed directory) ──
|
|
160
|
+
export type BonfireListArgs = { state?: string; query?: string; limit?: number; offset?: number };
|
|
161
|
+
|
|
162
|
+
export async function listOrganizations(args: BonfireListArgs): Promise<MetaBundle> {
|
|
163
|
+
const limit = args.limit ?? 50;
|
|
164
|
+
const offset = args.offset ?? 0;
|
|
165
|
+
const stateFilter = args.state ? args.state.trim().toUpperCase() : null;
|
|
166
|
+
const queryFilter = args.query ? args.query.trim().toLowerCase() : null;
|
|
167
|
+
const filtersApplied: string[] = [];
|
|
168
|
+
if (stateFilter) filtersApplied.push("state");
|
|
169
|
+
if (queryFilter) filtersApplied.push("query");
|
|
170
|
+
|
|
171
|
+
const filtered = BONFIRE_ORGS.filter(
|
|
172
|
+
(o) =>
|
|
173
|
+
(!stateFilter || o.state.toUpperCase() === stateFilter) &&
|
|
174
|
+
(!queryFilter || o.name.toLowerCase().includes(queryFilter)),
|
|
175
|
+
);
|
|
176
|
+
const totalAvailable = filtered.length;
|
|
177
|
+
const page = filtered.slice(offset, offset + limit);
|
|
178
|
+
const returned = page.length;
|
|
179
|
+
const hasMore = offset + returned < totalAvailable;
|
|
180
|
+
|
|
181
|
+
return withMeta(
|
|
182
|
+
{ organizations: page },
|
|
183
|
+
{
|
|
184
|
+
source: "Bonfire (Euna) curated seed directory (keyless)",
|
|
185
|
+
keylessMode: true,
|
|
186
|
+
returned,
|
|
187
|
+
totalAvailable,
|
|
188
|
+
filtersApplied,
|
|
189
|
+
filtersDropped: [],
|
|
190
|
+
fieldsUnavailable: [],
|
|
191
|
+
pagination: { offset, limit, hasMore, nextOffset: hasMore ? offset + returned : null },
|
|
192
|
+
notes: [BONFIRE_SEED_NOTE],
|
|
193
|
+
} satisfies Partial<ResponseMeta>,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Tool 2: bonfire_search_opportunities (per-org RSS) ───────────
|
|
198
|
+
export type BonfireSearchArgs = { org: string; limit?: number; offset?: number };
|
|
199
|
+
|
|
200
|
+
export async function searchOpportunities(args: BonfireSearchArgs): Promise<MetaBundle> {
|
|
201
|
+
const limit = args.limit ?? 50;
|
|
202
|
+
const offset = args.offset ?? 0;
|
|
203
|
+
const org = args.org ?? "";
|
|
204
|
+
|
|
205
|
+
if (!BONFIRE_ORG_RE.test(org)) {
|
|
206
|
+
throw new ToolErrorCarrier({
|
|
207
|
+
kind: "invalid_input",
|
|
208
|
+
message: `Invalid Bonfire org ${JSON.stringify(org)} — expected a lowercase-alnum/hyphen subdomain slug (from bonfire_list_organizations, e.g. 'harriscountytx', 'u-46').`,
|
|
209
|
+
retryable: false,
|
|
210
|
+
upstreamEndpoint: bonfireLabel(org),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const body = await getBonfireRss(org); // getText THROWS on 429/5xx/404/timeout (P2)
|
|
215
|
+
|
|
216
|
+
// P4: a 200 body must be RSS (no <rss/<channel ⇒ drift; e.g. an error/HTML page).
|
|
217
|
+
if (!/<rss[\s>]/i.test(body) || !/<channel[\s>]/i.test(body)) {
|
|
218
|
+
throw driftError(bonfireLabel(org), "Bonfire returned a non-RSS body at HTTP 200 — schema drift (expected an <rss><channel> feed).");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Tolerate attributes on the opening <item …> tag (consistent with the <channel[\s>]
|
|
222
|
+
// drift guard and the inner tag() matcher). RSS 2.0 <item> has no standard attributes,
|
|
223
|
+
// but a namespaced/extended feed could add them — a bare-<item> regex would silently
|
|
224
|
+
// DROP such items and undercount totalAvailable (a latent P1 risk caught in dogfooding).
|
|
225
|
+
const items = body.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi) ?? [];
|
|
226
|
+
const all = items.map(mapItem);
|
|
227
|
+
// P1: the RSS is the COMPLETE open set ⇒ totalAvailable = item count (true total).
|
|
228
|
+
const totalAvailable = all.length;
|
|
229
|
+
const pageRows = all.slice(offset, offset + limit);
|
|
230
|
+
const returned = pageRows.length;
|
|
231
|
+
const hasMore = offset + returned < totalAvailable;
|
|
232
|
+
|
|
233
|
+
return withMeta(
|
|
234
|
+
{ org, opportunities: pageRows },
|
|
235
|
+
{
|
|
236
|
+
source: BONFIRE_SOURCE(org),
|
|
237
|
+
keylessMode: true,
|
|
238
|
+
returned,
|
|
239
|
+
totalAvailable,
|
|
240
|
+
filtersApplied: ["org"],
|
|
241
|
+
filtersDropped: [],
|
|
242
|
+
fieldsUnavailable: [],
|
|
243
|
+
pagination: { offset, limit, hasMore, nextOffset: hasMore ? offset + returned : null },
|
|
244
|
+
notes: [
|
|
245
|
+
"The RSS is the COMPLETE set of the org's currently-OPEN opportunities (no server pagination) — totalAvailable is the exact open-opportunity count, and this tool pages over it client-side. An empty feed (returned 0) means the org has no open opportunities right now. closeDate is parsed best-effort from the description text.",
|
|
246
|
+
],
|
|
247
|
+
} satisfies Partial<ResponseMeta>,
|
|
248
|
+
);
|
|
249
|
+
}
|
package/src/cbp-border.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* cbp-border.ts — CBP Border Wait Times (bwt.cbp.gov, KEYLESS) — the FREIGHT /
|
|
3
|
-
* LOGISTICS lane. Live
|
|
3
|
+
* LOGISTICS lane. Live COMMERCIAL-VEHICLE (freight-truck) wait times at every US
|
|
4
4
|
* land border port (Canadian + Mexican): per-port lane delays, operational status,
|
|
5
|
-
* and open-lane counts.
|
|
5
|
+
* and open-lane counts. (The raw feed also carries passenger/pedestrian lanes, but
|
|
6
|
+
* this tool surfaces ONLY the commercial-vehicle lanes — the freight lane.)
|
|
7
|
+
* Answers "what's the current commercial-truck delay at port
|
|
6
8
|
* X" — real-time freight-crossing situational awareness for logistics/trade vendors.
|
|
7
9
|
*
|
|
8
10
|
* SOURCE: CBP's official Border Wait Times API (bwt.cbp.gov/api/bwtnew) — a .gov host,
|
|
@@ -127,9 +129,11 @@ async function loadPorts(): Promise<CbpPort[]> {
|
|
|
127
129
|
|
|
128
130
|
// ─── Tool: cbp_border_wait_times ──────────────────────────────────
|
|
129
131
|
/**
|
|
130
|
-
* List CBP land-border-port
|
|
131
|
-
* filtered by border (Canadian/Mexican) and/or port name (substring
|
|
132
|
-
*
|
|
132
|
+
* List CBP land-border-port COMMERCIAL-VEHICLE (freight-truck) wait times, optionally
|
|
133
|
+
* filtered by border (Canadian/Mexican) and/or port name (substring, applied
|
|
134
|
+
* CLIENT-SIDE over the full fetched set and disclosed as such). Passenger/pedestrian
|
|
135
|
+
* lanes are NOT surfaced (freight lane only). Honest `_meta` (exact match total +
|
|
136
|
+
* real-time freshness).
|
|
133
137
|
*/
|
|
134
138
|
export async function borderWaitTimes(args: {
|
|
135
139
|
border?: string;
|
|
@@ -142,10 +146,20 @@ export async function borderWaitTimes(args: {
|
|
|
142
146
|
const all = await loadPorts();
|
|
143
147
|
|
|
144
148
|
const filtersApplied: string[] = [];
|
|
149
|
+
const filtersDropped: string[] = [];
|
|
145
150
|
const borderQ = args.border?.trim().toLowerCase();
|
|
146
151
|
const portQ = args.portName?.trim().toLowerCase();
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
// [filter honesty] Only claim a filter APPLIED when its query is non-empty — a
|
|
153
|
+
// border:"" / portName:"" (or whitespace) narrows nothing, so reporting it as
|
|
154
|
+
// applied while returning every port would be a false filtersApplied.
|
|
155
|
+
if (args.border !== undefined) {
|
|
156
|
+
if (borderQ) filtersApplied.push("border");
|
|
157
|
+
else filtersDropped.push("border(empty)");
|
|
158
|
+
}
|
|
159
|
+
if (args.portName !== undefined) {
|
|
160
|
+
if (portQ) filtersApplied.push("portName");
|
|
161
|
+
else filtersDropped.push("portName(empty)");
|
|
162
|
+
}
|
|
149
163
|
|
|
150
164
|
const matched = all.filter((p) => {
|
|
151
165
|
if (borderQ && !(p.border ?? "").toLowerCase().includes(borderQ)) return false;
|
|
@@ -159,6 +173,13 @@ export async function borderWaitTimes(args: {
|
|
|
159
173
|
const hasMore = offset + returned < totalAvailable;
|
|
160
174
|
const nextOffset = hasMore ? offset + returned : null;
|
|
161
175
|
|
|
176
|
+
const notes: string[] = [PROVENANCE_NOTE, FRESHNESS_NOTE];
|
|
177
|
+
if (filtersApplied.length > 0) {
|
|
178
|
+
notes.push(
|
|
179
|
+
"border / portName are applied CLIENT-SIDE over the full live port set (fetched in ONE request — the CBP feed has no server-side filter); totalAvailable is the EXACT matched count over that full set.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
162
183
|
return withMeta(
|
|
163
184
|
{ ports: page },
|
|
164
185
|
{
|
|
@@ -168,10 +189,10 @@ export async function borderWaitTimes(args: {
|
|
|
168
189
|
totalAvailable,
|
|
169
190
|
truncated: hasMore,
|
|
170
191
|
filtersApplied,
|
|
171
|
-
filtersDropped
|
|
192
|
+
filtersDropped,
|
|
172
193
|
fieldsUnavailable: [],
|
|
173
194
|
pagination: { offset, limit, hasMore, nextOffset },
|
|
174
|
-
notes
|
|
195
|
+
notes,
|
|
175
196
|
} satisfies Partial<ResponseMeta>,
|
|
176
197
|
);
|
|
177
198
|
}
|
package/src/gao.ts
CHANGED
|
@@ -593,6 +593,21 @@ export async function gaoProtestLookup(args: {
|
|
|
593
593
|
|
|
594
594
|
// ── Feed path ──────────────────────────────────────────────────────
|
|
595
595
|
const xml = await getText(RSS_URL, "gao:rss");
|
|
596
|
+
// [P2/P4] A 200 body that isn't the RSS feed (GAO's edge is Cloudflare/WAF — a
|
|
597
|
+
// challenge or maintenance interstitial returns 200 HTML, NOT the feed) must
|
|
598
|
+
// NOT be parsed into an empty decision list: parseFeed's <item> regex yields []
|
|
599
|
+
// on any non-RSS body, which would read as "no recent bid protests" — an
|
|
600
|
+
// outage-as-empty lie. A real feed always carries <rss>/<channel>/<item> (a
|
|
601
|
+
// genuinely EMPTY feed still has <channel>, so a 0-item feed passes). Guard the
|
|
602
|
+
// shape and surface a retryable outage instead of a fabricated empty result.
|
|
603
|
+
if (!/<(rss|feed|channel|item)\b/i.test(xml)) {
|
|
604
|
+
throw new ToolErrorCarrier({
|
|
605
|
+
kind: "upstream_unavailable",
|
|
606
|
+
retryable: true,
|
|
607
|
+
message: `GAO RSS returned a 200 body with no <rss>/<channel>/<item> markup (${xml.length} bytes) — GAO's Cloudflare/WAF edge most likely served a challenge or maintenance interstitial instead of the feed. Surfaced as a retryable outage rather than a fabricated empty decision list (which would falsely read as "no recent bid protests"). Retry shortly.`,
|
|
608
|
+
upstreamEndpoint: "gao:rss",
|
|
609
|
+
});
|
|
610
|
+
}
|
|
596
611
|
const rawItems = parseFeed(xml);
|
|
597
612
|
const protests = rawItems.filter(isBidProtest);
|
|
598
613
|
|
|
@@ -653,9 +668,16 @@ export async function gaoProtestLookup(args: {
|
|
|
653
668
|
|
|
654
669
|
// Apply the outcome filter now that we (may) have parsed outcomes, then cap.
|
|
655
670
|
const filtersDropped: string[] = [];
|
|
671
|
+
let outcomeUndetermined = 0;
|
|
656
672
|
if (outcomeFilter) {
|
|
657
673
|
if (enrich) {
|
|
658
674
|
filtersApplied.push("outcome(from decision page)");
|
|
675
|
+
// [filter honesty] A decision whose page could not be enriched has
|
|
676
|
+
// outcome=null — UNDETERMINED, not confirmed "≠ outcomeFilter". Dropping
|
|
677
|
+
// those silently makes returned:0 read as "no <outcome> protests exist"
|
|
678
|
+
// when the truth is "no outcome could be read" (GAO per-decision pages are
|
|
679
|
+
// intermittently WAF-blocked). Count them so it is disclosed, not swallowed.
|
|
680
|
+
outcomeUndetermined = decisions.filter((d) => d.outcome === null).length;
|
|
659
681
|
decisions = decisions.filter((d) => d.outcome === outcomeFilter);
|
|
660
682
|
} else {
|
|
661
683
|
// Can't determine outcome without enrichment → don't silently pretend to.
|
|
@@ -672,6 +694,11 @@ export async function gaoProtestLookup(args: {
|
|
|
672
694
|
`${enrichFailures} decision(s) returned feed-level fields only; per-decision page enrichment failed (agency/outcome/solicitation may be null for those).`,
|
|
673
695
|
);
|
|
674
696
|
}
|
|
697
|
+
if (outcomeFilter && enrich && outcomeUndetermined > 0) {
|
|
698
|
+
notes.push(
|
|
699
|
+
`${outcomeUndetermined} recent decision(s) were EXCLUDED from the outcome='${outcomeFilter}' filter because their decision page could not be read (enrichment failed) — their outcome is UNDETERMINED, not confirmed to differ. Treat a 0 or low result here as "outcome could not be determined for those", NOT "none exist"; retry (GAO per-decision pages are intermittently WAF-blocked) or drop the outcome filter to list all recent decisions.`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
675
702
|
if (!enrich) {
|
|
676
703
|
notes.push(
|
|
677
704
|
"enrich=false: only RSS feed-level fields were returned (protester/title/date/summary). Agency, outcome, solicitation number, and the decision PDF require the per-decision page — call again with enrich=true or a specific bNumber.",
|
package/src/grants.ts
CHANGED
|
@@ -139,7 +139,7 @@ export async function searchGrants(args: {
|
|
|
139
139
|
const notes: string[] = [];
|
|
140
140
|
if (args.agency || args.cfda) {
|
|
141
141
|
notes.push(
|
|
142
|
-
"Grants.gov
|
|
142
|
+
"Grants.gov applies the agency/CFDA filter server-side (live-verified 2026-07-20): a bogus or misspelled value returns 0 results, NOT an error and NOT the unfiltered set — so an unexpectedly EMPTY filtered search most often means the agency/CFDA value is invalid, not that no grants exist. `filtersApplied` reflects that the filter was sent. Verify the agency code / CFDA number if a filtered result is surprisingly empty.",
|
|
143
143
|
);
|
|
144
144
|
}
|
|
145
145
|
// VQ-1 (C82 dogfooding): Grants.gov OR-tokenizes multi-word keywords (matches ANY
|
package/src/gsa-perdiem.ts
CHANGED
|
@@ -57,8 +57,26 @@ const GSA_PERDIEM_LABEL = "gsa-perdiem:/travel/perdiem/v2/rates";
|
|
|
57
57
|
const GSA_PERDIEM_SOURCE = (mode: string) =>
|
|
58
58
|
`${GSA_PERDIEM_HOST} via GSA Federal Travel Per-Diem API (${mode})`;
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The US federal fiscal year for a date (ADR-0050). The FY begins Oct 1, so
|
|
62
|
+
* Oct–Dec belong to the NEXT calendar year's FY (e.g. 2025-11 → FY2026) while
|
|
63
|
+
* Jan–Sep stay in the current (e.g. 2026-07 → FY2026). Pure + UTC-based so it is
|
|
64
|
+
* deterministic and timezone-independent (the intra-day rollover instant is
|
|
65
|
+
* immaterial — per-diem rates do not change within a day).
|
|
66
|
+
*/
|
|
67
|
+
export function federalFiscalYear(d: Date): number {
|
|
68
|
+
return d.getUTCMonth() >= 9 ? d.getUTCFullYear() + 1 : d.getUTCFullYear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The default per-diem year when the caller omits `year`: the CURRENT federal
|
|
73
|
+
* fiscal year. GSA publishes rates per FY; a hard-coded default silently serves
|
|
74
|
+
* an EXPIRED vintage once the FY rolls over (the drift this replaces — a no-year
|
|
75
|
+
* lookup must track the live FY, not a frozen year).
|
|
76
|
+
*/
|
|
77
|
+
export function defaultPerdiemYear(): string {
|
|
78
|
+
return String(federalFiscalYear(new Date()));
|
|
79
|
+
}
|
|
62
80
|
|
|
63
81
|
// ─── Validation charclasses (SSRF + "verify the input" honesty) ───
|
|
64
82
|
// Each rides in a single PATH segment (encodeURIComponent-escaped), so these are
|
|
@@ -158,7 +176,7 @@ async function getGsaPerdiem(path: string): Promise<unknown> {
|
|
|
158
176
|
|
|
159
177
|
/**
|
|
160
178
|
* Look up GSA Federal Travel per-diem rates by EITHER (city + state) OR zip, for a
|
|
161
|
-
* given `year` (default
|
|
179
|
+
* given `year` (default: the current U.S. federal fiscal year). Returns flattened rate rows (each outer state/year
|
|
162
180
|
* group × inner city/rate) + honest `_meta`: totalAvailable = the row count (no
|
|
163
181
|
* pagination — P1), lodging/meals as null-never-0 dollars (P3), standardRate/isOconus
|
|
164
182
|
* as real booleans, the months array preserved as-is. The DEMO_KEY rate disclosure
|
|
@@ -168,7 +186,8 @@ export async function perdiemRates(
|
|
|
168
186
|
args: GsaPerdiemRatesArgs,
|
|
169
187
|
): Promise<MetaBundle> {
|
|
170
188
|
const label = GSA_PERDIEM_LABEL;
|
|
171
|
-
const
|
|
189
|
+
const yearWasDefaulted = args.year === undefined;
|
|
190
|
+
const year = args.year ?? defaultPerdiemYear();
|
|
172
191
|
|
|
173
192
|
// ── [INPUT] EITHER (city + state) OR zip — never both, never neither. This is a
|
|
174
193
|
// caller-shape check (0 fetch): an ambiguous or empty lookup is invalid_input,
|
|
@@ -341,6 +360,11 @@ export async function perdiemRates(
|
|
|
341
360
|
|
|
342
361
|
const returned = rows.length;
|
|
343
362
|
const notes: string[] = [RATE_MEANING_NOTE, STANDARD_RATE_NOTE, NO_PAGINATION_NOTE];
|
|
363
|
+
if (yearWasDefaulted) {
|
|
364
|
+
notes.push(
|
|
365
|
+
`No \`year\` was supplied, so it defaulted to the CURRENT U.S. federal fiscal year (FY${year}). GSA per-diem ceilings are set per fiscal year (Oct 1–Sep 30); pass an explicit \`year\` for a prior or upcoming FY.`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
344
368
|
pushKeyNote(notes);
|
|
345
369
|
|
|
346
370
|
return withMeta(
|
package/src/integrity.ts
CHANGED
|
@@ -294,7 +294,22 @@ export async function checkExclusions(args: {
|
|
|
294
294
|
const serverTruncated =
|
|
295
295
|
totalElements !== null && rawResults.length < totalElements;
|
|
296
296
|
const hitCap = totalElements !== null && totalElements > SGS_MAX_RECORDS;
|
|
297
|
-
|
|
297
|
+
// totalAvailable HONESTY: the raw free-text `totalElements` counts every record
|
|
298
|
+
// that merely shares a WORD with the query — NOT the name-gated matches this
|
|
299
|
+
// tool reports. Surfacing it read as "0 of 252 matches, incomplete" for a firm
|
|
300
|
+
// with ZERO real matches (a vetting tool must not overstate match availability).
|
|
301
|
+
// When the result is name-gated (the normal case — a selector is always
|
|
302
|
+
// required), the true count of name-MATCHING exclusions is genuinely unknown
|
|
303
|
+
// from one page of text hits, so report null (never the free-text total); the
|
|
304
|
+
// per-page match count stays in data.matchCount.
|
|
305
|
+
const totalAvailable = postFiltered ? null : totalElements;
|
|
306
|
+
// truncated only when there is genuinely more to see: more server pages, the
|
|
307
|
+
// deep-paging cap, or this page dropped loose text hits (so a name VARIANT
|
|
308
|
+
// could match on a later page). The old `|| postFiltered` forced truncated:true
|
|
309
|
+
// even over a genuinely empty result set (0 text hits) — asserting incompleteness
|
|
310
|
+
// over an empty set, a contradiction. A fully-consumed name-gated result is a
|
|
311
|
+
// complete, honest empty.
|
|
312
|
+
const truncated = serverTruncated || hitCap || (postFiltered && looseTextHits > 0);
|
|
298
313
|
|
|
299
314
|
const notes: string[] = [NOT_PROOF_NOTE];
|
|
300
315
|
notes.push(
|
|
@@ -307,7 +322,7 @@ export async function checkExclusions(args: {
|
|
|
307
322
|
}
|
|
308
323
|
if (postFiltered) {
|
|
309
324
|
notes.push(
|
|
310
|
-
"A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true
|
|
325
|
+
"A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true name-matched total is unknown from one page, so `totalAvailable` is null (the raw free-text hit count is NOT the match count). `matchCount` is this page's name-gated matches; narrow with a more specific `query` or raise `size`.",
|
|
311
326
|
);
|
|
312
327
|
}
|
|
313
328
|
if (hitCap) {
|
|
@@ -333,7 +348,7 @@ export async function checkExclusions(args: {
|
|
|
333
348
|
source: EXCLUSIONS_SOURCE,
|
|
334
349
|
keylessMode: true,
|
|
335
350
|
returned: records.length,
|
|
336
|
-
totalAvailable
|
|
351
|
+
totalAvailable,
|
|
337
352
|
truncated,
|
|
338
353
|
pagination: {
|
|
339
354
|
offset: page * size,
|
package/src/lda.ts
CHANGED
|
@@ -173,7 +173,7 @@ export type LdaSearchFilingsArgs = {
|
|
|
173
173
|
lobbyistName?: string;
|
|
174
174
|
filingYear?: string; // ^\d{4}$
|
|
175
175
|
filingType?: string; // a short code (e.g. "Q1", "RR")
|
|
176
|
-
agency?: string; //
|
|
176
|
+
agency?: string; // NOT server-filterable on /filings/ (API silently ignores government_entity) — never sent upstream; reported in _meta.filtersDropped with a workaround note.
|
|
177
177
|
issue?: string; // → filing_specific_lobbying_issues
|
|
178
178
|
page?: number; // 1-based, default 1
|
|
179
179
|
pageSize?: number; // 1..25, default 25
|
|
@@ -218,11 +218,22 @@ export async function searchFilings(
|
|
|
218
218
|
setFilter("lobbyist_name", args.lobbyistName, "lobbyistName");
|
|
219
219
|
setFilter("filing_year", args.filingYear, "filingYear");
|
|
220
220
|
setFilter("filing_type", args.filingType, "filingType");
|
|
221
|
-
setFilter("government_entity", args.agency, "agency");
|
|
222
221
|
setFilter("filing_specific_lobbying_issues", args.issue, "issue");
|
|
223
222
|
params.set("page", String(page));
|
|
224
223
|
params.set("page_size", String(pageSize));
|
|
225
224
|
|
|
225
|
+
// [filter honesty] `agency` maps to NO server-side filter on /filings/: the LDA
|
|
226
|
+
// API silently ignores `government_entity` (live-verified — adding it does NOT
|
|
227
|
+
// narrow `count`; government entities are nested per lobbying activity, not a
|
|
228
|
+
// top-level filter). So we NEVER send it (an ignored param buys nothing) and
|
|
229
|
+
// disclose it as DROPPED — reporting it as applied while returning the full
|
|
230
|
+
// corpus would be a silent-filter-drop lie (filtersApplied + totalAvailable
|
|
231
|
+
// overstated as if agency-scoped).
|
|
232
|
+
const filtersDropped: string[] = [];
|
|
233
|
+
if (args.agency !== undefined && args.agency !== "") {
|
|
234
|
+
filtersDropped.push("agency");
|
|
235
|
+
}
|
|
236
|
+
|
|
226
237
|
const url = `https://${LDA_HOST}${LDA_FILINGS_PATH}?${params.toString()}`;
|
|
227
238
|
// Belt-and-suspenders: the fixed host + strictly-built query leave nothing to
|
|
228
239
|
// steer the authority; assert the built URL cannot have been moved off-host.
|
|
@@ -266,8 +277,8 @@ export async function searchFilings(
|
|
|
266
277
|
kind: "invalid_input",
|
|
267
278
|
retryable: false,
|
|
268
279
|
message: apiMsg
|
|
269
|
-
? `LDA rejected the request (HTTP 400): ${apiMsg}. Check the filter parameters (filingYear, filingType,
|
|
270
|
-
: "LDA rejected the request (HTTP 400) — check the filter parameters (filingYear, filingType,
|
|
280
|
+
? `LDA rejected the request (HTTP 400): ${apiMsg}. Check the filter parameters (filingYear, filingType, issue, …).`
|
|
281
|
+
: "LDA rejected the request (HTTP 400) — check the filter parameters (filingYear, filingType, issue, …).",
|
|
271
282
|
upstreamStatus: 400,
|
|
272
283
|
upstreamEndpoint: LDA_FILINGS_LABEL,
|
|
273
284
|
});
|
|
@@ -310,6 +321,11 @@ export async function searchFilings(
|
|
|
310
321
|
`This is page ${page} (pageSize ${pageSize}) of ~${Math.ceil(totalAvailable / pageSize)} — pass page=${page + 1} for the next page.`,
|
|
311
322
|
);
|
|
312
323
|
}
|
|
324
|
+
if (filtersDropped.includes("agency")) {
|
|
325
|
+
notes.push(
|
|
326
|
+
"The `agency` filter was NOT applied: the keyless LDA /filings/ endpoint has no server-side government-entity filter (the API silently ignores it and returns the full corpus), so it is reported in filtersDropped rather than falsely narrowed — totalAvailable and filtersApplied reflect ONLY the filters actually applied. Government entities are nested per lobbying activity: each returned filing's lobbyingActivities[].governmentEntities lists them. To find lobbying that targeted a specific agency, narrow by registrantName/clientName/issue and inspect those nested governmentEntities.",
|
|
327
|
+
);
|
|
328
|
+
}
|
|
313
329
|
|
|
314
330
|
return withMeta(
|
|
315
331
|
{ filings },
|
|
@@ -319,7 +335,7 @@ export async function searchFilings(
|
|
|
319
335
|
returned,
|
|
320
336
|
totalAvailable,
|
|
321
337
|
filtersApplied,
|
|
322
|
-
filtersDropped
|
|
338
|
+
filtersDropped,
|
|
323
339
|
fieldsUnavailable: [],
|
|
324
340
|
pagination: { offset, limit: pageSize, hasMore, nextOffset },
|
|
325
341
|
notes,
|