@cliwant/mcp-sam-gov 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +279 -67
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +42 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +64 -0
- package/dist/cache.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +92 -17
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +247 -0
- package/dist/errors.js.map +1 -0
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +432 -15
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +114 -11
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3897 -295
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1664 -228
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +73 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +231 -127
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +303 -0
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +706 -191
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +290 -155
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4559 -327
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +2680 -925
- package/src/usitc.ts +420 -0
package/src/fpds.ts
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FPDS-NG (Federal Procurement Data System) — federal contract AWARD-ACTION
|
|
3
|
+
* records from the keyless public ATOM feed. The FIRST XML/ATOM source in the
|
|
4
|
+
* server (everything else is JSON via the getJson port; ADR-0012).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* ---------------
|
|
8
|
+
* FPDS-NG is the AUTHORITATIVE system-of-record for federal contract ACTIONS
|
|
9
|
+
* (each modification is its own transaction). USAspending.gov DERIVES its
|
|
10
|
+
* contract data FROM FPDS via a nightly ETL — it lags FPDS by 1-2 days and
|
|
11
|
+
* reshapes/enriches fields (sub-awards, Treasury-account linkage, a synthetic
|
|
12
|
+
* unique award id). This tool closes the "action-level / mod-level latest-truth"
|
|
13
|
+
* gap our usas_* tools (the transformed, lagged derivative) cannot.
|
|
14
|
+
*
|
|
15
|
+
* LIVE-VERIFIED 2026-07-12 (keyless, real HTTP probes, no key/cookie/session):
|
|
16
|
+
* GET https://www.fpds.gov/ezsearch/FEEDS/ATOM?FEEDNAME=PUBLIC&templateName=1.5.3&q=<Q>&start=<N>
|
|
17
|
+
* → HTTP 200 application/xml, an Atom 1.0 <feed>. Page size is FIXED at 10.
|
|
18
|
+
* The legacy ezSearch HTML variant (/ezsearch/search.do?...&feed=ATOM) 301s to
|
|
19
|
+
* sam.gov — the concrete justification for redirect:"error" + host-pinning.
|
|
20
|
+
*
|
|
21
|
+
* XML PARSE (bounded, fuzz-safe, dependency-free — the far.ts/gao.ts lineage):
|
|
22
|
+
* - entry slicer is an indexOf WALK ONLY (never a lazy regex — O(N) not O(N^2)
|
|
23
|
+
* on an unterminated <entry>), capped at MAX_ENTRIES.
|
|
24
|
+
* - leaf/attribute extractors are anchored, character-class ([^<]/[^"]) bounded
|
|
25
|
+
* regexes — no nested quantifiers, ReDoS-safe by construction.
|
|
26
|
+
* - attribute extraction is ELEMENT-SCOPED (M2): the attr is read from THAT
|
|
27
|
+
* element's own attr string, never a global description="…" scan (many FPDS
|
|
28
|
+
* elements share the `description`/`name` attribute names).
|
|
29
|
+
*
|
|
30
|
+
* HONESTY (the whole reason to ship this): the advertised total is a LOWER BOUND
|
|
31
|
+
* (±10) for >10 results, keyless deep-paging is capped (~200K) far below the
|
|
32
|
+
* advertised total, and a typo'd field name is a SILENT ZERO. All disclosed via
|
|
33
|
+
* totalIsLowerBound + _meta.notes; hasMore is page-fullness (never offset<total).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
37
|
+
import { driftError, getText } from "./datasource.js";
|
|
38
|
+
import { num } from "./coerce.js";
|
|
39
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
40
|
+
|
|
41
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js) so
|
|
42
|
+
// the fault suite's num-parity guard resolves the SAME `num` (fpds.num ===
|
|
43
|
+
// coerce.num === treasury.num === socrata.num — a num regression fails together).
|
|
44
|
+
export { num };
|
|
45
|
+
|
|
46
|
+
// ─── Fixed endpoint (SSRF core) ───────────────────────────────────
|
|
47
|
+
const FPDS_HOST = "www.fpds.gov";
|
|
48
|
+
const FPDS_PATH = "/ezsearch/FEEDS/ATOM";
|
|
49
|
+
const FPDS_ORIGIN_PATH = `https://${FPDS_HOST}${FPDS_PATH}`;
|
|
50
|
+
const FPDS_LABEL = "www.fpds.gov";
|
|
51
|
+
// WAF-friendly browser-ish UA (mirrors gao.ts convention).
|
|
52
|
+
const FPDS_UA =
|
|
53
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
|
54
|
+
|
|
55
|
+
const PAGE_SIZE = 10;
|
|
56
|
+
/** Hard cap on entries sliced per feed (page size is 10; anything past a small
|
|
57
|
+
* ceiling is drift/hostile). Bounds the parser against a megabyte of <entry>s. */
|
|
58
|
+
const MAX_ENTRIES = 25;
|
|
59
|
+
const LEAF_MAX = 8000; // bounded leaf text length (ReDoS-safe [^<]{0,LEAF_MAX})
|
|
60
|
+
const ATTR_MAX = 1000; // bounded attribute value length ([^"]{0,ATTR_MAX})
|
|
61
|
+
|
|
62
|
+
// ─── Disclosure notes (honesty obligations, §4 / M1 / B1) ─────────
|
|
63
|
+
/** M1 anti-livelock — MUST accompany every multi-page (rel="last") response. */
|
|
64
|
+
const ANTI_LIVELOCK_NOTE =
|
|
65
|
+
"IMPORTANT: do NOT paginate using totalAvailable. Use pagination.hasMore (page-fullness: returned===10) as the SOLE continuation signal. The advertised total is a lower bound (true count ∈ [totalAvailable, totalAvailable+9]); FPDS keyless deep-paging is capped (~200K reachable) far below the advertised total — narrow filters (agency/date/naics) to reach specific records.";
|
|
66
|
+
|
|
67
|
+
/** The §1e silent-zero disclosure — MUST accompany every genuine-empty response. */
|
|
68
|
+
const SILENT_ZERO_NOTE =
|
|
69
|
+
'FPDS returns an empty feed (HTTP 200) for BOTH a genuine zero-match AND an unrecognized field name / malformed query. If you expected results, re-check the filters (naics=PRINCIPAL_NAICS_CODE, contractingAgencyName, vendorName, piid, departmentId, signedDate*) and the date-range YYYY-MM-DD syntax.';
|
|
70
|
+
|
|
71
|
+
/** B1 — an empty page reached via deep paging (start>0) is AMBIGUOUS, not a total:0. */
|
|
72
|
+
const CEILING_AMBIGUITY_NOTE =
|
|
73
|
+
"0 results at this offset — AMBIGUOUS between the natural end of a short result set and FPDS's keyless deep-paging ceiling (empty pages appear somewhere past ~200K even when many more records exist). Not a reliable total:0; narrow the query with filters instead of deep-paging.";
|
|
74
|
+
|
|
75
|
+
/** The canonical-vs-derived disclosure (FPDS is source-of-record; USAspending is the lagged derivative). */
|
|
76
|
+
const FPDS_VS_USAS_NOTE =
|
|
77
|
+
"FPDS-NG is the AUTHORITATIVE system-of-record for federal contract ACTIONS (each modification is its own transaction). USAspending.gov derives its contract data FROM FPDS via a nightly ETL — it may lag FPDS by 1-2 days and reshapes/enriches fields (sub-awards, Treasury-account linkage, a synthetic unique award id). For latest mod-level / action-level truth, FPDS is the source; for spending rollups and sub-award graphs, prefer the usas_* tools.";
|
|
78
|
+
|
|
79
|
+
// FPDS derivations that live only in USAspending, never in the action feed.
|
|
80
|
+
const FIELDS_UNAVAILABLE = [
|
|
81
|
+
"subAwards",
|
|
82
|
+
"federalAccountLinkage",
|
|
83
|
+
"generatedUniqueAwardId",
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
87
|
+
// Fetch — delegated to the shared `getText` port (ADR-0013) with retry:false
|
|
88
|
+
// (the SINGLE-attempt m-redirect strategy) + redirect:"error" + the preserved
|
|
89
|
+
// redirect disclosure below. `isRedirectError` now lives in datasource.ts: the
|
|
90
|
+
// port catches the redirect:"error" TypeError on the sole attempt and classifies
|
|
91
|
+
// it as a NON-retryable schema_drift there (never routed through the retrying
|
|
92
|
+
// fetchWithRetry, which m-redirect forbids). A 5xx/429/404/timeout is classified
|
|
93
|
+
// + THROWS (never a fake empty). The XML parser / SSRF guard / _meta stay here.
|
|
94
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
95
|
+
|
|
96
|
+
/** The redirect disclosure surfaced when the legacy /ezsearch/search.do UI
|
|
97
|
+
* 301-redirects off-host to sam.gov (redirect:"error" fails closed). Preserved
|
|
98
|
+
* VERBATIM (ADR-0013 Q2) — it names the /ezsearch/FEEDS/ATOM machine feed, an
|
|
99
|
+
* honesty disclosure, not incidental text. Passed to getText as redirectMessage. */
|
|
100
|
+
const FPDS_REDIRECT_MSG = `FPDS fetch hit an off-host redirect (the legacy /ezsearch/search.do UI 301-redirects to sam.gov). Refused to follow it (redirect:"error"). This is NOT an empty result — use the /ezsearch/FEEDS/ATOM machine feed.`;
|
|
101
|
+
|
|
102
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
103
|
+
// q builder — structured filters → a fielded FPDS `q`. NO raw-q passthrough
|
|
104
|
+
// (a typo'd field name would be a silent zero). m-inject: strip embedded
|
|
105
|
+
// double-quotes from phrase values; strip FIELD: operators from free keywords.
|
|
106
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
107
|
+
|
|
108
|
+
/** A phrase value goes inside FIELD:"value" — strip embedded double-quotes so a
|
|
109
|
+
* crafted value cannot close the quote and inject a second FPDS field token. */
|
|
110
|
+
function cleanPhrase(v: string): string {
|
|
111
|
+
return v.replace(/"/g, "").replace(/\s+/g, " ").trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The bare keyword is UNquoted — strip embedded quotes AND FIELD: operators
|
|
115
|
+
* (UPPERCASE-word + colon) so a caller cannot inject FPDS query operators. */
|
|
116
|
+
function cleanKeyword(v: string): string {
|
|
117
|
+
return v
|
|
118
|
+
.replace(/"/g, " ")
|
|
119
|
+
.replace(/\b[A-Z][A-Z0-9_]{1,}:/g, " ")
|
|
120
|
+
.replace(/\s+/g, " ")
|
|
121
|
+
.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** YYYY-MM-DD → YYYY/MM/DD (the FPDS date-range syntax). Format is Zod-validated. */
|
|
125
|
+
function toFpdsDate(iso: string): string {
|
|
126
|
+
return iso.replace(/-/g, "/");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type FpdsSearchArgs = {
|
|
130
|
+
naics?: string;
|
|
131
|
+
vendorName?: string;
|
|
132
|
+
piid?: string;
|
|
133
|
+
departmentId?: string;
|
|
134
|
+
contractingAgencyName?: string;
|
|
135
|
+
signedDateFrom?: string;
|
|
136
|
+
signedDateTo?: string;
|
|
137
|
+
lastModifiedFrom?: string;
|
|
138
|
+
lastModifiedTo?: string;
|
|
139
|
+
keyword?: string;
|
|
140
|
+
offset?: number;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Build the fielded `q` string from structured filters (AND-combined by space). */
|
|
144
|
+
export function buildQuery(args: FpdsSearchArgs): {
|
|
145
|
+
q: string;
|
|
146
|
+
filters: string[];
|
|
147
|
+
dropped: string[];
|
|
148
|
+
suppliedEmpty: string[];
|
|
149
|
+
} {
|
|
150
|
+
const tokens: string[] = [];
|
|
151
|
+
const filters: string[] = [];
|
|
152
|
+
const dropped: string[] = [];
|
|
153
|
+
// D1 (no-silent-filter): a caller-SUPPLIED value that sanitizes to empty (a
|
|
154
|
+
// whitespace/quote-only phrase, or a keyword that is nothing but a stripped
|
|
155
|
+
// FIELD: operator) contributes NO token. Record its label so the handler can
|
|
156
|
+
// DISCLOSE it (partial case) or REFUSE (when it is the SOLE filter) — never
|
|
157
|
+
// silently drop it and run a bare unfiltered scan.
|
|
158
|
+
const suppliedEmpty: string[] = [];
|
|
159
|
+
const phrase = (field: string, val: string | undefined, label: string) => {
|
|
160
|
+
if (val === undefined) return;
|
|
161
|
+
const c = cleanPhrase(val);
|
|
162
|
+
if (c.length === 0) {
|
|
163
|
+
suppliedEmpty.push(label);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
tokens.push(`${field}:"${c}"`);
|
|
167
|
+
filters.push(label);
|
|
168
|
+
};
|
|
169
|
+
phrase("PRINCIPAL_NAICS_CODE", args.naics, "naics");
|
|
170
|
+
phrase("VENDOR_NAME", args.vendorName, "vendorName");
|
|
171
|
+
phrase("PIID", args.piid, "piid");
|
|
172
|
+
phrase("DEPARTMENT_ID", args.departmentId, "departmentId");
|
|
173
|
+
phrase("CONTRACTING_AGENCY_NAME", args.contractingAgencyName, "contractingAgencyName");
|
|
174
|
+
// FPDS date ranges REQUIRE both ends (SIGNED_DATE:[from,to]) — FPDS has no
|
|
175
|
+
// open-ended date syntax. F5 (P4 no-silent-filter): when BOTH ends are present
|
|
176
|
+
// we apply the range; when only ONE end is supplied (orphaned) it CANNOT be
|
|
177
|
+
// sent, so we DISCLOSE it in `dropped` rather than silently ignoring the lone
|
|
178
|
+
// bound (which — when the request is satisfied by another filter like naics —
|
|
179
|
+
// would otherwise vanish: not in filtersApplied, not in filtersDropped).
|
|
180
|
+
if (args.signedDateFrom !== undefined && args.signedDateTo !== undefined) {
|
|
181
|
+
tokens.push(`SIGNED_DATE:[${toFpdsDate(args.signedDateFrom)},${toFpdsDate(args.signedDateTo)}]`);
|
|
182
|
+
filters.push("signedDate");
|
|
183
|
+
} else if (args.signedDateFrom !== undefined) {
|
|
184
|
+
dropped.push("signedDateFrom(needs signedDateTo — FPDS requires both date-range ends)");
|
|
185
|
+
} else if (args.signedDateTo !== undefined) {
|
|
186
|
+
dropped.push("signedDateTo(needs signedDateFrom — FPDS requires both date-range ends)");
|
|
187
|
+
}
|
|
188
|
+
if (args.lastModifiedFrom !== undefined && args.lastModifiedTo !== undefined) {
|
|
189
|
+
tokens.push(`LAST_MOD_DATE:[${toFpdsDate(args.lastModifiedFrom)},${toFpdsDate(args.lastModifiedTo)}]`);
|
|
190
|
+
filters.push("lastModifiedDate");
|
|
191
|
+
} else if (args.lastModifiedFrom !== undefined) {
|
|
192
|
+
dropped.push("lastModifiedFrom(needs lastModifiedTo — FPDS requires both date-range ends)");
|
|
193
|
+
} else if (args.lastModifiedTo !== undefined) {
|
|
194
|
+
dropped.push("lastModifiedTo(needs lastModifiedFrom — FPDS requires both date-range ends)");
|
|
195
|
+
}
|
|
196
|
+
if (args.keyword !== undefined) {
|
|
197
|
+
const k = cleanKeyword(args.keyword);
|
|
198
|
+
if (k.length > 0) {
|
|
199
|
+
tokens.push(k);
|
|
200
|
+
filters.push("keyword");
|
|
201
|
+
} else {
|
|
202
|
+
suppliedEmpty.push("keyword");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { q: tokens.join(" "), filters, dropped, suppliedEmpty };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Construct the SSRF-safe feed URL. Everything except `q`+`start` is fixed
|
|
210
|
+
* (host, path, FEEDNAME, templateName); the caller-influenced values go through
|
|
211
|
+
* URLSearchParams (which percent-encodes ` " : [ ] , & #`), so a value can add
|
|
212
|
+
* a query param but CANNOT alter the host or path. Then a belt-and-suspenders
|
|
213
|
+
* hostname/protocol assertion (verbatim ckan.ts pattern).
|
|
214
|
+
*/
|
|
215
|
+
export function buildSearchUrl(q: string, start: number): string {
|
|
216
|
+
const params = new URLSearchParams();
|
|
217
|
+
params.set("FEEDNAME", "PUBLIC");
|
|
218
|
+
params.set("templateName", "1.5.3");
|
|
219
|
+
params.set("q", q);
|
|
220
|
+
params.set("start", String(start));
|
|
221
|
+
const url = `${FPDS_ORIGIN_PATH}?${params.toString()}`;
|
|
222
|
+
const built = new URL(url);
|
|
223
|
+
if (built.hostname !== FPDS_HOST || built.protocol !== "https:") {
|
|
224
|
+
throw new ToolErrorCarrier({
|
|
225
|
+
kind: "invalid_input",
|
|
226
|
+
message: `Constructed FPDS URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${FPDS_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
227
|
+
retryable: false,
|
|
228
|
+
upstreamEndpoint: FPDS_LABEL,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return url;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
235
|
+
// XML parse — bounded, ReDoS-safe, dependency-free.
|
|
236
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
237
|
+
|
|
238
|
+
/** Decode the handful of XML entities FPDS emits (CDATA handled separately). */
|
|
239
|
+
function decode(s: string): string {
|
|
240
|
+
return s
|
|
241
|
+
.replace(/</g, "<")
|
|
242
|
+
.replace(/>/g, ">")
|
|
243
|
+
.replace(/"/g, '"')
|
|
244
|
+
.replace(/�?39;/g, "'")
|
|
245
|
+
.replace(/'/g, "'")
|
|
246
|
+
.replace(/'/g, "'")
|
|
247
|
+
// & LAST so an already-decoded entity is not double-decoded.
|
|
248
|
+
.replace(/&/g, "&")
|
|
249
|
+
.trim();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Compiled-regex caches (bounded, anchored, character-class — ReDoS-safe).
|
|
253
|
+
const elCache = new Map<string, RegExp>();
|
|
254
|
+
function elRe(name: string): RegExp {
|
|
255
|
+
let re = elCache.get(name);
|
|
256
|
+
if (!re) {
|
|
257
|
+
// <ns1:NAME [attrs]>leaf</ns1:NAME> — leaf is [^<]{0,LEAF_MAX} (cannot cross a
|
|
258
|
+
// tag boundary, cannot backtrack catastrophically).
|
|
259
|
+
re = new RegExp(`<ns1:${name}\\b([^>]*)>([^<]{0,${LEAF_MAX}})</ns1:${name}>`);
|
|
260
|
+
elCache.set(name, re);
|
|
261
|
+
}
|
|
262
|
+
return re;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const attrCache = new Map<string, RegExp>();
|
|
266
|
+
function attrRe(name: string): RegExp {
|
|
267
|
+
let re = attrCache.get(name);
|
|
268
|
+
if (!re) {
|
|
269
|
+
re = new RegExp(`\\b${name}="([^"]{0,${ATTR_MAX}})"`);
|
|
270
|
+
attrCache.set(name, re);
|
|
271
|
+
}
|
|
272
|
+
return re;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
type Leaf = { text: string | null; attrs: string };
|
|
276
|
+
|
|
277
|
+
/** First <ns1:NAME …>text</ns1:NAME> within `scope`; returns its text + own
|
|
278
|
+
* attr string (for M2 element-scoped attribute reads). Missing → null text. */
|
|
279
|
+
function el(scope: string, name: string): Leaf | null {
|
|
280
|
+
const m = elRe(name).exec(scope);
|
|
281
|
+
if (!m) return null;
|
|
282
|
+
const raw = m[2] ?? "";
|
|
283
|
+
return { attrs: m[1] ?? "", text: raw === "" ? "" : decode(raw) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The text of the first <ns1:NAME> leaf, or null when absent. */
|
|
287
|
+
function leafText(scope: string, name: string): string | null {
|
|
288
|
+
const l = el(scope, name);
|
|
289
|
+
if (!l || l.text === null || l.text === "") return null;
|
|
290
|
+
return l.text;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** M2 — read attribute `attr` ONLY from element `name`'s own attr string
|
|
294
|
+
* (never a global scan across the entry, where the wrong element would win). */
|
|
295
|
+
function elAttr(scope: string, name: string, attr: string): string | null {
|
|
296
|
+
const l = el(scope, name);
|
|
297
|
+
if (!l) return null;
|
|
298
|
+
const m = attrRe(attr).exec(l.attrs);
|
|
299
|
+
return m && m[1] !== undefined ? decode(m[1]) : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A named amount leaf coerced null-never-0 (real "0.00"→0 and negatives kept). */
|
|
303
|
+
function leafNum(scope: string, name: string): number | null {
|
|
304
|
+
const l = el(scope, name);
|
|
305
|
+
return l ? num(l.text) : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Slice a nested container block <ns1:NAME …>…</ns1:NAME> (for path
|
|
309
|
+
* disambiguation: referencedIDVID vs awardContractID, PoP-state vs vendor-state). */
|
|
310
|
+
function blockOf(scope: string, name: string): string | null {
|
|
311
|
+
const openLit = `<ns1:${name}`;
|
|
312
|
+
const closeLit = `</ns1:${name}>`;
|
|
313
|
+
let from = 0;
|
|
314
|
+
for (;;) {
|
|
315
|
+
const s = scope.indexOf(openLit, from);
|
|
316
|
+
if (s === -1) return null;
|
|
317
|
+
// Boundary: the char after the element name must NOT be a name char (avoid
|
|
318
|
+
// <ns1:referencedIDVID matching inside <ns1:referencedIDVMultipleOrSingle).
|
|
319
|
+
const nextCh = scope.charAt(s + openLit.length);
|
|
320
|
+
if (nextCh === ">" || nextCh === " " || nextCh === "\t" || nextCh === "\n" || nextCh === "\r" || nextCh === "/") {
|
|
321
|
+
const e = scope.indexOf(closeLit, s);
|
|
322
|
+
if (e === -1) return null;
|
|
323
|
+
return scope.slice(s, e + closeLit.length);
|
|
324
|
+
}
|
|
325
|
+
from = s + openLit.length;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** CDATA-aware text of the Atom <title> (m-cdata — the title uses <![CDATA[…]]>).
|
|
330
|
+
* Strips the CDATA MARKERS (never a String.replace with the captured content —
|
|
331
|
+
* that would interpret a `$1` inside an FPDS amount like "$107,271" as a
|
|
332
|
+
* back-reference and corrupt the title). Keeps leading text + CDATA content. */
|
|
333
|
+
function atomTitle(entry: string): string | null {
|
|
334
|
+
const m = /<title\b[^>]*>([\s\S]{0,4000}?)<\/title>/.exec(entry);
|
|
335
|
+
if (!m || m[1] === undefined) return null;
|
|
336
|
+
const inner = m[1].replace(/<!\[CDATA\[/g, "").replace(/\]\]>/g, "");
|
|
337
|
+
const t = decode(inner);
|
|
338
|
+
return t.length > 0 ? t : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The entry's rel="alternate" HTML link (the FPDS display URL; NEVER fetched). */
|
|
342
|
+
function alternateHref(entry: string): string | null {
|
|
343
|
+
const link = /<link\b[^>]*\brel="alternate"[^>]*>/.exec(entry);
|
|
344
|
+
if (!link) return null;
|
|
345
|
+
const href = /\bhref="([^"]{0,2000})"/.exec(link[0]);
|
|
346
|
+
return href && href[1] !== undefined ? decode(href[1]) : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export type FpdsAward = {
|
|
350
|
+
recordType: "award" | "idv";
|
|
351
|
+
title: string | null;
|
|
352
|
+
piid: string | null;
|
|
353
|
+
modNumber: string | null;
|
|
354
|
+
parentIdvPiid: string | null;
|
|
355
|
+
actionType: string | null;
|
|
356
|
+
reasonForModification: string | null;
|
|
357
|
+
signedDate: string | null;
|
|
358
|
+
contractingDepartmentId: string | null;
|
|
359
|
+
contractingDepartmentName: string | null;
|
|
360
|
+
contractingOfficeAgencyId: string | null;
|
|
361
|
+
contractingOfficeAgencyName: string | null;
|
|
362
|
+
vendorName: string | null;
|
|
363
|
+
vendorUei: string | null;
|
|
364
|
+
ultimateParentUei: string | null;
|
|
365
|
+
ultimateParentUeiName: string | null;
|
|
366
|
+
cageCode: string | null;
|
|
367
|
+
vendorCity: string | null;
|
|
368
|
+
vendorState: string | null;
|
|
369
|
+
businessSize: string | null;
|
|
370
|
+
obligatedAmount: number | null;
|
|
371
|
+
totalObligatedAmount: number | null;
|
|
372
|
+
baseAndAllOptionsValue: number | null;
|
|
373
|
+
naics: string | null;
|
|
374
|
+
naicsDescription: string | null;
|
|
375
|
+
psc: string | null;
|
|
376
|
+
pscDescription: string | null;
|
|
377
|
+
description: string | null;
|
|
378
|
+
placeOfPerformanceState: string | null;
|
|
379
|
+
placeOfPerformanceCity: string | null;
|
|
380
|
+
extentCompeted: string | null;
|
|
381
|
+
offersReceived: number | null;
|
|
382
|
+
setAside: string | null;
|
|
383
|
+
socioeconomic: {
|
|
384
|
+
smallBusiness: boolean | null;
|
|
385
|
+
womenOwned: boolean | null;
|
|
386
|
+
veteranOwned: boolean | null;
|
|
387
|
+
};
|
|
388
|
+
fpdsHtmlUrl: string | null;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** "true"/"false" leaf → boolean; absent → null (never a fabricated false). */
|
|
392
|
+
function boolLeaf(scope: string, name: string): boolean | null {
|
|
393
|
+
const t = leafText(scope, name);
|
|
394
|
+
if (t === null) return null;
|
|
395
|
+
if (t === "true") return true;
|
|
396
|
+
if (t === "false") return false;
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Parse ONE entry block (content root is <ns1:award> OR <ns1:IDV>; the flat
|
|
401
|
+
* extractor is not path-sensitive so both roots parse identically). */
|
|
402
|
+
function parseEntry(entry: string): FpdsAward {
|
|
403
|
+
const recordType: "award" | "idv" = /<ns1:IDV\b/.test(entry) ? "idv" : "award";
|
|
404
|
+
// parentIdvPiid: the PIID INSIDE referencedIDVID (an award's parent vehicle);
|
|
405
|
+
// scoped so it is never confused with the award's own (first) PIID.
|
|
406
|
+
const refIdv = blockOf(entry, "referencedIDVID");
|
|
407
|
+
const vendorLoc = blockOf(entry, "vendorLocation");
|
|
408
|
+
const pop = blockOf(entry, "placeOfPerformance");
|
|
409
|
+
return {
|
|
410
|
+
recordType,
|
|
411
|
+
title: atomTitle(entry),
|
|
412
|
+
piid: leafText(entry, "PIID"),
|
|
413
|
+
modNumber: leafText(entry, "modNumber"),
|
|
414
|
+
parentIdvPiid: refIdv ? leafText(refIdv, "PIID") : null,
|
|
415
|
+
actionType: elAttr(entry, "contractActionType", "description"),
|
|
416
|
+
reasonForModification: elAttr(entry, "reasonForModification", "description"),
|
|
417
|
+
signedDate: leafText(entry, "signedDate"),
|
|
418
|
+
contractingDepartmentId: leafText(entry, "agencyID"),
|
|
419
|
+
contractingDepartmentName: elAttr(entry, "agencyID", "name"),
|
|
420
|
+
contractingOfficeAgencyId: leafText(entry, "contractingOfficeAgencyID"),
|
|
421
|
+
contractingOfficeAgencyName: elAttr(entry, "contractingOfficeAgencyID", "name"),
|
|
422
|
+
vendorName: leafText(entry, "vendorName"),
|
|
423
|
+
vendorUei: leafText(entry, "UEI"),
|
|
424
|
+
ultimateParentUei: leafText(entry, "ultimateParentUEI"),
|
|
425
|
+
ultimateParentUeiName: leafText(entry, "ultimateParentUEIName"),
|
|
426
|
+
cageCode: leafText(entry, "cageCode"),
|
|
427
|
+
vendorCity: vendorLoc ? leafText(vendorLoc, "city") : null,
|
|
428
|
+
vendorState: vendorLoc ? leafText(vendorLoc, "state") : null,
|
|
429
|
+
businessSize: elAttr(entry, "contractingOfficerBusinessSizeDetermination", "description"),
|
|
430
|
+
obligatedAmount: leafNum(entry, "obligatedAmount"),
|
|
431
|
+
totalObligatedAmount: leafNum(entry, "totalObligatedAmount"),
|
|
432
|
+
baseAndAllOptionsValue: leafNum(entry, "baseAndAllOptionsValue"),
|
|
433
|
+
naics: leafText(entry, "principalNAICSCode"),
|
|
434
|
+
naicsDescription: elAttr(entry, "principalNAICSCode", "description"),
|
|
435
|
+
psc: leafText(entry, "productOrServiceCode"),
|
|
436
|
+
pscDescription: elAttr(entry, "productOrServiceCode", "description"),
|
|
437
|
+
description: leafText(entry, "descriptionOfContractRequirement"),
|
|
438
|
+
placeOfPerformanceState: pop ? leafText(pop, "stateCode") : null,
|
|
439
|
+
placeOfPerformanceCity: pop ? leafText(pop, "city") : null,
|
|
440
|
+
extentCompeted: elAttr(entry, "extentCompeted", "description"),
|
|
441
|
+
offersReceived: leafNum(entry, "numberOfOffersReceived"),
|
|
442
|
+
setAside: elAttr(entry, "typeOfSetAside", "description") ?? elAttr(entry, "idvTypeOfSetAside", "description"),
|
|
443
|
+
socioeconomic: {
|
|
444
|
+
smallBusiness: boolLeaf(entry, "isSmallBusiness"),
|
|
445
|
+
womenOwned: boolLeaf(entry, "isWomenOwned"),
|
|
446
|
+
veteranOwned: boolLeaf(entry, "isVeteranOwned"),
|
|
447
|
+
},
|
|
448
|
+
fpdsHtmlUrl: alternateHref(entry),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Slice <entry>…</entry> blocks with an indexOf WALK ONLY (O(N), never a lazy
|
|
453
|
+
* regex), capped at MAX_ENTRIES. An unterminated <entry> stops the walk. */
|
|
454
|
+
function sliceEntries(xml: string): string[] {
|
|
455
|
+
const out: string[] = [];
|
|
456
|
+
let pos = 0;
|
|
457
|
+
while (out.length < MAX_ENTRIES) {
|
|
458
|
+
const s = xml.indexOf("<entry", pos);
|
|
459
|
+
if (s === -1) break;
|
|
460
|
+
const e = xml.indexOf("</entry>", s);
|
|
461
|
+
if (e === -1) break; // tolerate truncation — never hang
|
|
462
|
+
out.push(xml.slice(s, e));
|
|
463
|
+
pos = e + 8; // len("</entry>")
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The `start` of the rel="last" link, or null when absent (≤10 results). */
|
|
469
|
+
function lastStart(xml: string): number | null {
|
|
470
|
+
const link = /<link\b[^>]*\brel="last"[^>]*>/.exec(xml);
|
|
471
|
+
if (!link) return null;
|
|
472
|
+
const m = /[?&](?:amp;)?start=(\d{1,9})/.exec(link[0]);
|
|
473
|
+
if (!m || m[1] === undefined) return null;
|
|
474
|
+
const n = Number(m[1]);
|
|
475
|
+
return Number.isFinite(n) ? n : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Is this body an Atom <feed> (not an HTML error page / redirect target)? */
|
|
479
|
+
function looksLikeAtomFeed(xml: string): boolean {
|
|
480
|
+
const head = xml.slice(0, 4000);
|
|
481
|
+
if (/<!doctype html/i.test(head) || /<html[\s>]/i.test(head)) return false;
|
|
482
|
+
return /<feed\b/.test(head) && head.includes("http://www.w3.org/2005/Atom");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
486
|
+
// Tool — fpds_search_awards
|
|
487
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
488
|
+
|
|
489
|
+
export async function searchAwards(args: FpdsSearchArgs): Promise<MetaBundle> {
|
|
490
|
+
const start = args.offset ?? 0;
|
|
491
|
+
const { q, filters, dropped, suppliedEmpty } = buildQuery(args);
|
|
492
|
+
|
|
493
|
+
// D1 (no-silent-filter): the caller supplied filter(s) that ALL sanitized to an
|
|
494
|
+
// EMPTY query (a whitespace/quote-only value, or a keyword that is nothing but a
|
|
495
|
+
// stripped FIELD: operator), leaving no effective filter. Refuse (invalid_input, 0
|
|
496
|
+
// fetch) — do NOT run a bare unfiltered FPDS scan that would return the newest
|
|
497
|
+
// UNRELATED awards AS IF they matched the caller's filter. (A genuine NO-filter call
|
|
498
|
+
// is refused earlier at the server's Zod .refine boundary; this closes the
|
|
499
|
+
// sanitize-to-empty BYPASS that slips a present-but-empty filter past that check —
|
|
500
|
+
// the specific bare-scan masquerade this fix targets.)
|
|
501
|
+
if (q === "" && suppliedEmpty.length > 0) {
|
|
502
|
+
throw new ToolErrorCarrier({
|
|
503
|
+
kind: "invalid_input",
|
|
504
|
+
message: `The supplied FPDS filter(s) [${suppliedEmpty.join(", ")}] sanitized to an EMPTY query (a whitespace/quote-only value, or a keyword that is only a stripped FIELD: operator), leaving no effective filter. Refusing to run a bare unfiltered FPDS scan (it would return the newest UNRELATED awards as if they matched). Provide a filter value with real content: naics, vendorName, piid, departmentId, contractingAgencyName, a signedDate/lastModified range, or a distinctive keyword.`,
|
|
505
|
+
retryable: false,
|
|
506
|
+
upstreamEndpoint: FPDS_LABEL,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const url = buildSearchUrl(q, start);
|
|
511
|
+
const xml = await getText(url, {
|
|
512
|
+
label: FPDS_LABEL,
|
|
513
|
+
headers: {
|
|
514
|
+
"User-Agent": FPDS_UA,
|
|
515
|
+
Accept: "application/atom+xml, application/xml;q=0.9, */*;q=0.8",
|
|
516
|
+
},
|
|
517
|
+
redirect: "error",
|
|
518
|
+
retry: false,
|
|
519
|
+
redirectMessage: FPDS_REDIRECT_MSG,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Feed guard FIRST — an FPDS/edge HTML error page or a redirect target served
|
|
523
|
+
// as 200 is schema DRIFT, never a fake-empty result.
|
|
524
|
+
if (!looksLikeAtomFeed(xml)) {
|
|
525
|
+
throw driftError(
|
|
526
|
+
FPDS_LABEL,
|
|
527
|
+
"FPDS returned HTTP 200 but the body is not an Atom <feed> (an HTML error page, an interstitial, or a redirect target). Refusing to report it as an empty result.",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const advertisedLastStart = lastStart(xml);
|
|
532
|
+
const awards = sliceEntries(xml).map(parseEntry);
|
|
533
|
+
const returned = awards.length;
|
|
534
|
+
|
|
535
|
+
// M3 — namespace-drift guard: a non-empty feed where EVERY entry yields a null
|
|
536
|
+
// piid means the ns1: prefix / award schema drifted. Never return hollow rows.
|
|
537
|
+
if (returned > 0 && awards.every((a) => a.piid === null)) {
|
|
538
|
+
throw driftError(
|
|
539
|
+
FPDS_LABEL,
|
|
540
|
+
"non-empty ATOM feed but all entries yielded null piid — ns1: prefix / award schema drifted (refusing to return a page of hollow records).",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// hasMore is page-fullness ONLY (never offset<total) — the advertised total
|
|
545
|
+
// over-promises reachable pages (deep-paging ceiling).
|
|
546
|
+
const hasMore = returned === PAGE_SIZE;
|
|
547
|
+
const nextOffset = hasMore ? start + PAGE_SIZE : null;
|
|
548
|
+
const pagination = { offset: start, limit: PAGE_SIZE, nextOffset, hasMore };
|
|
549
|
+
|
|
550
|
+
const notes: string[] = [];
|
|
551
|
+
let totalAvailable: number | null;
|
|
552
|
+
let totalIsLowerBound: boolean | undefined;
|
|
553
|
+
let complete: boolean | undefined;
|
|
554
|
+
|
|
555
|
+
if (advertisedLastStart !== null) {
|
|
556
|
+
// Multi-page: totalAvailable is a KNOWN LOWER BOUND (true count ∈ [N, N+9]).
|
|
557
|
+
totalAvailable = advertisedLastStart + 1;
|
|
558
|
+
totalIsLowerBound = true;
|
|
559
|
+
complete = undefined; // buildMeta derives from pagination.hasMore
|
|
560
|
+
notes.push(ANTI_LIVELOCK_NOTE);
|
|
561
|
+
} else if (returned === 0) {
|
|
562
|
+
if (start === 0) {
|
|
563
|
+
// Genuine-empty (page 0, no rel="last"): an honest exact zero.
|
|
564
|
+
totalAvailable = 0;
|
|
565
|
+
complete = true;
|
|
566
|
+
notes.push(SILENT_ZERO_NOTE);
|
|
567
|
+
} else {
|
|
568
|
+
// B1 ceiling-hit: an empty page reached via deep paging is AMBIGUOUS.
|
|
569
|
+
totalAvailable = null;
|
|
570
|
+
complete = false;
|
|
571
|
+
notes.push(CEILING_AMBIGUITY_NOTE);
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
// ≤10 results (no rel="last"): this is the last page → total is EXACT.
|
|
575
|
+
totalAvailable = start + returned;
|
|
576
|
+
complete = start === 0 ? undefined : false;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
notes.push(FPDS_VS_USAS_NOTE);
|
|
580
|
+
|
|
581
|
+
// F5 (P4): disclose any orphaned lone date bound that could not be applied, so
|
|
582
|
+
// the results are never silently presented as date-filtered on that facet.
|
|
583
|
+
if (dropped.length > 0) {
|
|
584
|
+
notes.push(
|
|
585
|
+
`An open-ended date filter was supplied but FPDS date ranges require BOTH ends — the lone bound(s) [${dropped.join("; ")}] were NOT applied and appear in _meta.filtersDropped (results are UNFILTERED on that date facet). Supply both signedDateFrom+signedDateTo (or lastModifiedFrom+lastModifiedTo) for a date-bounded search.`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// D1 (no-silent-filter), partial case: a value filter that sanitized to empty
|
|
590
|
+
// ALONGSIDE a still-valid filter (so q is non-empty and the all-empty refusal
|
|
591
|
+
// above did NOT fire). Disclose it in filtersDropped + a note — results are
|
|
592
|
+
// UNFILTERED on that facet — rather than silently discarding it.
|
|
593
|
+
const filtersDropped = [...dropped];
|
|
594
|
+
if (suppliedEmpty.length > 0) {
|
|
595
|
+
for (const label of suppliedEmpty) {
|
|
596
|
+
filtersDropped.push(
|
|
597
|
+
`${label}(supplied but sanitized to empty — no effective filter contributed)`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
notes.push(
|
|
601
|
+
`One or more supplied filters sanitized to an EMPTY value (whitespace/quote-only, or a stripped FIELD: operator) and were NOT applied: [${suppliedEmpty.join(", ")}] — results are UNFILTERED on those facets. Supply real content for those filters.`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const meta: Partial<ResponseMeta> = {
|
|
606
|
+
source: "www.fpds.gov ezSearch ATOM (FPDS-NG, keyless)",
|
|
607
|
+
keylessMode: true,
|
|
608
|
+
returned,
|
|
609
|
+
totalAvailable,
|
|
610
|
+
filtersApplied: filters,
|
|
611
|
+
filtersDropped,
|
|
612
|
+
fieldsUnavailable: FIELDS_UNAVAILABLE,
|
|
613
|
+
pagination,
|
|
614
|
+
notes,
|
|
615
|
+
};
|
|
616
|
+
if (totalIsLowerBound !== undefined) meta.totalIsLowerBound = totalIsLowerBound;
|
|
617
|
+
if (complete !== undefined) meta.complete = complete;
|
|
618
|
+
|
|
619
|
+
return withMeta({ query: q, returned, totalAvailable, awards }, meta);
|
|
620
|
+
}
|