@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
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* datagov-catalog.ts — data.gov v4 Catalog API (`api.gsa.gov/technology/datagov/v4`)
|
|
3
|
+
* federal DATASET DISCOVERY (ADR-0046, resilience-initiative Phase 3).
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT ADDS: data.gov RETIRED its CKAN `package_search` endpoint in 2025; the
|
|
6
|
+
* v4 Catalog API is its replacement. This restores federal open-dataset DISCOVERY
|
|
7
|
+
* across all publishing agencies (hundreds of thousands of datasets) as a NEW
|
|
8
|
+
* keyless-first source. It is a SEPARATE host from datagov.ts's api.data.gov trio
|
|
9
|
+
* (Regulations.gov / Congress.gov), but it shares the IDENTICAL api.data.gov key
|
|
10
|
+
* (`api.gsa.gov` accepts the same DATA_GOV_API_KEY / DEMO_KEY via `X-Api-Key`), so
|
|
11
|
+
* it REUSES the audited `datagovKey.ts` key seam verbatim (keyHeader / keyModeLabel
|
|
12
|
+
* / pushKeyNote) — keylessMode:false (genuinely keyed, mirroring the regulations trio).
|
|
13
|
+
*
|
|
14
|
+
* The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
|
|
15
|
+
* (redirect:"error", the X-Api-Key header) / `driftError` / `str` (coerce.ts,
|
|
16
|
+
* null-never-empty-string) / `withMeta`·`buildMeta` / `ResponseMeta.nextCursor`,
|
|
17
|
+
* and MIRRORS datagov.ts's `searchDockets` schema_drift catch-ladder verbatim.
|
|
18
|
+
*
|
|
19
|
+
* ★ SSRF: the host is a compile-time literal (`DATAGOV_CATALOG_HOST`); every filter
|
|
20
|
+
* (`_q`/`organization`/`_size`/`_format`) rides in a MODULE-BUILT `URLSearchParams`
|
|
21
|
+
* assembled key-by-key from validated typed args — NO raw-query passthrough. The
|
|
22
|
+
* opaque `cursor` is charclass-validated (`^[A-Za-z0-9+/=_-]{1,4096}$`, rejecting
|
|
23
|
+
* `../` / spaces / `%`) BEFORE it rides the `after=` query param. A post-construction
|
|
24
|
+
* hostname/protocol assertion + `redirect:"error"` lock it (fail closed on any
|
|
25
|
+
* off-host 3xx — a 3xx off api.gsa.gov could carry the X-Api-Key header away).
|
|
26
|
+
*
|
|
27
|
+
* ★ THE HONESTY PILLARS (P1-P4, captured live 2026-07-14 — the v4 facts):
|
|
28
|
+
* P1 (NO total): the v4 search response is `{ after, results, sort }` — it reports
|
|
29
|
+
* NO match count. `totalAvailable = null` (NEVER results.length, NEVER a fabricated
|
|
30
|
+
* total). Pagination is an OPAQUE `after` cursor: `hasMore = after is a non-empty
|
|
31
|
+
* string`; `nextCursor = hasMore ? after : null` (passed back VERBATIM as the next
|
|
32
|
+
* `cursor` argument). offset/nextOffset are null (a numeric offset is meaningless).
|
|
33
|
+
* P2: getJson→fetchWithRetry THROWS on 429 (rate_limited — very likely at DEMO_KEY's
|
|
34
|
+
* ~10/hr), 5xx (upstream_unavailable), timeout — NEVER a fake empty. A genuine
|
|
35
|
+
* no-match (results:[], no after) ⇒ honest empty (datasets:[], returned:0,
|
|
36
|
+
* nextCursor:null, complete:true).
|
|
37
|
+
* P3: every scalar via `str` (null-never-empty-string — a missing accessLevel /
|
|
38
|
+
* license / landingPage is null, NEVER "").
|
|
39
|
+
* P4: `body.results` absent/non-array ⇒ driftError; a 200 non-JSON body ⇒ schema_drift
|
|
40
|
+
* via the catch-ladder (ToolErrorCarrier rethrow FIRST so a 429/5xx keeps its
|
|
41
|
+
* taxonomy → SyntaxError→driftError → bare rethrow).
|
|
42
|
+
* accessLevel is surfaced VERBATIM (public / restricted public / non-public), null
|
|
43
|
+
* when absent — the consumer judges the dataset's openness (this tool only
|
|
44
|
+
* DISCOVERS datasets; it does not ingest distributions).
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
48
|
+
import { getJson, driftError } from "./datasource.js";
|
|
49
|
+
import { str } from "./coerce.js";
|
|
50
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
51
|
+
// The SHARED api.data.gov key seam (ADR-0010 §2). api.gsa.gov accepts the SAME
|
|
52
|
+
// DATA_GOV_API_KEY / DEMO_KEY via the X-Api-Key header, so this is a THIRD consumer
|
|
53
|
+
// of the audited key discipline — a key-leak regression now fails this suite too.
|
|
54
|
+
import { keyHeader, keyModeLabel, pushKeyNote } from "./datagovKey.js";
|
|
55
|
+
|
|
56
|
+
// ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
|
|
57
|
+
export const DATAGOV_CATALOG_HOST = "api.gsa.gov";
|
|
58
|
+
const DATAGOV_CATALOG_BASE = "/technology/datagov/v4";
|
|
59
|
+
const DATAGOV_CATALOG_SEARCH_PATH = `${DATAGOV_CATALOG_BASE}/search`;
|
|
60
|
+
// HOST+path label — surfaces in ToolError.upstreamEndpoint; the key rides ONLY in
|
|
61
|
+
// the X-Api-Key header, so no token can ever appear here.
|
|
62
|
+
const DATAGOV_CATALOG_LABEL = "datagov-catalog:/technology/datagov/v4/search";
|
|
63
|
+
|
|
64
|
+
const DATAGOV_CATALOG_SOURCE = (mode: string) =>
|
|
65
|
+
`${DATAGOV_CATALOG_HOST} via data.gov v4 Catalog API (${mode})`;
|
|
66
|
+
|
|
67
|
+
// The opaque `after` cursor grammar (SSRF + injection guard). data.gov's cursor is
|
|
68
|
+
// a base64/URL-safe token — `+/=_-`, no `%` (URLSearchParams would double-encode a
|
|
69
|
+
// literal `%` ('%2e'→'%252e') and corrupt the cursor; the real tokens carry none).
|
|
70
|
+
// Validated BEFORE the value rides `after=` — a `../` / space / `%` ⇒ invalid_input,
|
|
71
|
+
// 0 fetch (mirrors clinicaltrials CT_TOKEN_RE).
|
|
72
|
+
export const DATAGOV_CURSOR_RE = new RegExp("^[A-Za-z0-9+/=_-]{1,4096}$");
|
|
73
|
+
|
|
74
|
+
// The P1 no-total disclosure — the load-bearing honesty caveat carried EVERY response.
|
|
75
|
+
const DATAGOV_CATALOG_NO_TOTAL_NOTE =
|
|
76
|
+
"data.gov catalog does not report a total match count — use nextCursor to page; totalAvailable is unknown (null). Pagination is an OPAQUE cursor (offset/nextOffset are meaningless/null); pass _meta.nextCursor back as the `cursor` argument. nextCursor:null / hasMore:false means this is the last page.";
|
|
77
|
+
|
|
78
|
+
// ─── The curated dataset shape ────────────────────────────────────
|
|
79
|
+
export type CatalogDistribution = {
|
|
80
|
+
title: string | null;
|
|
81
|
+
format: string | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type CatalogDataset = {
|
|
85
|
+
id: string | null;
|
|
86
|
+
title: string | null;
|
|
87
|
+
organization: string | null;
|
|
88
|
+
description: string | null;
|
|
89
|
+
accessLevel: string | null;
|
|
90
|
+
license: string | null;
|
|
91
|
+
landingPage: string | null;
|
|
92
|
+
modified: string | null;
|
|
93
|
+
lastHarvested: string | null;
|
|
94
|
+
keywords: string[];
|
|
95
|
+
themes: string[];
|
|
96
|
+
distributions: CatalogDistribution[];
|
|
97
|
+
identifier: string | null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** A string[] from a mixed value (drops null/empty via str), else [] when absent/non-array. */
|
|
101
|
+
function strArray(x: unknown): string[] {
|
|
102
|
+
if (!Array.isArray(x)) return [];
|
|
103
|
+
return x.map((v) => str(v)).filter((v): v is string => v !== null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Map ONE `results[]` row → the curated dataset shape. Every scalar via `str`
|
|
108
|
+
* (null-never-empty-string — a missing accessLevel/license/landingPage is null,
|
|
109
|
+
* NEVER ""). description falls back from the row-level to the DCAT-US field.
|
|
110
|
+
* keywords/themes/distributions default to [] (an honest "none listed"); a
|
|
111
|
+
* distribution is kept only when it carries a title OR a format.
|
|
112
|
+
*/
|
|
113
|
+
function mapDataset(row: unknown): CatalogDataset {
|
|
114
|
+
const r = (row ?? {}) as Record<string, unknown>;
|
|
115
|
+
const dcat = (r.dcat ?? {}) as Record<string, unknown>;
|
|
116
|
+
const distributions: CatalogDistribution[] = Array.isArray(dcat.distribution)
|
|
117
|
+
? (dcat.distribution as unknown[])
|
|
118
|
+
.map((d) => {
|
|
119
|
+
const it = (d ?? {}) as Record<string, unknown>;
|
|
120
|
+
return { title: str(it.title), format: str(it.format ?? it.mediaType) };
|
|
121
|
+
})
|
|
122
|
+
.filter((x) => x.title !== null || x.format !== null)
|
|
123
|
+
: [];
|
|
124
|
+
return {
|
|
125
|
+
id: str(r.slug),
|
|
126
|
+
title: str(r.title),
|
|
127
|
+
organization: str(r.organization),
|
|
128
|
+
description: str(r.description ?? dcat.description),
|
|
129
|
+
accessLevel: str(dcat.accessLevel),
|
|
130
|
+
license: str(dcat.license),
|
|
131
|
+
landingPage: str(dcat.landingPage),
|
|
132
|
+
modified: str(dcat.modified),
|
|
133
|
+
lastHarvested: str(r.last_harvested_date),
|
|
134
|
+
keywords: strArray(r.keyword),
|
|
135
|
+
themes: strArray(r.theme),
|
|
136
|
+
distributions,
|
|
137
|
+
identifier: str(dcat.identifier),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
|
|
142
|
+
/**
|
|
143
|
+
* GET one data.gov v4 Catalog JSON resource. `path` is a fixed base path; all
|
|
144
|
+
* caller params ride in `params` (URLSearchParams, encoded). Builds
|
|
145
|
+
* `https://${DATAGOV_CATALOG_HOST}${path}?${params}` on the FIXED host, asserts the
|
|
146
|
+
* CONSTRUCTED URL's hostname === the host over https (belt-and-suspenders), sets
|
|
147
|
+
* `redirect:"error"` (an off-host 3xx must NOT be followed — it could carry the
|
|
148
|
+
* X-Api-Key header to a foreign host), and attaches the key ONLY in the X-Api-Key
|
|
149
|
+
* header. `label` is host+path only (→ ToolError.upstreamEndpoint).
|
|
150
|
+
*/
|
|
151
|
+
async function getDatagovCatalog(
|
|
152
|
+
path: string,
|
|
153
|
+
label: string,
|
|
154
|
+
params: URLSearchParams,
|
|
155
|
+
): Promise<unknown> {
|
|
156
|
+
const qs = params.toString();
|
|
157
|
+
const url = `https://${DATAGOV_CATALOG_HOST}${path}${qs ? `?${qs}` : ""}`;
|
|
158
|
+
const built = new URL(url);
|
|
159
|
+
if (built.hostname !== DATAGOV_CATALOG_HOST || built.protocol !== "https:") {
|
|
160
|
+
throw new ToolErrorCarrier({
|
|
161
|
+
kind: "invalid_input",
|
|
162
|
+
message: `Constructed data.gov catalog URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(DATAGOV_CATALOG_HOST)} over https — refusing to fetch (SSRF safety).`,
|
|
163
|
+
retryable: false,
|
|
164
|
+
upstreamEndpoint: label,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// The key rides in the X-Api-Key header ONLY (never the URL/label/_meta);
|
|
168
|
+
// redirect:"error" (fail closed on any off-host 3xx).
|
|
169
|
+
return getJson(url, { label, headers: keyHeader(), redirect: "error" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Tool: datagov_search_datasets ────────────────────────────────
|
|
173
|
+
export type DatagovSearchDatasetsArgs = {
|
|
174
|
+
query?: string;
|
|
175
|
+
organization?: string;
|
|
176
|
+
limit?: number;
|
|
177
|
+
cursor?: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Search the data.gov v4 dataset catalog (the CKAN-retirement replacement).
|
|
182
|
+
* Filters: `query` (→_q), `organization` (publisher slug), `limit` (→_size),
|
|
183
|
+
* `cursor` (→after, the opaque continuation). The query is MODULE-BUILT from
|
|
184
|
+
* validated typed args through URLSearchParams (NO raw passthrough); `_format=json`
|
|
185
|
+
* is ALWAYS appended. Returns curated dataset rows + honest `_meta`: totalAvailable
|
|
186
|
+
* is NULL (the v4 API reports no count — P1), the opaque-cursor continuation, the
|
|
187
|
+
* accessLevel openness field surfaced verbatim, and the DEMO_KEY rate disclosure.
|
|
188
|
+
*/
|
|
189
|
+
export async function searchDatasets(
|
|
190
|
+
args: DatagovSearchDatasetsArgs,
|
|
191
|
+
): Promise<MetaBundle> {
|
|
192
|
+
const label = DATAGOV_CATALOG_LABEL;
|
|
193
|
+
const limit = args.limit ?? 20;
|
|
194
|
+
|
|
195
|
+
// ── Belt-and-suspenders cursor grammar (behind the server's Zod). A bad cursor
|
|
196
|
+
// would ride `after=` and either 400 or silently mis-page; reject it pre-fetch
|
|
197
|
+
// (0 network call) so a `../` / space / `%` can never reach the query. ──
|
|
198
|
+
if (args.cursor !== undefined && !DATAGOV_CURSOR_RE.test(args.cursor)) {
|
|
199
|
+
throw new ToolErrorCarrier({
|
|
200
|
+
kind: "invalid_input",
|
|
201
|
+
message: `Invalid cursor (opaque continuation token) — must be a ≤4096-char base64/URL-safe token (no spaces, '../', or '%'). Pass back the _meta.nextCursor from the previous page.`,
|
|
202
|
+
retryable: false,
|
|
203
|
+
upstreamEndpoint: label,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
|
|
208
|
+
// passthrough). _format=json is ALWAYS appended. ──
|
|
209
|
+
const params = new URLSearchParams();
|
|
210
|
+
const filtersApplied: string[] = [];
|
|
211
|
+
if (args.query !== undefined) {
|
|
212
|
+
params.set("_q", args.query);
|
|
213
|
+
filtersApplied.push("query");
|
|
214
|
+
}
|
|
215
|
+
if (args.organization !== undefined) {
|
|
216
|
+
params.set("organization", args.organization);
|
|
217
|
+
filtersApplied.push("organization");
|
|
218
|
+
}
|
|
219
|
+
params.set("_size", String(limit));
|
|
220
|
+
if (args.cursor !== undefined) {
|
|
221
|
+
params.set("after", args.cursor);
|
|
222
|
+
filtersApplied.push("cursor");
|
|
223
|
+
}
|
|
224
|
+
params.set("_format", "json");
|
|
225
|
+
|
|
226
|
+
// ── The typed catch-ladder (datagov.ts searchDockets shape, VERBATIM). Preserve
|
|
227
|
+
// the 429/404/5xx/400/timeout ToolErrorCarrier taxonomy FIRST (LOAD-BEARING:
|
|
228
|
+
// the DEMO_KEY-~10/hr 429→rate_limited frontier would regress to schema_drift
|
|
229
|
+
// under a broader catch); reclassify a 200 non-JSON `.json()` SyntaxError to
|
|
230
|
+
// schema_drift SECOND; bare-rethrow LAST. The host-assert ToolErrorCarrier is
|
|
231
|
+
// also rethrown first. ──
|
|
232
|
+
let body: unknown;
|
|
233
|
+
try {
|
|
234
|
+
body = await getDatagovCatalog(DATAGOV_CATALOG_SEARCH_PATH, label, params);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
237
|
+
if (e instanceof SyntaxError)
|
|
238
|
+
throw driftError(
|
|
239
|
+
label,
|
|
240
|
+
"data.gov catalog returned a non-JSON body at HTTP 200 — schema drift.",
|
|
241
|
+
);
|
|
242
|
+
throw e;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const b = (body ?? {}) as { results?: unknown; after?: unknown };
|
|
246
|
+
|
|
247
|
+
// ── P4: `results` MUST be an array (a missing/string/null results is drift,
|
|
248
|
+
// never a fabricated empty — a TypeError must never mask drift as
|
|
249
|
+
// upstream_unavailable). ──
|
|
250
|
+
if (!Array.isArray(b.results)) {
|
|
251
|
+
throw driftError(
|
|
252
|
+
label,
|
|
253
|
+
"data.gov catalog shape drift — /search response.results must be an array.",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const datasets = (b.results as unknown[]).map(mapDataset);
|
|
258
|
+
const returned = datasets.length;
|
|
259
|
+
|
|
260
|
+
// ── P1: NO total is reported ⇒ totalAvailable = null (NEVER results.length,
|
|
261
|
+
// NEVER a fabricated total). Pagination is the opaque `after` cursor: hasMore
|
|
262
|
+
// from cursor-presence; nextCursor passed back VERBATIM (never derived). ──
|
|
263
|
+
const rawAfter = b.after;
|
|
264
|
+
const hasMore = typeof rawAfter === "string" && rawAfter.length > 0;
|
|
265
|
+
const nextCursor = hasMore ? (rawAfter as string) : null;
|
|
266
|
+
|
|
267
|
+
const notes: string[] = [DATAGOV_CATALOG_NO_TOTAL_NOTE];
|
|
268
|
+
pushKeyNote(notes);
|
|
269
|
+
if (filtersApplied.length === 0) {
|
|
270
|
+
notes.push(
|
|
271
|
+
"No filters were applied — this is an unscoped scan of the WHOLE data.gov catalog. Add `query` and/or `organization` for a meaningful scoped result set.",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return withMeta(
|
|
276
|
+
{ datasets },
|
|
277
|
+
{
|
|
278
|
+
source: DATAGOV_CATALOG_SOURCE(keyModeLabel()),
|
|
279
|
+
keylessMode: false, // genuinely keyed (api.data.gov X-Api-Key)
|
|
280
|
+
returned,
|
|
281
|
+
// P1 — the v4 API reports NO match count. NULL, never results.length.
|
|
282
|
+
totalAvailable: null,
|
|
283
|
+
// complete is DERIVED by buildMeta: a page with no `after` (hasMore:false) and
|
|
284
|
+
// no dropped filters ⇒ complete:true (an honest exact empty on a no-match); a
|
|
285
|
+
// page WITH an `after` cursor ⇒ hasMore:true ⇒ complete:false.
|
|
286
|
+
filtersApplied,
|
|
287
|
+
filtersDropped: [],
|
|
288
|
+
fieldsUnavailable: [],
|
|
289
|
+
// Cursor page: offset/nextOffset null (no numeric offset); continuation is
|
|
290
|
+
// nextCursor (the opaque `after` token, passed back verbatim as `cursor`).
|
|
291
|
+
pagination: { offset: null, limit, hasMore, nextOffset: null },
|
|
292
|
+
nextCursor,
|
|
293
|
+
notes,
|
|
294
|
+
} satisfies Partial<ResponseMeta>,
|
|
295
|
+
);
|
|
296
|
+
}
|