@cliwant/mcp-sam-gov 0.3.0 → 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 +226 -64
- 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 +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- 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 +74 -1
- 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 +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- 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 +419 -3
- 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 +111 -6
- 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 +3889 -296
- 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 +1629 -201
- 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 +5 -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 +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- 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 +139 -7
- 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 +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/src/ckan.ts
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CKAN `datastore_search` — keyless open data for state / local (SLED) portals.
|
|
3
|
+
*
|
|
4
|
+
* Second SLED family after Socrata (ADR-0006) and the FIRST source built on the
|
|
5
|
+
* R2 `DataSource` port (ADR-0005): CKAN writes ZERO fetch/coercion/error code —
|
|
6
|
+
* it reuses `getJson` / `driftError` / `num`·`str` / `withMeta` and COPIES (does
|
|
7
|
+
* NOT import) Socrata's SSRF + honesty PATTERN. Fully PUBLIC, KEYLESS — the CKAN
|
|
8
|
+
* datastore is anonymous-readable, so there is NO token anywhere in scope (no
|
|
9
|
+
* `headers`, byte-clean init).
|
|
10
|
+
*
|
|
11
|
+
* CKAN is Socrata's sibling — another multi-portal keyless open-data platform
|
|
12
|
+
* reached by swapping a hostname + a dataset id on one identical JSON Action API.
|
|
13
|
+
* We keep the guard per-source (host lists, id grammar, and the response envelope
|
|
14
|
+
* all differ — ADR-0006 §7) and do NOT unify with `socrata.ts`.
|
|
15
|
+
* Row query: https://{host}/api/3/action/datastore_search?resource_id={uuid}&…
|
|
16
|
+
* Discovery: https://{host}/api/3/action/package_search?q=…&rows=N
|
|
17
|
+
*
|
|
18
|
+
* Unlike SODA (a bare array, NO total), CKAN WRAPS records in an envelope that
|
|
19
|
+
* carries `result.total` and `result.total_was_estimated` DIRECTLY — so no
|
|
20
|
+
* count(*) companion is needed; the total is read from the body.
|
|
21
|
+
*
|
|
22
|
+
* ★ SSRF GUARD (policy① — the central design risk). COPY Socrata's 4-step order:
|
|
23
|
+
* (1) `host` ∈ CKAN_HOSTS — a Zod enum at the server boundary (single source of
|
|
24
|
+
* truth) AND a belt-and-suspenders `Set.has` check in the fetch fn → else
|
|
25
|
+
* invalid_input BEFORE any fetch;
|
|
26
|
+
* (2) `resourceId` matches the CKAN UUID grammar — the server's Zod
|
|
27
|
+
* `.length(36).regex(UUID_RE)` (NO `.trim()`, NO `i` flag) PLUS a runtime
|
|
28
|
+
* recheck in the fetch fn (M1 — parallel to the host `Set.has` recheck;
|
|
29
|
+
* Socrata re-checks datasetId too). m1: JS `$` does NOT match before a
|
|
30
|
+
* trailing `\n` (unlike Python), so the regex ALONE rejects a newline;
|
|
31
|
+
* `.length(36)` is belt-and-suspenders, not the primary newline guard.
|
|
32
|
+
* m2: the grammar is LOWERCASE hex only — every live id on every host is a
|
|
33
|
+
* 36-char lowercase UUID (confirmed) — so we keep it strict (no `i` flag);
|
|
34
|
+
* (3) FIXED path `https://${host}/api/3/action/datastore_search` (only `host`
|
|
35
|
+
* interpolates into the authority; `resourceId` goes through URLSearchParams,
|
|
36
|
+
* NEVER the path). Then ASSERT `new URL(built).hostname === host` and
|
|
37
|
+
* `protocol === "https:"` → invalid_input on mismatch;
|
|
38
|
+
* (4) all params (`resource_id`, `q`, `filters`, `sort`, `limit`, `offset`) via
|
|
39
|
+
* URLSearchParams — encoded values, no host-alteration surface. Bad
|
|
40
|
+
* `filters`/`sort` are upstream-validated (409 → invalid_input, surfaced).
|
|
41
|
+
*
|
|
42
|
+
* B1 (SSRF, redirect) — `datastore_search`/`package_search` are direct-JSON
|
|
43
|
+
* endpoints, so a 3xx off an allowlisted host is anomalous (migration / DNS
|
|
44
|
+
* hijack / reused domain) and would otherwise be followed off-allowlist past the
|
|
45
|
+
* pre-check. We set `redirect:"error"` in EVERY getJson call (getJson forwards
|
|
46
|
+
* `init.redirect` to fetch verbatim — a redirect throws, its body is never read).
|
|
47
|
+
*
|
|
48
|
+
* Security non-goal (explicit): we expose ONLY `datastore_search` (parameterized)
|
|
49
|
+
* and `package_search`. We do NOT expose `datastore_search_sql` — the raw-SQL
|
|
50
|
+
* CKAN endpoint IS an injection surface and is out of scope. `filters` is a
|
|
51
|
+
* CONSTRAINED object we `JSON.stringify` (Q3), never caller-supplied raw SQL/JSON.
|
|
52
|
+
* We NEVER pass `total_estimation_threshold` or `include_total:false` (Q4) — we
|
|
53
|
+
* always request an EXACT total; the estimate path fires only if a host is
|
|
54
|
+
* server-side reconfigured, hence defensive.
|
|
55
|
+
*
|
|
56
|
+
* HONESTY (`_meta`) — the load-bearing B1 fix (ADR-0006 v2):
|
|
57
|
+
* - EXACT total (the default — `total_estimation_threshold` is null on all
|
|
58
|
+
* hosts → `total_was_estimated:false`): `totalAvailable = num(result.total)`
|
|
59
|
+
* (exact), `hasMore = offset + returned < total` (or the B2 hedge
|
|
60
|
+
* `returned >= limit` if `total` is absent — a host running include_total:
|
|
61
|
+
* false). Genuine-empty (records:[], total:0) → complete:true / total:0.
|
|
62
|
+
* - ESTIMATED total (`total_was_estimated:true` — rare/defensive): the estimate
|
|
63
|
+
* is a Postgres `reltuples` approximation that can land ABOVE OR BELOW the
|
|
64
|
+
* truth (live-verified 345285 vs 344504 — an OVERSHOOT), so it is NOT a lower
|
|
65
|
+
* bound and MUST NOT drive pagination. Pass `totalAvailable:null` (so
|
|
66
|
+
* buildMeta's `totalProvesTruncation` does not fire on the estimate),
|
|
67
|
+
* `hasMore = returned >= limit` (paginate by page-fullness — the anti-livelock
|
|
68
|
+
* guard), `totalIsEstimated:true`, and a `_meta.notes` disclosure carrying the
|
|
69
|
+
* estimate value.
|
|
70
|
+
* - `success:false` (even on HTTP 200) or `result`/`records` missing/non-array
|
|
71
|
+
* → THROW (never a fake empty). Modern hosts surface 404/409 (→ not_found /
|
|
72
|
+
* invalid_input via errorFromResponse); this guard covers a 200+success:false
|
|
73
|
+
* host/proxy. outage/5xx/timeout → getJson throws, never `[]`.
|
|
74
|
+
* - `num`/`str` are null-never-0 (a missing value is honest "unknown", never 0).
|
|
75
|
+
*
|
|
76
|
+
* ALLOWLIST — LIVE-VERIFIED 2026-07-12 (each `.gov`, government-controlled, a live
|
|
77
|
+
* CKAN datastore; each carries a real datastore-active sample resource_id that
|
|
78
|
+
* returned HTTP 200 + the envelope above). NO commercial-vendor domains (the
|
|
79
|
+
* Socrata Tyler-Technologies M1 mistake is avoided). `data.virginia.gov` is the
|
|
80
|
+
* same VA that CHURNED OFF Socrata (excluded from the SODA allowlist) — it belongs
|
|
81
|
+
* HERE on CKAN. `data.ok.gov` is DEFERRED to SOURCE_BACKLOG (only a 9-row thin
|
|
82
|
+
* datastore verified — require a 2nd procurement-relevant >1k-row resource before
|
|
83
|
+
* adding). `catalog.data.gov` (federal harvester, no active datastore) is dropped.
|
|
84
|
+
* Adding a host later = a CKAN_HOSTS SOURCE edit + a live `datastore_search?
|
|
85
|
+
* limit=1` verification + an ownership note + a test-fixture note — NEVER a free
|
|
86
|
+
* runtime param.
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
90
|
+
import { getJson, driftError } from "./datasource.js";
|
|
91
|
+
import { num, str } from "./coerce.js";
|
|
92
|
+
import { memoize } from "./cache.js";
|
|
93
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
94
|
+
|
|
95
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js —
|
|
96
|
+
// ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
|
|
97
|
+
// `num` from this module (ckan.num === socrata.num === treasury.num === coerce.num).
|
|
98
|
+
export { num };
|
|
99
|
+
|
|
100
|
+
// ─── Curated allowlist (SSRF core) ────────────────────────────────
|
|
101
|
+
// A frozen list of live-verified (2026-07-12) CKAN datastore hosts. The Zod
|
|
102
|
+
// `host` enum in server.ts is built FROM this array (single source of truth), and
|
|
103
|
+
// the fetch fns re-check membership (belt-and-suspenders). Each entry carries a
|
|
104
|
+
// real sample resource_id (datastore_active) confirmed to return HTTP 200 + the
|
|
105
|
+
// {success, result:{records, total, total_was_estimated, fields}} envelope.
|
|
106
|
+
// Re-verify each with `GET https://{host}/api/3/action/datastore_search?
|
|
107
|
+
// resource_id={uuid}&limit=1` when touching this list.
|
|
108
|
+
export const CKAN_HOSTS = [
|
|
109
|
+
// State of California (CDT/GovOps) — Statewide Purchase Order Data 2012–2015,
|
|
110
|
+
// resource_id bb82edc5-9c78-44e2-8947-68ece26197c5 (~344,504 rows).
|
|
111
|
+
"data.ca.gov",
|
|
112
|
+
// Commonwealth of Virginia (VITA) — the VA that churned off Socrata. Norfolk
|
|
113
|
+
// SWaM Certified Businesses, resource_id f6804560-bf9e-44a4-92bf-bf3dd7d1fd60.
|
|
114
|
+
"data.virginia.gov",
|
|
115
|
+
// City of Boston (DoIT) — Checkbook Explorer (city spend), resource_id
|
|
116
|
+
// d22fdd5c-7e4c-41b7-a3eb-dfc57a87b245 (~101,465 rows).
|
|
117
|
+
"data.boston.gov",
|
|
118
|
+
] as const;
|
|
119
|
+
|
|
120
|
+
export type CkanHost = (typeof CKAN_HOSTS)[number];
|
|
121
|
+
|
|
122
|
+
const CKAN_HOST_SET: ReadonlySet<string> = new Set(CKAN_HOSTS);
|
|
123
|
+
|
|
124
|
+
// A CKAN datastore resource_id is EXACTLY a 36-char LOWERCASE hex UUID. m1: JS
|
|
125
|
+
// `$` does NOT admit a trailing "\n" (unlike Python), so this regex alone rejects
|
|
126
|
+
// a newline; `.length(36)` (server + the runtime recheck below) is belt-and-
|
|
127
|
+
// suspenders. m2: lowercase-only — no `i` flag (all live ids are lowercase).
|
|
128
|
+
const UUID_RE =
|
|
129
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
130
|
+
|
|
131
|
+
// ─── HONESTY-CRITICAL coercions (null, never 0, for absent) ───────
|
|
132
|
+
// `num`/`str` are the shared, audited null-never-0 coercions in ./coerce.js
|
|
133
|
+
// (imported above): null/undefined, the literal "null", ""/whitespace
|
|
134
|
+
// (Number("") is 0!), and "(-)"/"-" all become null (never 0).
|
|
135
|
+
|
|
136
|
+
// ─── fetch layer ──────────────────────────────────────────────────
|
|
137
|
+
export type CkanRecord = Record<string, unknown>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* GET one CKAN `datastore_search` resource. SSRF guard (belt-and-suspenders
|
|
141
|
+
* behind the server's Zod enum+regex): host ∈ allowlist, resourceId is a 36-char
|
|
142
|
+
* lowercase UUID (M1 runtime recheck), and the CONSTRUCTED URL's hostname === host
|
|
143
|
+
* (https). Sets `redirect:"error"` (B1); NO headers (keyless — the datastore is
|
|
144
|
+
* anonymous). Reuses errors.ts retry/timeout/taxonomy (429 → rate_limited;
|
|
145
|
+
* 5xx → upstream_unavailable; 404 → not_found; 409/400 → invalid_input). Returns
|
|
146
|
+
* the parsed JSON (unknown; the caller validates the envelope shape).
|
|
147
|
+
*/
|
|
148
|
+
async function getCkanDatastore(
|
|
149
|
+
host: string,
|
|
150
|
+
resourceId: string,
|
|
151
|
+
params: URLSearchParams,
|
|
152
|
+
): Promise<unknown> {
|
|
153
|
+
if (!CKAN_HOST_SET.has(host)) {
|
|
154
|
+
throw new ToolErrorCarrier({
|
|
155
|
+
kind: "invalid_input",
|
|
156
|
+
message: `CKAN host ${JSON.stringify(host)} is not on the curated allowlist. Allowed: ${CKAN_HOSTS.join(", ")}.`,
|
|
157
|
+
retryable: false,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// M1 — runtime belt-and-suspenders on resourceId (parallel to the host Set.has
|
|
161
|
+
// recheck; faithful copy of Socrata's re-check). No URLSearchParams-bypass
|
|
162
|
+
// exists, but this is defense-in-depth for the SSRF path-interpolation vector.
|
|
163
|
+
if (resourceId.length !== 36 || !UUID_RE.test(resourceId)) {
|
|
164
|
+
throw new ToolErrorCarrier({
|
|
165
|
+
kind: "invalid_input",
|
|
166
|
+
message: `Invalid CKAN resourceId ${JSON.stringify(resourceId)} — expected a 36-char lowercase UUID ([0-9a-f]{8}-{4}-{4}-{4}-{12}).`,
|
|
167
|
+
retryable: false,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
params.set("resource_id", resourceId);
|
|
171
|
+
const url = `https://${host}/api/3/action/datastore_search?${params.toString()}`;
|
|
172
|
+
// Belt-and-suspenders: the FIXED path leaves only host to interpolate into the
|
|
173
|
+
// authority; assert the built URL cannot have been steered off-host.
|
|
174
|
+
const built = new URL(url);
|
|
175
|
+
if (built.hostname !== host || built.protocol !== "https:") {
|
|
176
|
+
throw new ToolErrorCarrier({
|
|
177
|
+
kind: "invalid_input",
|
|
178
|
+
message: `Constructed CKAN URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the allowlisted host ${JSON.stringify(host)} over https — refusing to fetch (SSRF safety).`,
|
|
179
|
+
retryable: false,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Shared fetch envelope (ADR-0005): keyless ⇒ NO headers key (byte-clean init);
|
|
183
|
+
// B1 redirect:"error"; host-only label → ToolError.upstreamEndpoint.
|
|
184
|
+
return getJson(url, { label: "ckan:" + host, redirect: "error" });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* GET a CKAN `package_search` (discovery). Same host allowlist + fixed path +
|
|
189
|
+
* hostname assertion + redirect:"error" policy as getCkanDatastore.
|
|
190
|
+
*/
|
|
191
|
+
async function getCkanPackageSearch(
|
|
192
|
+
host: string,
|
|
193
|
+
params: URLSearchParams,
|
|
194
|
+
): Promise<unknown> {
|
|
195
|
+
if (!CKAN_HOST_SET.has(host)) {
|
|
196
|
+
throw new ToolErrorCarrier({
|
|
197
|
+
kind: "invalid_input",
|
|
198
|
+
message: `CKAN host ${JSON.stringify(host)} is not on the curated allowlist. Allowed: ${CKAN_HOSTS.join(", ")}.`,
|
|
199
|
+
retryable: false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const url = `https://${host}/api/3/action/package_search?${params.toString()}`;
|
|
203
|
+
const built = new URL(url);
|
|
204
|
+
if (built.hostname !== host || built.protocol !== "https:") {
|
|
205
|
+
throw new ToolErrorCarrier({
|
|
206
|
+
kind: "invalid_input",
|
|
207
|
+
message: `Constructed CKAN catalog URL host ${JSON.stringify(built.hostname)} does not match the allowlisted host ${JSON.stringify(host)} over https — refusing to fetch (SSRF safety).`,
|
|
208
|
+
retryable: false,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return getJson(url, { label: "ckan:" + host, redirect: "error" });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── map + meta helpers ───────────────────────────────────────────
|
|
215
|
+
const VALUE_TYPING_NOTE =
|
|
216
|
+
"Record values follow result.fields[].type (int/text/numeric/timestamp); a numeric column may arrive as a JSON number OR a string — parse client-side. A missing value is absent, never 0.";
|
|
217
|
+
|
|
218
|
+
const SOURCE_SUFFIX = "via CKAN datastore_search (keyless)";
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The `body.success === false` taxonomy (m3). Parse `body.error.__type`:
|
|
222
|
+
* "Not Found Error" → not_found
|
|
223
|
+
* "Validation Error" → invalid_input
|
|
224
|
+
* anything else → schema_drift (NOT a blanket not_found/driftError of the
|
|
225
|
+
* wrong kind — an unknown __type is genuine drift)
|
|
226
|
+
* This is the DEFENSIVE guard for a host/proxy that returns HTTP 200 with
|
|
227
|
+
* success:false (modern hosts surface real 404/409, handled by errorFromResponse
|
|
228
|
+
* before we ever see the body).
|
|
229
|
+
*/
|
|
230
|
+
function throwCkanApiError(host: string, err: unknown): never {
|
|
231
|
+
const type = str((err as { __type?: unknown } | null | undefined)?.__type);
|
|
232
|
+
const message = str((err as { message?: unknown } | null | undefined)?.message);
|
|
233
|
+
const label = "ckan:" + host;
|
|
234
|
+
if (type === "Not Found Error") {
|
|
235
|
+
throw new ToolErrorCarrier({
|
|
236
|
+
kind: "not_found",
|
|
237
|
+
message: `CKAN ${host} reported Not Found${message ? `: ${message}` : "."}`,
|
|
238
|
+
retryable: false,
|
|
239
|
+
upstreamEndpoint: label,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (type === "Validation Error") {
|
|
243
|
+
throw new ToolErrorCarrier({
|
|
244
|
+
kind: "invalid_input",
|
|
245
|
+
message: `CKAN ${host} reported a Validation Error${message ? `: ${message}` : "."}`,
|
|
246
|
+
retryable: false,
|
|
247
|
+
upstreamEndpoint: label,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
throw driftError(
|
|
251
|
+
label,
|
|
252
|
+
`CKAN ${host} returned success:false with an unrecognized error type ${JSON.stringify(type)} — treating as schema drift.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Tool 1: ckan_query ───────────────────────────────────────────
|
|
257
|
+
/**
|
|
258
|
+
* Query rows from an allowlisted CKAN datastore resource. The workhorse: reaches
|
|
259
|
+
* every datastore-active spend/checkbook/procurement/vendor/certification table on
|
|
260
|
+
* any allowlisted portal. Records pass through verbatim (values are typed per
|
|
261
|
+
* result.fields[].type).
|
|
262
|
+
*
|
|
263
|
+
* HONESTY: CKAN's envelope carries a real `result.total`. The DEFAULT path is an
|
|
264
|
+
* EXACT total (`total_was_estimated:false`) → exact totalAvailable + exact
|
|
265
|
+
* hasMore. The rare ESTIMATED path (`total_was_estimated:true`) is defused per
|
|
266
|
+
* ADR-0006 B1: totalAvailable:null + hasMore-by-page-fullness + totalIsEstimated +
|
|
267
|
+
* an estimate note (the estimate can be above OR below the truth, so it is NOT a
|
|
268
|
+
* lower bound and MUST NOT drive pagination). A genuine-empty (records:[],
|
|
269
|
+
* total:0) → complete:true/total:0; success:false / a missing-or-non-array
|
|
270
|
+
* `result.records` / a non-number `result.total` → THROWS (never a fake empty).
|
|
271
|
+
*/
|
|
272
|
+
export async function query(args: {
|
|
273
|
+
host: CkanHost;
|
|
274
|
+
resourceId: string;
|
|
275
|
+
q?: string;
|
|
276
|
+
filters?: Record<string, string | number | Array<string | number>>;
|
|
277
|
+
sort?: string;
|
|
278
|
+
limit?: number;
|
|
279
|
+
offset?: number;
|
|
280
|
+
}): Promise<MetaBundle> {
|
|
281
|
+
const limit = args.limit ?? 100;
|
|
282
|
+
const offset = args.offset ?? 0;
|
|
283
|
+
|
|
284
|
+
const params = new URLSearchParams();
|
|
285
|
+
params.set("limit", String(limit));
|
|
286
|
+
params.set("offset", String(offset));
|
|
287
|
+
if (args.q) params.set("q", args.q);
|
|
288
|
+
if (args.sort) params.set("sort", args.sort);
|
|
289
|
+
// Q3 — `filters` is a constrained object WE serialize; never a caller-supplied
|
|
290
|
+
// raw JSON string. A bad field still surfaces as an upstream 409 → invalid_input.
|
|
291
|
+
if (args.filters !== undefined) params.set("filters", JSON.stringify(args.filters));
|
|
292
|
+
|
|
293
|
+
const body = await getCkanDatastore(args.host, args.resourceId, params);
|
|
294
|
+
const b = (body ?? {}) as {
|
|
295
|
+
success?: unknown;
|
|
296
|
+
error?: unknown;
|
|
297
|
+
result?: {
|
|
298
|
+
records?: unknown;
|
|
299
|
+
total?: unknown;
|
|
300
|
+
total_was_estimated?: unknown;
|
|
301
|
+
fields?: unknown;
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ── Shape guards (ORDER matters). ──
|
|
306
|
+
// (1) success:false (even on HTTP 200) → taxonomy-classified throw (m3), NEVER
|
|
307
|
+
// a fake empty.
|
|
308
|
+
if (b.success === false) throwCkanApiError(args.host, b.error);
|
|
309
|
+
// (2) result / records must be an array (m5) — a missing result or a
|
|
310
|
+
// string/null `records` is drift, never `[]`.
|
|
311
|
+
if (!b.result || !Array.isArray(b.result.records)) {
|
|
312
|
+
throw driftError(
|
|
313
|
+
"ckan:" + args.host,
|
|
314
|
+
`ckan:${args.host} datastore_search returned an unexpected shape (result.records must be an array).`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
// (3) a PRESENT `total` must be a number (m6) — num() cannot distinguish a
|
|
318
|
+
// non-number total from an absent one, so typeof-check BEFORE num().
|
|
319
|
+
if (b.result.total !== undefined && typeof b.result.total !== "number") {
|
|
320
|
+
throw driftError(
|
|
321
|
+
"ckan:" + args.host,
|
|
322
|
+
`ckan:${args.host} datastore_search returned a non-number result.total — treating as schema drift.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const records = b.result.records as CkanRecord[];
|
|
327
|
+
const fields = Array.isArray(b.result.fields) ? b.result.fields : [];
|
|
328
|
+
const returned = records.length;
|
|
329
|
+
const estimated = b.result.total_was_estimated === true;
|
|
330
|
+
|
|
331
|
+
const filtersApplied: string[] = [];
|
|
332
|
+
if (args.q) filtersApplied.push("q");
|
|
333
|
+
if (args.filters !== undefined) filtersApplied.push("filters");
|
|
334
|
+
if (args.sort) filtersApplied.push("sort");
|
|
335
|
+
|
|
336
|
+
const notes: string[] = [VALUE_TYPING_NOTE];
|
|
337
|
+
|
|
338
|
+
let totalAvailable: number | null;
|
|
339
|
+
let hasMore: boolean;
|
|
340
|
+
let totalIsEstimated: boolean | undefined;
|
|
341
|
+
|
|
342
|
+
if (estimated) {
|
|
343
|
+
// B1 — an ESTIMATE must NOT drive pagination. Pass null (so buildMeta derives
|
|
344
|
+
// truncated/complete from page-fullness alone), paginate by page-fullness, and
|
|
345
|
+
// disclose the estimate value + flag. The empty-trailing-page case (returned 0
|
|
346
|
+
// < limit) → hasMore:false → complete:true (the anti-livelock guard).
|
|
347
|
+
totalAvailable = null;
|
|
348
|
+
hasMore = returned >= limit;
|
|
349
|
+
totalIsEstimated = true;
|
|
350
|
+
const estValue = num(b.result.total);
|
|
351
|
+
notes.push(
|
|
352
|
+
`totalAvailable is withheld because the upstream reported it as a server-reported ESTIMATE${estValue !== null ? ` ~${estValue}` : ""}; it is not exact and may be above OR below the true count — paginate by page-fullness (hasMore is inferred from whether the page filled), not by this estimate.`,
|
|
353
|
+
);
|
|
354
|
+
} else {
|
|
355
|
+
// EXACT total (the default). B2 hedge: if `total` is somehow absent (a host
|
|
356
|
+
// running include_total:false), fall back to page-fullness so an unknown
|
|
357
|
+
// total never lies complete:true.
|
|
358
|
+
totalAvailable = num(b.result.total);
|
|
359
|
+
hasMore =
|
|
360
|
+
totalAvailable !== null
|
|
361
|
+
? offset + returned < totalAvailable
|
|
362
|
+
: returned >= limit;
|
|
363
|
+
if (totalAvailable === null) {
|
|
364
|
+
notes.push(
|
|
365
|
+
"The upstream did not report result.total; completeness is inferred from page-fullness (CKAN returns a short page only when the result set is exhausted).",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
370
|
+
|
|
371
|
+
const meta: Partial<ResponseMeta> = {
|
|
372
|
+
source: `${args.host} ${SOURCE_SUFFIX}`,
|
|
373
|
+
keylessMode: true,
|
|
374
|
+
returned,
|
|
375
|
+
totalAvailable,
|
|
376
|
+
filtersApplied,
|
|
377
|
+
filtersDropped: [],
|
|
378
|
+
fieldsUnavailable: [],
|
|
379
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
380
|
+
notes,
|
|
381
|
+
};
|
|
382
|
+
if (totalIsEstimated !== undefined) meta.totalIsEstimated = totalIsEstimated;
|
|
383
|
+
|
|
384
|
+
return withMeta(
|
|
385
|
+
{ host: args.host, resourceId: args.resourceId, fields, records },
|
|
386
|
+
meta,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── Tool 2: ckan_discover_datasets ───────────────────────────────
|
|
391
|
+
export type CkanDiscoveredResource = {
|
|
392
|
+
resourceId: string | null;
|
|
393
|
+
name: string | null;
|
|
394
|
+
datasetTitle: string | null;
|
|
395
|
+
format: string | null;
|
|
396
|
+
datastoreActive: boolean;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/** Flatten one package_search `results[]` package into its resource rows. */
|
|
400
|
+
function mapPackageResources(pkg: unknown): CkanDiscoveredResource[] {
|
|
401
|
+
const p = (pkg ?? {}) as { title?: unknown; resources?: unknown };
|
|
402
|
+
const datasetTitle = str(p.title);
|
|
403
|
+
const resources = Array.isArray(p.resources) ? p.resources : [];
|
|
404
|
+
return resources.map((res) => {
|
|
405
|
+
const r = (res ?? {}) as {
|
|
406
|
+
id?: unknown;
|
|
407
|
+
name?: unknown;
|
|
408
|
+
format?: unknown;
|
|
409
|
+
datastore_active?: unknown;
|
|
410
|
+
};
|
|
411
|
+
return {
|
|
412
|
+
resourceId: str(r.id),
|
|
413
|
+
name: str(r.name),
|
|
414
|
+
datasetTitle,
|
|
415
|
+
format: str(r.format),
|
|
416
|
+
// Only a queryable (datastore-active) resource can feed ckan_query.
|
|
417
|
+
datastoreActive: r.datastore_active === true,
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Discover datastore resource ids via CKAN `package_search` (memoized ~10 min).
|
|
424
|
+
* Returns per-RESOURCE rows `[{ resourceId, name, datasetTitle, format,
|
|
425
|
+
* datastoreActive }]` + `totalAvailable = result.count` (the DATASET match count).
|
|
426
|
+
* Feed a `datastoreActive:true` resource's `resourceId` to ckan_query.
|
|
427
|
+
*
|
|
428
|
+
* m6 — this is the catalog's PRIMARY response: a non-number `result.count` is a
|
|
429
|
+
* hard schema_drift throw (nothing valid to return), never a fabricated total —
|
|
430
|
+
* INSIDE the memoize callback so a bad shape is never cached as a success
|
|
431
|
+
* (ADR-0005 v2 test-2 rule).
|
|
432
|
+
*/
|
|
433
|
+
export async function discoverDatasets(args: {
|
|
434
|
+
host: CkanHost;
|
|
435
|
+
q: string;
|
|
436
|
+
limit?: number;
|
|
437
|
+
}): Promise<MetaBundle> {
|
|
438
|
+
const limit = args.limit ?? 20;
|
|
439
|
+
const params = new URLSearchParams();
|
|
440
|
+
params.set("q", args.q);
|
|
441
|
+
params.set("rows", String(limit));
|
|
442
|
+
|
|
443
|
+
const key = `ckan:package_search:${args.host}:${args.q}:${limit}`;
|
|
444
|
+
const { totalAvailable, results } = await memoize(
|
|
445
|
+
key,
|
|
446
|
+
async () => {
|
|
447
|
+
const body = await getCkanPackageSearch(args.host, params);
|
|
448
|
+
const b = (body ?? {}) as {
|
|
449
|
+
success?: unknown;
|
|
450
|
+
error?: unknown;
|
|
451
|
+
result?: { count?: unknown; results?: unknown };
|
|
452
|
+
};
|
|
453
|
+
if (b.success === false) throwCkanApiError(args.host, b.error);
|
|
454
|
+
if (!b.result) {
|
|
455
|
+
throw driftError(
|
|
456
|
+
"ckan:" + args.host,
|
|
457
|
+
`ckan:${args.host} package_search returned an unexpected shape (result missing).`,
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
// m6 — a non-number count is drift on the PRIMARY response (contrast a
|
|
461
|
+
// best-effort secondary). Check stays INSIDE memoize so it is never cached.
|
|
462
|
+
if (typeof b.result.count !== "number") {
|
|
463
|
+
throw driftError(
|
|
464
|
+
"ckan:" + args.host,
|
|
465
|
+
`ckan:${args.host} package_search returned a non-number result.count — treating as schema drift.`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
const total = num(b.result.count);
|
|
469
|
+
const packages = Array.isArray(b.result.results) ? b.result.results : [];
|
|
470
|
+
const rows = packages.flatMap(mapPackageResources);
|
|
471
|
+
return { totalAvailable: total, results: rows };
|
|
472
|
+
},
|
|
473
|
+
10 * 60 * 1000,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const returned = results.length;
|
|
477
|
+
const notes: string[] = [
|
|
478
|
+
`package_search over ${args.host} (rows=${limit}). Feed a datastoreActive:true result's resourceId to ckan_query; a datastoreActive:false resource is a raw file blob (CSV/PDF/…) NOT in the datastore and is NOT queryable.`,
|
|
479
|
+
"totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned may differ from totalAvailable.",
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
return withMeta(
|
|
483
|
+
{ host: args.host, query: args.q, results },
|
|
484
|
+
{
|
|
485
|
+
source: `${args.host} package_search ${SOURCE_SUFFIX}`,
|
|
486
|
+
keylessMode: true,
|
|
487
|
+
returned,
|
|
488
|
+
totalAvailable,
|
|
489
|
+
filtersApplied: ["q"],
|
|
490
|
+
filtersDropped: [],
|
|
491
|
+
fieldsUnavailable: [],
|
|
492
|
+
notes,
|
|
493
|
+
} satisfies Partial<ResponseMeta>,
|
|
494
|
+
);
|
|
495
|
+
}
|