@cliwant/mcp-sam-gov 1.11.0 → 1.13.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 +17 -13
- package/README.ko.md +17 -13
- package/README.md +25 -16
- package/dist/arcgis-feature.d.ts.map +1 -1
- package/dist/arcgis-feature.js +11 -0
- package/dist/arcgis-feature.js.map +1 -1
- package/dist/bls.d.ts.map +1 -1
- package/dist/bls.js +6 -1
- package/dist/bls.js.map +1 -1
- package/dist/bonfire.d.ts +1 -1
- package/dist/bonfire.d.ts.map +1 -1
- package/dist/bonfire.js +14 -3
- package/dist/bonfire.js.map +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +10 -1
- package/dist/census-economic.js.map +1 -1
- package/dist/ckan.d.ts.map +1 -1
- package/dist/ckan.js +17 -3
- package/dist/ckan.js.map +1 -1
- package/dist/clinicaltrials.d.ts.map +1 -1
- package/dist/clinicaltrials.js +23 -6
- package/dist/clinicaltrials.js.map +1 -1
- package/dist/echo.d.ts +6 -2
- package/dist/echo.d.ts.map +1 -1
- package/dist/echo.js +12 -6
- package/dist/echo.js.map +1 -1
- package/dist/fdic.js +7 -7
- package/dist/fdic.js.map +1 -1
- package/dist/fema.d.ts.map +1 -1
- package/dist/fema.js +13 -1
- package/dist/fema.js.map +1 -1
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +13 -3
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +9 -0
- package/dist/gsa-csv.d.ts.map +1 -1
- package/dist/gsa-csv.js +37 -8
- package/dist/gsa-csv.js.map +1 -1
- package/dist/nppes.d.ts.map +1 -1
- package/dist/nppes.js +17 -4
- package/dist/nppes.js.map +1 -1
- package/dist/nsf.d.ts.map +1 -1
- package/dist/nsf.js +9 -0
- package/dist/nsf.js.map +1 -1
- package/dist/open-checkbook.d.ts +65 -0
- package/dist/open-checkbook.d.ts.map +1 -0
- package/dist/open-checkbook.js +166 -0
- package/dist/open-checkbook.js.map +1 -0
- package/dist/openfda-device.d.ts.map +1 -1
- package/dist/openfda-device.js +8 -5
- package/dist/openfda-device.js.map +1 -1
- package/dist/openfda-drugsfda.d.ts.map +1 -1
- package/dist/openfda-drugsfda.js +8 -5
- package/dist/openfda-drugsfda.js.map +1 -1
- package/dist/openfda.d.ts +21 -0
- package/dist/openfda.d.ts.map +1 -1
- package/dist/openfda.js +38 -4
- package/dist/openfda.js.map +1 -1
- package/dist/opengov.d.ts.map +1 -1
- package/dist/opengov.js +8 -4
- package/dist/opengov.js.map +1 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +101 -10
- package/dist/server.js.map +1 -1
- package/dist/socrata.d.ts.map +1 -1
- package/dist/socrata.js +14 -7
- package/dist/socrata.js.map +1 -1
- package/dist/tableau.d.ts +58 -0
- package/dist/tableau.d.ts.map +1 -0
- package/dist/tableau.js +132 -0
- package/dist/tableau.js.map +1 -0
- package/dist/usaspending.d.ts +2 -2
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +21 -10
- package/dist/usaspending.js.map +1 -1
- package/package.json +2 -2
- package/src/arcgis-feature.ts +11 -0
- package/src/bls.ts +8 -1
- package/src/bonfire.ts +14 -3
- package/src/census-economic.ts +13 -2
- package/src/ckan.ts +19 -3
- package/src/clinicaltrials.ts +25 -8
- package/src/echo.ts +12 -6
- package/src/fdic.ts +7 -7
- package/src/fema.ts +15 -1
- package/src/grants.ts +15 -2
- package/src/gsa-csv.ts +38 -7
- package/src/nppes.ts +18 -4
- package/src/nsf.ts +10 -0
- package/src/open-checkbook.ts +183 -0
- package/src/openfda-device.ts +10 -4
- package/src/openfda-drugsfda.ts +10 -4
- package/src/openfda.ts +47 -5
- package/src/opengov.ts +8 -4
- package/src/server.ts +110 -10
- package/src/socrata.ts +14 -7
- package/src/tableau.ts +159 -0
- package/src/usaspending.ts +21 -10
package/src/fdic.ts
CHANGED
|
@@ -523,7 +523,7 @@ export async function searchInstitutions(args: {
|
|
|
523
523
|
const records = env.records.map(mapInstitution);
|
|
524
524
|
const returned = records.length;
|
|
525
525
|
const totalAvailable = env.totalAvailable;
|
|
526
|
-
const hasMore = offset + returned < totalAvailable;
|
|
526
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
527
527
|
const nextOffset = hasMore ? offset + returned : null;
|
|
528
528
|
|
|
529
529
|
const filtersApplied: string[] = [];
|
|
@@ -618,7 +618,7 @@ export async function institutionFinancials(args: {
|
|
|
618
618
|
const records = env.records.map(mapFinancials);
|
|
619
619
|
const returned = records.length;
|
|
620
620
|
const totalAvailable = env.totalAvailable;
|
|
621
|
-
const hasMore = offset + returned < totalAvailable;
|
|
621
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
622
622
|
const nextOffset = hasMore ? offset + returned : null;
|
|
623
623
|
|
|
624
624
|
const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
|
|
@@ -862,7 +862,7 @@ export async function bankFailures(args: {
|
|
|
862
862
|
const records = env.records.map(mapFailure);
|
|
863
863
|
const returned = records.length;
|
|
864
864
|
const totalAvailable = env.totalAvailable;
|
|
865
|
-
const hasMore = offset + returned < totalAvailable;
|
|
865
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
866
866
|
const nextOffset = hasMore ? offset + returned : null;
|
|
867
867
|
|
|
868
868
|
const filtersApplied: string[] = [];
|
|
@@ -1184,7 +1184,7 @@ export async function institutionHistory(args: {
|
|
|
1184
1184
|
const records = env.records.map(mapHistory);
|
|
1185
1185
|
const returned = records.length;
|
|
1186
1186
|
const totalAvailable = env.totalAvailable;
|
|
1187
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1187
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1188
1188
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1189
1189
|
|
|
1190
1190
|
const filtersApplied: string[] = [];
|
|
@@ -1524,7 +1524,7 @@ export async function industrySummary(args: {
|
|
|
1524
1524
|
const records = env.records.map(mapSummary);
|
|
1525
1525
|
const returned = records.length;
|
|
1526
1526
|
const totalAvailable = env.totalAvailable;
|
|
1527
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1527
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1528
1528
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1529
1529
|
|
|
1530
1530
|
const filtersApplied: string[] = [];
|
|
@@ -1820,7 +1820,7 @@ export async function riskRatios(args: {
|
|
|
1820
1820
|
const records = env.records.map(mapRiskRatios);
|
|
1821
1821
|
const returned = records.length;
|
|
1822
1822
|
const totalAvailable = env.totalAvailable;
|
|
1823
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1823
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1824
1824
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1825
1825
|
|
|
1826
1826
|
const filtersApplied: string[] = ["cert"];
|
|
@@ -2013,7 +2013,7 @@ export async function branchDeposits(args: {
|
|
|
2013
2013
|
const records = env.records.map(mapBranchDeposit);
|
|
2014
2014
|
const returned = records.length;
|
|
2015
2015
|
const totalAvailable = env.totalAvailable;
|
|
2016
|
-
const hasMore = offset + returned < totalAvailable;
|
|
2016
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
2017
2017
|
const nextOffset = hasMore ? offset + returned : null;
|
|
2018
2018
|
|
|
2019
2019
|
const filtersApplied: string[] = [];
|
package/src/fema.ts
CHANGED
|
@@ -407,8 +407,14 @@ function shapeResponse(args: {
|
|
|
407
407
|
|
|
408
408
|
const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
|
|
409
409
|
const returned = rows.length;
|
|
410
|
+
// `returned > 0` guard (matches nvd/usaspending): an EMPTY page MUST terminate the
|
|
411
|
+
// walk. OpenFEMA's metadata.count can EXCEED the rows it will actually serve via
|
|
412
|
+
// $skip/$top (live: the ~822k-row public_assistance set stops serving rows well
|
|
413
|
+
// before its count), so without this guard a near-end offset yields returned:0 while
|
|
414
|
+
// offset < count ⇒ hasMore:true, nextOffset === offset — a non-advancing cursor an
|
|
415
|
+
// agent following nextOffset re-requests forever (empty page, re-scan, repeat).
|
|
410
416
|
const hasMore =
|
|
411
|
-
totalAvailable !== null && args.offset + returned < totalAvailable;
|
|
417
|
+
returned > 0 && totalAvailable !== null && args.offset + returned < totalAvailable;
|
|
412
418
|
const nextOffset = hasMore ? args.offset + returned : null;
|
|
413
419
|
|
|
414
420
|
const notes: string[] = [SHAPE_NOTE];
|
|
@@ -421,6 +427,14 @@ function shapeResponse(args: {
|
|
|
421
427
|
"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
428
|
);
|
|
423
429
|
}
|
|
430
|
+
// Phantom tail: OpenFEMA returned 0 rows though metadata.count is higher — its count
|
|
431
|
+
// can exceed the rows it will serve via pagination, so the walk is COMPLETE here even
|
|
432
|
+
// though returned:0 < count. Disclose it so the count is not read as a reachable target.
|
|
433
|
+
if (returned === 0 && totalAvailable !== null && args.offset > 0 && args.offset < totalAvailable) {
|
|
434
|
+
notes.push(
|
|
435
|
+
`OpenFEMA served 0 rows at offset ${args.offset} although metadata.count is ${totalAvailable} — its count can exceed the rows actually pageable via $skip/$top, so pagination is COMPLETE here (do not treat count as a reachable row target; narrow the filter for an exact set).`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
424
438
|
// Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
|
|
425
439
|
if (args.offset > 100000) {
|
|
426
440
|
notes.push(
|
package/src/grants.ts
CHANGED
|
@@ -45,13 +45,17 @@ export async function searchGrants(args: {
|
|
|
45
45
|
oppStatuses?: GrantStatus[];
|
|
46
46
|
rows?: number;
|
|
47
47
|
}) {
|
|
48
|
+
// The tool ALWAYS sends oppStatuses — default forecasted|posted when the caller
|
|
49
|
+
// omits it — so it is ALWAYS an applied server-side filter (disclosed below).
|
|
50
|
+
const effOppStatuses = args.oppStatuses ?? ["forecasted", "posted"];
|
|
51
|
+
const statusDefaulted = args.oppStatuses == null;
|
|
48
52
|
const body: Record<string, unknown> = {
|
|
49
53
|
rows: args.rows ?? 10,
|
|
50
54
|
keyword: args.keyword ?? "",
|
|
51
55
|
cfda: args.cfda ?? "",
|
|
52
56
|
agencies: args.agency ?? "",
|
|
53
57
|
oppNum: args.oppNum ?? "",
|
|
54
|
-
oppStatuses:
|
|
58
|
+
oppStatuses: effOppStatuses.join("|"),
|
|
55
59
|
};
|
|
56
60
|
type Resp = {
|
|
57
61
|
errorcode?: number;
|
|
@@ -135,8 +139,17 @@ export async function searchGrants(args: {
|
|
|
135
139
|
if (args.cfda) sent.push("cfda");
|
|
136
140
|
if (args.agency) sent.push("agency");
|
|
137
141
|
if (args.oppNum) sent.push("oppNum");
|
|
138
|
-
|
|
142
|
+
// oppStatuses is ALWAYS sent (default or caller-supplied), so ALWAYS disclose the
|
|
143
|
+
// EFFECTIVE set — omitting it when defaulted made totalAvailable (a status-filtered
|
|
144
|
+
// subset) read as unfiltered (live: keyword=cybersecurity → 235 forecasted|posted vs
|
|
145
|
+
// 2008 all-statuses, a 1773-record closed/archived bucket hidden).
|
|
146
|
+
sent.push(`oppStatuses(${effOppStatuses.join("|") || "all-statuses"})`);
|
|
139
147
|
const notes: string[] = [];
|
|
148
|
+
if (statusDefaulted) {
|
|
149
|
+
notes.push(
|
|
150
|
+
"No oppStatuses supplied — DEFAULTED to forecasted|posted, so CLOSED and ARCHIVED opportunities are EXCLUDED from both the results AND totalAvailable (this total is the open/forecasted subset, not all statuses). To count or return every status, pass oppStatuses including 'closed'/'archived'.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
140
153
|
if (args.agency || args.cfda) {
|
|
141
154
|
notes.push(
|
|
142
155
|
"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.",
|
package/src/gsa-csv.ts
CHANGED
|
@@ -57,10 +57,12 @@
|
|
|
57
57
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
58
58
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
59
59
|
import { createInterface } from "node:readline";
|
|
60
|
+
import { once } from "node:events";
|
|
60
61
|
import { pipeline } from "node:stream/promises";
|
|
61
62
|
import * as os from "node:os";
|
|
62
63
|
import * as path from "node:path";
|
|
63
64
|
import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
|
|
65
|
+
import { driftError } from "./datasource.js";
|
|
64
66
|
import { withMeta } from "./meta.js";
|
|
65
67
|
|
|
66
68
|
// ─── Source + config ─────────────────────────────────────────────
|
|
@@ -309,20 +311,34 @@ function fieldsFromRecord(rec: string[]): NoticeFields {
|
|
|
309
311
|
* loads the whole file into memory — readline yields one physical line at a
|
|
310
312
|
* time and the assembler holds at most one in-progress (quote-spanning) record.
|
|
311
313
|
*/
|
|
312
|
-
async function buildIndexFromFile(
|
|
314
|
+
export async function buildIndexFromFile(
|
|
313
315
|
csvPath: string,
|
|
314
316
|
): Promise<{ notices: Record<string, NoticeFields>; rowCount: number }> {
|
|
315
317
|
const notices: Record<string, NoticeFields> = Object.create(null);
|
|
316
318
|
let headerSeen = false;
|
|
317
319
|
let rowCount = 0;
|
|
318
320
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
crlfDelay: Infinity,
|
|
322
|
-
});
|
|
321
|
+
const stream = createReadStream(csvPath, { encoding: "utf8" });
|
|
322
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
323
323
|
const asm = makeRecordAssembler((rec) => {
|
|
324
324
|
if (!headerSeen) {
|
|
325
325
|
headerSeen = true; // first logical record is the 47-column header
|
|
326
|
+
// [P4] The column contract is POSITIONAL (fixed COL indices). If GSA
|
|
327
|
+
// reorders / inserts / renames a column, trusting the old positions would
|
|
328
|
+
// serve WRONG-POSITION data as authoritative (or, if NoticeId shifts, skip
|
|
329
|
+
// every row → a false "not in the current CSV snapshot"). Neither throws
|
|
330
|
+
// today. Assert the expected header NAME at each COL index before indexing
|
|
331
|
+
// any data row — a mismatch is schema drift, never a fake-empty (mirrors the
|
|
332
|
+
// census-economic.ts header guard). Each COL key IS its expected header name.
|
|
333
|
+
for (const [expectedName, idx] of Object.entries(COL)) {
|
|
334
|
+
const got = (rec[idx] ?? "").trim();
|
|
335
|
+
if (got !== expectedName) {
|
|
336
|
+
throw driftError(
|
|
337
|
+
"gsa:csv",
|
|
338
|
+
`GSA Contract-Opportunities CSV header drift — column ${idx} is ${JSON.stringify(got)}, expected ${JSON.stringify(expectedName)}. The positional column contract changed; refusing to index (a silent column shift would serve wrong-position data as authoritative, or skip every row as a false "not in the current snapshot").`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
326
342
|
return;
|
|
327
343
|
}
|
|
328
344
|
rowCount++;
|
|
@@ -333,8 +349,23 @@ async function buildIndexFromFile(
|
|
|
333
349
|
notices[noticeId.toLowerCase()] = fieldsFromRecord(rec);
|
|
334
350
|
});
|
|
335
351
|
|
|
336
|
-
|
|
337
|
-
|
|
352
|
+
try {
|
|
353
|
+
for await (const line of rl) asm.push(line);
|
|
354
|
+
asm.flush();
|
|
355
|
+
} finally {
|
|
356
|
+
// Release the OS file handle on ALL exit paths (including the header-drift
|
|
357
|
+
// throw, which aborts mid-stream). destroy() is ASYNC — the fd is not freed
|
|
358
|
+
// until the 'close' event — so AWAIT it; otherwise a caller deleting the file
|
|
359
|
+
// immediately after (Windows holds a lock on an open read stream) hits
|
|
360
|
+
// ENOTEMPTY/EBUSY. On the normal EOF path the stream auto-destroys, so guard
|
|
361
|
+
// on `.destroyed` and set the listener BEFORE destroy() to catch its 'close'.
|
|
362
|
+
rl.close();
|
|
363
|
+
if (!stream.destroyed) {
|
|
364
|
+
const closed = once(stream, "close");
|
|
365
|
+
stream.destroy();
|
|
366
|
+
await closed.catch(() => {});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
338
369
|
|
|
339
370
|
return { notices, rowCount };
|
|
340
371
|
}
|
package/src/nppes.ts
CHANGED
|
@@ -793,16 +793,29 @@ export async function lookupProvider(
|
|
|
793
793
|
const candidateNext = skip + returned;
|
|
794
794
|
const nextSkip = pageFull && candidateNext <= NPPES_MAX_SKIP ? candidateNext : null;
|
|
795
795
|
const hasMore = nextSkip !== null;
|
|
796
|
-
|
|
796
|
+
// P1: NPPES exposes no grand total. `skip + returned` is the total ONLY when the
|
|
797
|
+
// page actually held rows — a partial/last page ⇒ EXACT, a full page ⇒ a lower
|
|
798
|
+
// bound (flagged below). An EMPTY page past skip 0 proves only that the true total
|
|
799
|
+
// is ≤ skip (an UPPER bound), NEVER a count: reporting skip + 0 = skip would
|
|
800
|
+
// fabricate an arbitrary total (e.g. skip 56 over a 6-match query ⇒ a false "56").
|
|
801
|
+
// At skip 0 an empty page is the genuine zero.
|
|
802
|
+
const overSkipped = returned === 0 && skip > 0;
|
|
803
|
+
const totalAvailable = overSkipped ? null : skip + returned;
|
|
797
804
|
|
|
798
805
|
const notes: string[] = [];
|
|
799
806
|
if (pageFull) {
|
|
800
|
-
|
|
807
|
+
// pageFull ⇒ returned === limit ≥ 1 ⇒ overSkipped false ⇒ totalAvailable is the
|
|
808
|
+
// numeric lower bound skip + returned (pass it directly so the type stays number).
|
|
809
|
+
notes.push(lowerBoundNote(skip + returned));
|
|
801
810
|
if (nextSkip === null) {
|
|
802
811
|
notes.push(
|
|
803
812
|
`A full page was returned but the next page would exceed the skip ≤ ${NPPES_MAX_SKIP} policy cap — additional matches exist but are NOT reachable via this tool. Narrow your filters for a complete set.`,
|
|
804
813
|
);
|
|
805
814
|
}
|
|
815
|
+
} else if (overSkipped) {
|
|
816
|
+
notes.push(
|
|
817
|
+
`No providers at skip=${skip}: the skip offset is past the end of the result set. An empty page proves only that the true total is ≤ ${skip} (an upper bound), not a count — so totalAvailable is unknown (null). Page back (a lower skip) to reach the last populated page and its exact total.`,
|
|
818
|
+
);
|
|
806
819
|
} else if (returned === 0) {
|
|
807
820
|
notes.push(
|
|
808
821
|
`No NPPES providers matched (found:false / empty — an honest zero, distinct from an outage or a body-level error). Not a fitness determination.`,
|
|
@@ -813,8 +826,9 @@ export async function lookupProvider(
|
|
|
813
826
|
const meta: Partial<ResponseMeta> = {
|
|
814
827
|
source: SOURCE,
|
|
815
828
|
keylessMode: true,
|
|
816
|
-
// A full page
|
|
817
|
-
|
|
829
|
+
// A full page (more may exist) OR an over-skip empty page (matches exist at a
|
|
830
|
+
// LOWER skip, not here) is not complete; only skip-0 / a partial last page is.
|
|
831
|
+
truncated: pageFull || overSkipped,
|
|
818
832
|
returned,
|
|
819
833
|
totalAvailable,
|
|
820
834
|
filtersApplied,
|
package/src/nsf.ts
CHANGED
|
@@ -158,6 +158,13 @@ const AMOUNTS_NOTE =
|
|
|
158
158
|
const DATA_CURRENCY_NOTE =
|
|
159
159
|
"NSF updates awards on a rolling basis; per-record refresh lag is not API-verifiable.";
|
|
160
160
|
|
|
161
|
+
/** Unstable-order disclosure: NSF exposes no stable server-side sort/tiebreaker, so
|
|
162
|
+
* paging by offset can overlap (duplicate) or skip rows across pages. Fires whenever
|
|
163
|
+
* the result set spans more than one page (hasMore, or the caller is already past
|
|
164
|
+
* offset 0). Live-verified: repeated identical requests reorder; adjacent windows overlap. */
|
|
165
|
+
const NSF_ORDER_UNSTABLE_NOTE =
|
|
166
|
+
"NSF returns results in an order that is NOT stably sorted (no server-side stable sort/tiebreaker), so paging by offset across multiple pages can DUPLICATE or SKIP rows (live-verified: repeated identical requests reorder, and adjacent offset windows overlap). totalAvailable is the reliable COUNT — do NOT enumerate by walking offsets; to list an exact set, narrow the query (state / keyword / PI / date / UEI) so the whole result fits one page (offset 0).";
|
|
167
|
+
|
|
161
168
|
/** Live-verified date-filter semantics (Open-Q6 — do not assume). */
|
|
162
169
|
const DATE_SEMANTICS_NOTE =
|
|
163
170
|
"dateStart / dateEnd filter on the award ACTION date (the initial award / obligation date — the `date` / initAmendmentDate field, live-verified 2026-07-12), NOT the project startDate or expDate. Format is STRICT mm/dd/yyyy; a yyyy-mm-dd (or any other format) is silently mis-parsed by NSF (not an error), so it is rejected client-side.";
|
|
@@ -618,6 +625,9 @@ export async function searchAwards(args: NsfSearchArgs): Promise<MetaBundle> {
|
|
|
618
625
|
const notes: string[] = [NSF_GRANT_CAVEAT, UEI_JOIN_NOTE, AMOUNTS_NOTE];
|
|
619
626
|
if (multiWordKeyword) notes.push(orSemanticsNote(multiWordKeyword)); // M1
|
|
620
627
|
if (totalIsLowerBound) notes.push(LOWER_BOUND_NOTE);
|
|
628
|
+
// Unstable-order disclosure: fire whenever the result spans >1 page (offset paging is
|
|
629
|
+
// in play), so an agent never silently double-counts/skips rows by walking offsets.
|
|
630
|
+
if (hasMore || offset > 0) notes.push(NSF_ORDER_UNSTABLE_NOTE);
|
|
621
631
|
if (rpp < limit) notes.push(clampNote(rpp));
|
|
622
632
|
if (args.dateStart !== undefined || args.dateEnd !== undefined)
|
|
623
633
|
notes.push(DATE_SEMANTICS_NOTE);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* open-checkbook.ts — Socrata "Open Expenditures / Open Checkbook" row-level
|
|
3
|
+
* vendor-payment API, a keyless-first SLED source (loop cycle 76, 2026-07-24 —
|
|
4
|
+
* South Dakota dark-state closure, the LAST dark state).
|
|
5
|
+
*
|
|
6
|
+
* WHAT IT ADDS: some US governments run Socrata's "Open Expenditures" product,
|
|
7
|
+
* whose public dashboard fronts a KEYLESS app-proxy JSON API at
|
|
8
|
+
* `https://{host}/api/checkbook_data.json?year=…&{filters}&page=P&limit=L`
|
|
9
|
+
* → `{ data:[…row-level payments…], count, total_amount }`. First (and, as of
|
|
10
|
+
* this writing, only live-verified) portal: **South Dakota Open Checkbook**
|
|
11
|
+
* (740,980 vendor-payment rows, ~$8.41B, the ~3 most-recent fiscal years).
|
|
12
|
+
*
|
|
13
|
+
* ★ KEYLESS- vs-GATED HONESTY (load-bearing): the app-proxy above is anonymous
|
|
14
|
+
* and public. The UNDERLYING Socrata SODA dataset (7uwr-juaf on
|
|
15
|
+
* southdakota.data.socrata.com) is **403 login-gated** — this module NEVER
|
|
16
|
+
* touches it and NEVER presents it as reachable. We only call the public
|
|
17
|
+
* /api/checkbook_data.json surface the dashboard itself uses anonymously.
|
|
18
|
+
*
|
|
19
|
+
* ★ CURATED allowlist (SSRF core): each portal is a FIXED, live-verified host;
|
|
20
|
+
* the `portal` enum in server.ts is built FROM the keys. Host asserted before
|
|
21
|
+
* fetch (redirect:"error").
|
|
22
|
+
*
|
|
23
|
+
* ★ HONESTY PILLARS:
|
|
24
|
+
* P1: totalAvailable = the API's own `count` (the REAL filtered total — it
|
|
25
|
+
* matches the product's totals.json exactly; e.g. 740,980 unfiltered,
|
|
26
|
+
* 109,887 for org1=TRANSPORTATION), NEVER the page length.
|
|
27
|
+
* P2: getJson THROWS on 429/5xx/timeout — NEVER a fake empty. A bogus filter
|
|
28
|
+
* ⇒ honest count:0/empty; a deep offset past the end ⇒ returned:0 with the
|
|
29
|
+
* real count preserved (an honest tail, not an outage).
|
|
30
|
+
* P3: `amount` ⇒ number|null (a real $0 is 0, an absent value is null, never a
|
|
31
|
+
* fabricated 0); all other fields via str (null-never-empty). Dates verbatim.
|
|
32
|
+
* P4: a body that is not `{data:[…], count:<number>}` ⇒ schema_drift.
|
|
33
|
+
* ★ Coverage disclosure (P5): only the ~3 most-recent fiscal years are exposed
|
|
34
|
+
* by the product (NOT full history) — disclosed every response.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
38
|
+
import { getJson, driftError } from "./datasource.js";
|
|
39
|
+
import { num, str } from "./coerce.js";
|
|
40
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
41
|
+
|
|
42
|
+
export { num };
|
|
43
|
+
|
|
44
|
+
// ─── Curated portal allowlist (SSRF core) — LIVE-VERIFIED 2026-07-24 ──
|
|
45
|
+
export type OpenCheckbookPortal = { key: string; host: string; label: string; note: string };
|
|
46
|
+
export const OPEN_CHECKBOOK_PORTALS: readonly OpenCheckbookPortal[] = [
|
|
47
|
+
{
|
|
48
|
+
key: "sd",
|
|
49
|
+
host: "southdakota.spending.socrata.com",
|
|
50
|
+
label: "South Dakota — Open Checkbook",
|
|
51
|
+
note: "State of South Dakota vendor-payment checkbook (row fields: expense_category, description, fund, payment_date, vendor, org1=department, amount, custom_checkbook_field7=invoice ref, payment_id). ~740,980 rows / ~$8.41B across the ~3 most-recent fiscal years (NOT full history). The underlying Socrata SODA dataset is login-gated; this public app-proxy is the keyless door.",
|
|
52
|
+
},
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
const PORTAL_BY_KEY: ReadonlyMap<string, OpenCheckbookPortal> = new Map(OPEN_CHECKBOOK_PORTALS.map((p) => [p.key, p]));
|
|
56
|
+
|
|
57
|
+
// Sort fields the product supports (validated — an SSRF/injection + silent-noop guard).
|
|
58
|
+
const SORT_FIELDS = new Set(["amount", "payment_date", "vendor", "org1", "expense_category"]);
|
|
59
|
+
|
|
60
|
+
// ─── SSRF-guarded fetch (fixed allowlist host + assertion) ──
|
|
61
|
+
async function getCheckbook(portal: OpenCheckbookPortal, params: URLSearchParams): Promise<unknown> {
|
|
62
|
+
const url = `https://${portal.host}/api/checkbook_data.json?${params.toString()}`;
|
|
63
|
+
const built = new URL(url);
|
|
64
|
+
if (built.hostname !== portal.host || built.protocol !== "https:") {
|
|
65
|
+
throw new ToolErrorCarrier({
|
|
66
|
+
kind: "invalid_input",
|
|
67
|
+
message: `Constructed Open-Checkbook URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted portal host ${JSON.stringify(portal.host)} over https — refusing to fetch (SSRF safety).`,
|
|
68
|
+
retryable: false,
|
|
69
|
+
upstreamEndpoint: `open-checkbook:${portal.key}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return getJson(url, { label: `open-checkbook:${portal.key}`, redirect: "error" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Tool: open_checkbook_search ──────────────────────────────────
|
|
76
|
+
export type OpenCheckbookSearchArgs = {
|
|
77
|
+
portal: string;
|
|
78
|
+
year?: string;
|
|
79
|
+
vendor?: string;
|
|
80
|
+
org?: string;
|
|
81
|
+
expenseCategory?: string;
|
|
82
|
+
sortBy?: string;
|
|
83
|
+
sortOrder?: string;
|
|
84
|
+
limit?: number;
|
|
85
|
+
offset?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Row-level vendor-payment search over a curated Socrata Open-Expenditures
|
|
90
|
+
* checkbook portal (keyless). `portal` is an allowlist enum; `year`/`vendor`/
|
|
91
|
+
* `org`/`expenseCategory` are EXACT-match filters; `sortBy`/`sortOrder` sort;
|
|
92
|
+
* `limit`/`offset` page. Returns { portal, rows:[…] } + honest _meta
|
|
93
|
+
* (totalAvailable = the API's real count, NOT a page length).
|
|
94
|
+
*/
|
|
95
|
+
export async function openCheckbookSearch(args: OpenCheckbookSearchArgs): Promise<MetaBundle> {
|
|
96
|
+
const portal = PORTAL_BY_KEY.get(args.portal);
|
|
97
|
+
if (!portal) {
|
|
98
|
+
throw new ToolErrorCarrier({
|
|
99
|
+
kind: "invalid_input",
|
|
100
|
+
message: `Unknown Open-Checkbook portal ${JSON.stringify(args.portal)}. Allowed: ${OPEN_CHECKBOOK_PORTALS.map((p) => p.key).join(", ")}.`,
|
|
101
|
+
retryable: false,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const limit = args.limit ?? 25;
|
|
105
|
+
const offset = args.offset ?? 0;
|
|
106
|
+
// The product paginates by 1-based `page` + `limit`. Map offset→page and snap
|
|
107
|
+
// offset to the page boundary (disclosing the served offset when it differs).
|
|
108
|
+
const page = Math.floor(offset / limit) + 1;
|
|
109
|
+
const servedOffset = (page - 1) * limit;
|
|
110
|
+
|
|
111
|
+
const filtersApplied: string[] = ["portal"];
|
|
112
|
+
const params = new URLSearchParams();
|
|
113
|
+
params.set("year", args.year && args.year.trim() ? args.year.trim() : "All Years");
|
|
114
|
+
if (args.year && args.year.trim()) filtersApplied.push("year");
|
|
115
|
+
if (args.vendor && args.vendor.trim()) { params.set("vendor", args.vendor.trim()); filtersApplied.push("vendor"); }
|
|
116
|
+
if (args.org && args.org.trim()) { params.set("org1", args.org.trim()); filtersApplied.push("org"); }
|
|
117
|
+
if (args.expenseCategory && args.expenseCategory.trim()) { params.set("expense_category", args.expenseCategory.trim()); filtersApplied.push("expenseCategory"); }
|
|
118
|
+
if (args.sortBy && args.sortBy.trim()) {
|
|
119
|
+
const sf = args.sortBy.trim();
|
|
120
|
+
if (!SORT_FIELDS.has(sf)) {
|
|
121
|
+
throw new ToolErrorCarrier({
|
|
122
|
+
kind: "invalid_input",
|
|
123
|
+
message: `Invalid sortBy ${JSON.stringify(sf)}. Allowed: ${[...SORT_FIELDS].join(", ")}.`,
|
|
124
|
+
retryable: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
params.set("sort_field", sf);
|
|
128
|
+
params.set("sort_order", args.sortOrder === "asc" ? "asc" : "desc");
|
|
129
|
+
filtersApplied.push("sortBy");
|
|
130
|
+
}
|
|
131
|
+
params.set("page", String(page));
|
|
132
|
+
params.set("limit", String(limit));
|
|
133
|
+
|
|
134
|
+
const body = await getCheckbook(portal, params);
|
|
135
|
+
const b = (body ?? {}) as { data?: unknown; count?: unknown; total_amount?: unknown };
|
|
136
|
+
// P4: the shape MUST be { data:[…], count:<number> }.
|
|
137
|
+
if (!Array.isArray(b.data)) throw driftError(`open-checkbook:${portal.key}`, "Open-Checkbook shape drift — response.data must be an array.");
|
|
138
|
+
const totalAvailable = num(b.count);
|
|
139
|
+
if (totalAvailable === null) throw driftError(`open-checkbook:${portal.key}`, "Open-Checkbook shape drift — response.count must be a number.");
|
|
140
|
+
|
|
141
|
+
const rows = (b.data as unknown[]).map((r) => {
|
|
142
|
+
const o = (r ?? {}) as Record<string, unknown>;
|
|
143
|
+
return {
|
|
144
|
+
vendor: str(o.vendor),
|
|
145
|
+
amount: num(o.amount), // P3: a real $0 is 0; absent ⇒ null (never a fabricated 0)
|
|
146
|
+
payment_date: str(o.payment_date),
|
|
147
|
+
org1: str(o.org1),
|
|
148
|
+
expense_category: str(o.expense_category),
|
|
149
|
+
description: str(o.description),
|
|
150
|
+
fund: str(o.fund),
|
|
151
|
+
invoice: str(o.custom_checkbook_field7),
|
|
152
|
+
payment_id: str(o.payment_id),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const returned = rows.length;
|
|
156
|
+
const hasMore = servedOffset + returned < totalAvailable;
|
|
157
|
+
const nextOffset = hasMore ? servedOffset + returned : null;
|
|
158
|
+
|
|
159
|
+
const notes: string[] = [
|
|
160
|
+
`Source: ${portal.label} (Socrata Open Expenditures app-proxy /api/checkbook_data.json, keyless). ${portal.note}`,
|
|
161
|
+
"totalAvailable = the API's exact match count (matches the product's totals.json), NOT the page length.",
|
|
162
|
+
"Filters (year/vendor/org/expenseCategory) are EXACT-match — a partial/misspelled value returns an honest count:0, not an error. amount is number|null (a real $0 is 0, an absent value is null, never a fabricated 0).",
|
|
163
|
+
"COVERAGE: only the ~3 most-recent fiscal years are exposed by this product — this is NOT the state's full payment history.",
|
|
164
|
+
"FRESHNESS is set by the publisher: the portal says it refreshes each payment cycle, but refreshes can lag by weeks. To check recency, sort by payment_date (sortBy='payment_date', sortOrder='desc') and read the newest date.",
|
|
165
|
+
];
|
|
166
|
+
if (servedOffset !== offset)
|
|
167
|
+
notes.push(`offset ${offset} was snapped to ${servedOffset} (the product paginates by fixed page×limit); pass an offset that is a multiple of limit to avoid snapping.`);
|
|
168
|
+
|
|
169
|
+
return withMeta(
|
|
170
|
+
{ portal: portal.key, rows },
|
|
171
|
+
{
|
|
172
|
+
source: `${portal.host} via Socrata Open Expenditures (keyless app-proxy)`,
|
|
173
|
+
keylessMode: true,
|
|
174
|
+
returned,
|
|
175
|
+
totalAvailable,
|
|
176
|
+
filtersApplied,
|
|
177
|
+
filtersDropped: [],
|
|
178
|
+
fieldsUnavailable: [],
|
|
179
|
+
pagination: { offset: servedOffset, limit, hasMore, nextOffset },
|
|
180
|
+
notes,
|
|
181
|
+
} satisfies Partial<ResponseMeta>,
|
|
182
|
+
);
|
|
183
|
+
}
|
package/src/openfda-device.ts
CHANGED
|
@@ -53,6 +53,10 @@ import {
|
|
|
53
53
|
readOpenfdaError,
|
|
54
54
|
luceneQuote,
|
|
55
55
|
openfdaApiKey,
|
|
56
|
+
openfdaPageMeta,
|
|
57
|
+
openfdaEmptyTotal,
|
|
58
|
+
OPENFDA_CEILING_NOTE,
|
|
59
|
+
OPENFDA_OVERSKIP_NOTE,
|
|
56
60
|
} from "./openfda.js";
|
|
57
61
|
|
|
58
62
|
// state filter charclass (a 2-letter US state/territory postal code).
|
|
@@ -282,10 +286,10 @@ export async function deviceClearances(
|
|
|
282
286
|
const rawTotal = metaResults.total;
|
|
283
287
|
const totalAvailable =
|
|
284
288
|
typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
|
|
285
|
-
const hasMore
|
|
286
|
-
const nextOffset = hasMore ? skip + returned : null;
|
|
289
|
+
const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
|
|
287
290
|
|
|
288
291
|
const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
|
|
292
|
+
if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
|
|
289
293
|
if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
|
|
290
294
|
|
|
291
295
|
return withMeta(
|
|
@@ -327,13 +331,15 @@ function emptyResult(
|
|
|
327
331
|
})`,
|
|
328
332
|
keylessMode: true,
|
|
329
333
|
returned: 0,
|
|
330
|
-
totalAvailable:
|
|
334
|
+
totalAvailable: openfdaEmptyTotal(skip),
|
|
331
335
|
filtersApplied,
|
|
332
336
|
filtersDropped: [],
|
|
333
337
|
fieldsUnavailable: [],
|
|
334
338
|
pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
|
|
335
339
|
notes: [
|
|
336
|
-
|
|
340
|
+
skip === 0
|
|
341
|
+
? "No 510(k) device clearances matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
|
|
342
|
+
: OPENFDA_OVERSKIP_NOTE,
|
|
337
343
|
NOT_DETERMINATION_NOTE,
|
|
338
344
|
keyNote(hasKey),
|
|
339
345
|
],
|
package/src/openfda-drugsfda.ts
CHANGED
|
@@ -37,6 +37,10 @@ import {
|
|
|
37
37
|
readOpenfdaError,
|
|
38
38
|
luceneQuote,
|
|
39
39
|
openfdaApiKey,
|
|
40
|
+
openfdaPageMeta,
|
|
41
|
+
openfdaEmptyTotal,
|
|
42
|
+
OPENFDA_CEILING_NOTE,
|
|
43
|
+
OPENFDA_OVERSKIP_NOTE,
|
|
40
44
|
} from "./openfda.js";
|
|
41
45
|
|
|
42
46
|
const DEFAULT_LIMIT = 25;
|
|
@@ -241,10 +245,10 @@ export async function drugApprovals(args: DrugApprovalsArgs): Promise<MetaBundle
|
|
|
241
245
|
const rawTotal = metaResults.total;
|
|
242
246
|
const totalAvailable =
|
|
243
247
|
typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
|
|
244
|
-
const hasMore
|
|
245
|
-
const nextOffset = hasMore ? skip + returned : null;
|
|
248
|
+
const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
|
|
246
249
|
|
|
247
250
|
const notes: string[] = [NOT_DETERMINATION_NOTE, MARKETING_NOTE, keyNote(key !== undefined)];
|
|
251
|
+
if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
|
|
248
252
|
if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
|
|
249
253
|
|
|
250
254
|
return withMeta(
|
|
@@ -284,13 +288,15 @@ function emptyResult(
|
|
|
284
288
|
})`,
|
|
285
289
|
keylessMode: true,
|
|
286
290
|
returned: 0,
|
|
287
|
-
totalAvailable:
|
|
291
|
+
totalAvailable: openfdaEmptyTotal(skip),
|
|
288
292
|
filtersApplied,
|
|
289
293
|
filtersDropped: [],
|
|
290
294
|
fieldsUnavailable: [],
|
|
291
295
|
pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
|
|
292
296
|
notes: [
|
|
293
|
-
|
|
297
|
+
skip === 0
|
|
298
|
+
? "No Drugs@FDA applications matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
|
|
299
|
+
: OPENFDA_OVERSKIP_NOTE,
|
|
294
300
|
NOT_DETERMINATION_NOTE,
|
|
295
301
|
keyNote(hasKey),
|
|
296
302
|
],
|
package/src/openfda.ts
CHANGED
|
@@ -435,11 +435,10 @@ export async function enforcement(
|
|
|
435
435
|
const rawTotal = metaResults.total;
|
|
436
436
|
const totalAvailable =
|
|
437
437
|
typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
|
|
438
|
-
const hasMore =
|
|
439
|
-
totalAvailable !== null && skip + returned < totalAvailable;
|
|
440
|
-
const nextOffset = hasMore ? skip + returned : null;
|
|
438
|
+
const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
|
|
441
439
|
|
|
442
440
|
const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
|
|
441
|
+
if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
|
|
443
442
|
if (!hasStructuredFilter) notes.push(NO_FILTER_NOTE);
|
|
444
443
|
if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
|
|
445
444
|
|
|
@@ -476,8 +475,11 @@ function emptyResult(
|
|
|
476
475
|
hasKey: boolean,
|
|
477
476
|
categoryDefaulted: boolean,
|
|
478
477
|
): MetaBundle {
|
|
478
|
+
const emptyTotal = openfdaEmptyTotal(skip);
|
|
479
479
|
const notes = [
|
|
480
|
-
|
|
480
|
+
skip === 0
|
|
481
|
+
? "No recall/enforcement records matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
|
|
482
|
+
: OPENFDA_OVERSKIP_NOTE,
|
|
481
483
|
NOT_DETERMINATION_NOTE,
|
|
482
484
|
keyNote(hasKey),
|
|
483
485
|
];
|
|
@@ -493,7 +495,7 @@ function emptyResult(
|
|
|
493
495
|
})`,
|
|
494
496
|
keylessMode: true,
|
|
495
497
|
returned: 0,
|
|
496
|
-
totalAvailable:
|
|
498
|
+
totalAvailable: emptyTotal,
|
|
497
499
|
filtersApplied,
|
|
498
500
|
filtersDropped: [],
|
|
499
501
|
fieldsUnavailable: [],
|
|
@@ -516,3 +518,43 @@ function clampSkip(v: unknown): number {
|
|
|
516
518
|
const n = Math.floor(v);
|
|
517
519
|
return n < 0 ? 0 : n;
|
|
518
520
|
}
|
|
521
|
+
|
|
522
|
+
// ─── Shared pagination-honesty helpers (used by all three openFDA modules) ──
|
|
523
|
+
/** openFDA caps `skip` at 25000 (skip>25000 → HTTP 400 "Skip value must 25000 or less").
|
|
524
|
+
* The last reachable page is skip=25000, so at most ~25000+limit records are enumerable
|
|
525
|
+
* via offset paging — datasets larger than that have an UNREACHABLE tail. */
|
|
526
|
+
export const OPENFDA_MAX_SKIP = 25000;
|
|
527
|
+
|
|
528
|
+
/** Ceiling- and empty-aware pagination. `hasMore` is false on an empty page (returned:0,
|
|
529
|
+
* the fema/nvd guard) OR when the next offset would exceed OPENFDA_MAX_SKIP (advertising a
|
|
530
|
+
* skip>25000 nextOffset is a poison cursor — following it 400s). `ceilingHit` is true when
|
|
531
|
+
* more rows exist upstream but lie beyond the reachable skip window (must be disclosed). */
|
|
532
|
+
export function openfdaPageMeta(
|
|
533
|
+
skip: number,
|
|
534
|
+
returned: number,
|
|
535
|
+
totalAvailable: number | null,
|
|
536
|
+
): { hasMore: boolean; nextOffset: number | null; ceilingHit: boolean } {
|
|
537
|
+
const candidateNext = skip + returned;
|
|
538
|
+
const moreUpstream = totalAvailable !== null && candidateNext < totalAvailable;
|
|
539
|
+
const withinWindow = candidateNext <= OPENFDA_MAX_SKIP;
|
|
540
|
+
const hasMore = returned > 0 && moreUpstream && withinWindow;
|
|
541
|
+
return {
|
|
542
|
+
hasMore,
|
|
543
|
+
nextOffset: hasMore ? candidateNext : null,
|
|
544
|
+
ceilingHit: returned > 0 && moreUpstream && !withinWindow,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The ceiling disclosure (records beyond the skip window are permanently unreachable). */
|
|
549
|
+
export const OPENFDA_CEILING_NOTE =
|
|
550
|
+
`openFDA caps pagination at skip=${OPENFDA_MAX_SKIP}: records beyond ~${OPENFDA_MAX_SKIP}+limit are NOT reachable via this API, so totalAvailable exceeds the paginable window here and the tail is unreachable. Narrow the query (add filters or a date range) to bring the matching set within the first ${OPENFDA_MAX_SKIP} records.`;
|
|
551
|
+
|
|
552
|
+
/** Skip-aware total for a 404 NOT_FOUND: openFDA returns the SAME 404 for a genuine
|
|
553
|
+
* zero-match (only provable at skip 0) and an OVER-SKIP (skip past the end). So skip===0
|
|
554
|
+
* ⇒ a true total of 0; a skip>0 404 is AMBIGUOUS ⇒ totalAvailable unknown (null). */
|
|
555
|
+
export function openfdaEmptyTotal(skip: number): number | null {
|
|
556
|
+
return skip === 0 ? 0 : null;
|
|
557
|
+
}
|
|
558
|
+
/** Disclosure for a skip>0 404 (ambiguous over-skip vs no-match; total unknown). */
|
|
559
|
+
export const OPENFDA_OVERSKIP_NOTE =
|
|
560
|
+
"Returned 0 at this offset: the skip is at/past the end of the result set (or a genuine no-match). openFDA returns an IDENTICAL HTTP 404 for both, so the true total is UNKNOWN at a non-zero skip (totalAvailable is null, not 0) — re-query at skip:0 for the exact total. This is NOT a confirmed empty result set.";
|
package/src/opengov.ts
CHANGED
|
@@ -238,10 +238,14 @@ export async function searchSolicitations(
|
|
|
238
238
|
});
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
// The
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
241
|
+
// The OpenGov /project/list `page` param is 1-BASED — `page=0` CLAMPS to page 1
|
|
242
|
+
// (live-verified 2026-07-20: phoenix offset 0 and offset<limit both returned the
|
|
243
|
+
// SAME first page → a 0-based send duplicates page 1 and shifts every later page,
|
|
244
|
+
// silently dropping the tail while totalAvailable still claims the full count).
|
|
245
|
+
// Map our row `offset` → the 1-based page; servedOffset stays offset-aligned.
|
|
246
|
+
const apiPage = Math.floor(offset / limit) + 1;
|
|
247
|
+
const servedOffset = (apiPage - 1) * limit;
|
|
248
|
+
const payload = JSON.stringify({ governmentCode: code, publicView: true, page: apiPage, limit });
|
|
245
249
|
|
|
246
250
|
let body: unknown;
|
|
247
251
|
try {
|