@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/cms.ts
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS Open Payments — the healthcare industry→physician payment TRANSPARENCY lane
|
|
3
|
+
* on a NEW **DKAN 2.x** datastore adapter (keyless). Source #28 (ADR-0037).
|
|
4
|
+
*
|
|
5
|
+
* A third data-portal family after Socrata SODA (src/socrata.ts) and CKAN Action
|
|
6
|
+
* API (src/ckan.ts) — the DKAN DCAT-metastore + `/api/1/datastore/query` pair.
|
|
7
|
+
* Scoped this cycle to the single confirmed host `openpaymentsdata.cms.gov`, whose
|
|
8
|
+
* flagship datasets are the CMS Open Payments (Physician Payments Sunshine Act,
|
|
9
|
+
* Social Security Act §1128G / 42 CFR Part 403 subpart I) tables: every reported
|
|
10
|
+
* payment / other-transfer-of-value / ownership interest from drug & device
|
|
11
|
+
* manufacturers (and GPOs) to physicians, non-physician practitioners, and
|
|
12
|
+
* teaching hospitals. The B2G unlock: healthcare-COI / industry-financial-
|
|
13
|
+
* relationship vetting + healthcare market intelligence — the money/COI question
|
|
14
|
+
* NPPES (provider identity, src/nppes.ts) cannot answer.
|
|
15
|
+
*
|
|
16
|
+
* TWO tools (mirror the Socrata/CKAN discovery+query split):
|
|
17
|
+
* - cms_search_datasets — DKAN DCAT metastore discovery. `GET /api/1/metastore/
|
|
18
|
+
* schemas/dataset/items` returns the FULL catalog ARRAY in one shot (the server
|
|
19
|
+
* IGNORES limit/offset/page — M2). We fetch it ONCE and do ALL q-substring
|
|
20
|
+
* filtering + limit/offset slicing CLIENT-SIDE against the in-memory array, so
|
|
21
|
+
* totalAvailable is the EXACT post-q catalog size and pagination is honest
|
|
22
|
+
* against the KNOWN length (never a false-more, never a dead-end offset).
|
|
23
|
+
* - cms_query_dataset — DKAN datastore query by datasetId + distribution index,
|
|
24
|
+
* with server-side `conditions` filters, an EXACT `count`, offset/limit ≤ 500
|
|
25
|
+
* pagination, a `properties` projection, and `results:false` = the count/schema
|
|
26
|
+
* column-discovery mode. Rows pass through VERBATIM (values are text strings).
|
|
27
|
+
*
|
|
28
|
+
* HONESTY (writes ZERO fetch/coerce/error/meta code — REUSES getJson/throughGate/
|
|
29
|
+
* driftError + coerce.num/str + withMeta/buildMeta):
|
|
30
|
+
* P1 `count` is the EXACT grand total (num-guarded) → totalAvailable=count, real
|
|
31
|
+
* offset pagination; a PRESENT non-number count in results-mode ⇒ driftError.
|
|
32
|
+
* limit ≤ 500 is the HARD API cap (Zod .max(500); a higher limit ⇒ invalid_input
|
|
33
|
+
* client-side, so the API's own 400 is never reached).
|
|
34
|
+
* P2 empty (`{count:0, results:[]}`) ⇒ honest complete:true; a 400 (bad column /
|
|
35
|
+
* bad limit) / 404 (bad datasetId/index) / HTML (SPA/WAF) / 5xx / timeout /
|
|
36
|
+
* non-JSON ⇒ THROW (never a fake empty).
|
|
37
|
+
* P3 money/amounts (total_amount_of_payment_usdollars, …) are text STRINGS →
|
|
38
|
+
* surfaced verbatim; coerce.num is null-never-0 (a missing/""/"-" amount → null,
|
|
39
|
+
* NEVER 0 — the pricing.ts money-lie precedent).
|
|
40
|
+
* P4 `conditions` are server-side + self-policing: a bad column → the API 400s →
|
|
41
|
+
* invalid_input; every requested condition either applies or the call errors,
|
|
42
|
+
* so filtersDropped is ALWAYS empty (no silent-drop path).
|
|
43
|
+
* P-drift (M1) — the results-array drift guard is CONDITIONED on the effective
|
|
44
|
+
* `results` mode: results:true (default) REQUIRES Array.isArray(body.results) +
|
|
45
|
+
* the schema anchor; results:false EXPECTS results ABSENT (rows:[], no throw)
|
|
46
|
+
* and uses schema presence + a number-typed count as the drift anchor. The
|
|
47
|
+
* schema block (keyed by the DISTRIBUTION id) is the anchor — fields are read
|
|
48
|
+
* from Object.values(schema)[0].fields.
|
|
49
|
+
*
|
|
50
|
+
* SSRF (the CKAN/Socrata fixed-host idiom, COPIED not imported). The load-bearing
|
|
51
|
+
* risk: `datasetId` + `index` interpolate into the URL PATH
|
|
52
|
+
* (`/api/1/datastore/query/{datasetId}/{index}`), so URLSearchParams does NOT
|
|
53
|
+
* protect them. Validate datasetId against the strict 36-char LOWERCASE UUID
|
|
54
|
+
* grammar (the CKAN UUID_RE verbatim — rejects %2F, "..", uppercase, a trailing
|
|
55
|
+
* "\n") and index as a small non-negative int BEFORE interpolation; every OTHER
|
|
56
|
+
* param (conditions[i][*], properties[], limit, offset, count, results) rides the
|
|
57
|
+
* query string via URLSearchParams. Host is a compile-time constant; a
|
|
58
|
+
* post-construction hostname/protocol assert + redirect:"error" fail closed.
|
|
59
|
+
*
|
|
60
|
+
* PII / scope boundary (NPPES precedent): Open Payments NAMES individual physicians
|
|
61
|
+
* + payment amounts AND is a federal transparency-BY-LAW public dataset — IN-SCOPE
|
|
62
|
+
* per the NPPES/NSF-PI precedent, bounded to targeted vetting (per-query limit ≤ 500
|
|
63
|
+
* + an offset reach cap S3, no enrichment, NO covered_recipient_npi→NPPES auto-join,
|
|
64
|
+
* a mandatory not-a-COI-finding / cross-check-SAM+OFAC+LEIE caveat on every response).
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
68
|
+
import { getJson, throughGate, driftError } from "./datasource.js";
|
|
69
|
+
import { num, str } from "./coerce.js";
|
|
70
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
71
|
+
|
|
72
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js —
|
|
73
|
+
// ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
|
|
74
|
+
// `num` from this module (cms.num === coerce.num — a num regression fails together).
|
|
75
|
+
export { num };
|
|
76
|
+
|
|
77
|
+
// ─── Fixed host allowlist (SSRF core — compile-time CONSTANTS) ────
|
|
78
|
+
// Single-entry allowlist, structured for a later host-add exactly like CKAN_HOSTS.
|
|
79
|
+
// A future DKAN gov portal is a one-line edit + a live `?limit=1` verification.
|
|
80
|
+
export const CMS_HOSTS = ["openpaymentsdata.cms.gov"] as const;
|
|
81
|
+
export type CmsHost = (typeof CMS_HOSTS)[number];
|
|
82
|
+
const CMS_HOST: CmsHost = CMS_HOSTS[0];
|
|
83
|
+
const CMS_HOST_SET: ReadonlySet<string> = new Set(CMS_HOSTS);
|
|
84
|
+
|
|
85
|
+
// HOST-only label. Surfaces in ToolError.upstreamEndpoint; keyless → no token.
|
|
86
|
+
const CMS_LABEL = "cms:" + CMS_HOST;
|
|
87
|
+
|
|
88
|
+
// Modest self-throttle (courteous to a single public gov host; matches the
|
|
89
|
+
// NPPES/ECHO/CKAN defensive posture — no documented hard rate limit).
|
|
90
|
+
const CMS_GATE_MIN_INTERVAL_MS = 200;
|
|
91
|
+
|
|
92
|
+
// ─── Grammars + caps (SSRF + honesty guards) ─────────────────────
|
|
93
|
+
// A DKAN datasetId is EXACTLY a 36-char LOWERCASE hex UUID (the CKAN UUID_RE
|
|
94
|
+
// verbatim). m1: JS `$` does NOT admit a trailing "\n" (unlike Python) — the regex
|
|
95
|
+
// alone rejects a newline; `.length(36)` is belt-and-suspenders. m2: lowercase-only
|
|
96
|
+
// (no `i` flag) — every live DKAN id is a 36-char lowercase UUID.
|
|
97
|
+
const UUID_RE =
|
|
98
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
99
|
+
|
|
100
|
+
// A DKAN column name is snake_case lowercase alnum (verified). A bad one 400s
|
|
101
|
+
// upstream anyway, but we validate to keep the wire clean and the PATH unbreakable.
|
|
102
|
+
const COLUMN_RE = /^[a-z0-9_]+$/;
|
|
103
|
+
const MAX_COLUMN_LEN = 128;
|
|
104
|
+
const MAX_VALUE_LEN = 200;
|
|
105
|
+
|
|
106
|
+
// The `conditions[i][operator]` ENUM (DKAN 400s an unknown operator).
|
|
107
|
+
export const CMS_OPERATORS = ["=", "<>", "<", ">", "<=", ">=", "like", "in"] as const;
|
|
108
|
+
export type CmsOperator = (typeof CMS_OPERATORS)[number];
|
|
109
|
+
const CMS_OPERATOR_SET: ReadonlySet<string> = new Set(CMS_OPERATORS);
|
|
110
|
+
|
|
111
|
+
const CMS_MAX_LIMIT = 500; // the HARD DKAN cap (400s over it — no silent clamp).
|
|
112
|
+
const CMS_DEFAULT_LIMIT = 100;
|
|
113
|
+
const CMS_MAX_INDEX = 50; // distributions are single-digit.
|
|
114
|
+
const CMS_MAX_CONDITIONS = 10; // bound URL length.
|
|
115
|
+
// ★ S3 — our OWN server POLICY offset/reach cap (mirrors NPPES_MAX_SKIP). Open
|
|
116
|
+
// Payments names individual physicians + dollar amounts (MORE sensitive than
|
|
117
|
+
// NPPES), so we impose a deliberate targeted-lookup boundary. The EXACT count
|
|
118
|
+
// SIZES a harvest; it does not BOUND it — this cap does.
|
|
119
|
+
const CMS_MAX_OFFSET = 2000;
|
|
120
|
+
|
|
121
|
+
// ─── Disclosure constants (honesty obligations — verbatim, fault-asserted) ──
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* ★ The mandatory not-a-determination caveat carried on EVERY cms_query_dataset /
|
|
125
|
+
* cms_search_datasets response (mirrors NPPES_NOT_DETERMINATION_NOTE /
|
|
126
|
+
* OFAC_NOT_DETERMINATION_NOTE). Kept verbatim so the fault suite can assert it.
|
|
127
|
+
*/
|
|
128
|
+
export const CMS_OPEN_PAYMENTS_NOT_DETERMINATION_NOTE =
|
|
129
|
+
"Public transparency-by-law data (CMS Open Payments, Physician Payments Sunshine Act). Reports reported industry payments / transfers of value / ownership interests ONLY — it is NOT a conflict-of-interest finding, a fitness/exclusion determination, or evidence of wrongdoing (many payments are routine and lawful). Individual records name physicians, teaching hospitals, and dollar amounts verbatim from the public dataset; this tool performs NO enrichment and NO cross-source join (e.g. it does NOT auto-join covered_recipient_npi to NPPES). Cross-check SAM exclusions + OFAC for debarment/sanctions and the OIG-LEIE for healthcare exclusions.";
|
|
130
|
+
|
|
131
|
+
/** ★ S3 — the per-query reach-cap POLICY disclosure carried on EVERY response. */
|
|
132
|
+
export const CMS_OPEN_PAYMENTS_REACH_CAP_NOTE =
|
|
133
|
+
"This vetting tool reaches at most the first ~2,500 rows per query (limit ≤ 500, offset ≤ 2,000) as a deliberate targeted-lookup boundary — Open Payments names individual physicians AND dollar amounts, so the reach is bounded like NPPES. The EXACT count sizes a result set but does not bound a harvest; this is a PER-QUERY cap only (cross-query iteration is inherent to any datastore API). Narrow your `conditions` (recipient_state / specialty / manufacturer) for a complete, targeted result set, or use the metastore distribution downloadURL for a bulk pull.";
|
|
134
|
+
|
|
135
|
+
const VALUE_TYPING_NOTE =
|
|
136
|
+
"Row value fields follow schema.fields[].type; every Open Payments column is text, so a numeric column (e.g. total_amount_of_payment_usdollars) arrives as a STRING verbatim — a missing amount is absent, never 0 (coerce with null-never-0 semantics).";
|
|
137
|
+
|
|
138
|
+
const SOURCE = "openpaymentsdata.cms.gov (CMS Open Payments, DKAN datastore, keyless)";
|
|
139
|
+
|
|
140
|
+
// ─── invalid_input helper ─────────────────────────────────────────
|
|
141
|
+
function invalidInput(message: string): ToolErrorCarrier {
|
|
142
|
+
return new ToolErrorCarrier({
|
|
143
|
+
kind: "invalid_input",
|
|
144
|
+
message,
|
|
145
|
+
retryable: false,
|
|
146
|
+
upstreamEndpoint: CMS_LABEL,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Strip C0 control chars + DEL and trim (URLSearchParams encodes the rest). */
|
|
151
|
+
function sanitizeText(v: string): string {
|
|
152
|
+
let out = "";
|
|
153
|
+
for (const ch of v) {
|
|
154
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
155
|
+
if (code < 0x20 || code === 0x7f) continue;
|
|
156
|
+
out += ch;
|
|
157
|
+
}
|
|
158
|
+
return out.trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── SSRF-guarded fetch layer ─────────────────────────────────────
|
|
162
|
+
/**
|
|
163
|
+
* GET a DKAN datastore query. SSRF: host ∈ allowlist (belt-and-suspenders behind
|
|
164
|
+
* the server's Zod), datasetId is a 36-char lowercase UUID + index is a small
|
|
165
|
+
* non-negative int (BOTH re-checked here BEFORE they interpolate into the PATH),
|
|
166
|
+
* and the CONSTRUCTED URL's hostname === host (https). redirect:"error" (off-host
|
|
167
|
+
* 3xx fails closed). Keyless — NO headers. Returns parsed JSON (caller validates).
|
|
168
|
+
*/
|
|
169
|
+
async function getDatastore(
|
|
170
|
+
datasetId: string,
|
|
171
|
+
index: number,
|
|
172
|
+
params: URLSearchParams,
|
|
173
|
+
): Promise<unknown> {
|
|
174
|
+
if (!CMS_HOST_SET.has(CMS_HOST)) {
|
|
175
|
+
throw invalidInput(
|
|
176
|
+
`CMS host ${JSON.stringify(CMS_HOST)} is not on the curated allowlist.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
// ★ PATH-interpolation guards (load-bearing — datasetId + index ride the PATH).
|
|
180
|
+
if (datasetId.length !== 36 || !UUID_RE.test(datasetId)) {
|
|
181
|
+
throw invalidInput(
|
|
182
|
+
`Invalid CMS datasetId ${JSON.stringify(datasetId)} — expected a 36-char lowercase UUID ([0-9a-f]{8}-{4}-{4}-{4}-{12}); it interpolates into the URL path, so a %2F/../uppercase/newline id is refused (SSRF safety).`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!Number.isInteger(index) || index < 0 || index > CMS_MAX_INDEX) {
|
|
186
|
+
throw invalidInput(
|
|
187
|
+
`Invalid CMS index ${JSON.stringify(index)} — expected a non-negative integer 0..${CMS_MAX_INDEX} (the distribution index; it interpolates into the URL path).`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const url = `https://${CMS_HOST}/api/1/datastore/query/${datasetId}/${index}?${params.toString()}`;
|
|
191
|
+
const built = new URL(url);
|
|
192
|
+
if (built.hostname !== CMS_HOST || built.protocol !== "https:") {
|
|
193
|
+
throw invalidInput(
|
|
194
|
+
`Constructed CMS URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match ${CMS_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
// ★ W3-2 — the SyntaxError→schema_drift catch-ladder (fema.ts:262-275 shape).
|
|
198
|
+
// getJson's r.json() runs OUTSIDE fetchWithRetry, so a 200 non-JSON body (a DKAN
|
|
199
|
+
// SPA/WAF/maintenance HTML masquerade) throws a raw SyntaxError; toToolError has
|
|
200
|
+
// NO schema_drift branch → it would degrade to kind:"unknown". Preserve the
|
|
201
|
+
// fetchWithRetry taxonomy (429/404/5xx/400/timeout ToolErrorCarrier) FIRST (a
|
|
202
|
+
// broader catch would reclassify a 429 to schema_drift), reclassify the SyntaxError
|
|
203
|
+
// SECOND, bare-rethrow LAST.
|
|
204
|
+
try {
|
|
205
|
+
return await throughGate(CMS_HOST, CMS_GATE_MIN_INTERVAL_MS, () =>
|
|
206
|
+
getJson(url, { label: CMS_LABEL, redirect: "error" }),
|
|
207
|
+
);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
210
|
+
if (e instanceof SyntaxError)
|
|
211
|
+
throw driftError(
|
|
212
|
+
CMS_LABEL,
|
|
213
|
+
"CMS DKAN datastore returned a non-JSON body at HTTP 200 — schema drift.",
|
|
214
|
+
);
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* GET the DKAN DCAT metastore catalog (fixed path; no id interpolation).
|
|
221
|
+
*
|
|
222
|
+
* ★ D1 — `?show-reference-ids` is REQUIRED (not optional). The FLAT default endpoint
|
|
223
|
+
* (`…/items` with no params) returns each distribution as a top-level bag of keys
|
|
224
|
+
* (downloadURL/format/mediaType/title) with NO distribution `identifier` and NO
|
|
225
|
+
* nested `data` object — so mapDataset's `{identifier, data:{downloadURL,…}}` reader
|
|
226
|
+
* would resolve distId + downloadURL + mediaType + title to NULL for every row
|
|
227
|
+
* (dropping the real advertised bulk-download URL the reach-cap note points callers
|
|
228
|
+
* to). ONLY `?show-reference-ids` carries BOTH the distribution `identifier` AND the
|
|
229
|
+
* nested `data:{downloadURL,mediaType,title}` block. It is an ORTHOGONAL
|
|
230
|
+
* reference-expansion flag — the endpoint STILL ignores limit/offset/page and ships
|
|
231
|
+
* the full catalog in one shot, so the M2 client-side-slice behavior is unchanged.
|
|
232
|
+
*/
|
|
233
|
+
async function getMetastore(): Promise<unknown> {
|
|
234
|
+
const url = `https://${CMS_HOST}/api/1/metastore/schemas/dataset/items?show-reference-ids`;
|
|
235
|
+
const built = new URL(url);
|
|
236
|
+
if (built.hostname !== CMS_HOST || built.protocol !== "https:") {
|
|
237
|
+
throw invalidInput(
|
|
238
|
+
`Constructed CMS metastore URL host ${JSON.stringify(built.hostname)} does not match ${CMS_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
// ★ W3-2 — the IDENTICAL SyntaxError→schema_drift catch-ladder as getDatastore
|
|
242
|
+
// (fema.ts:262-275 shape). A 200 non-JSON metastore body (HTML/WAF) throws a raw
|
|
243
|
+
// SyntaxError from r.json() → without this it degrades to kind:"unknown". Preserve
|
|
244
|
+
// the ToolErrorCarrier taxonomy FIRST, reclassify SECOND, bare-rethrow LAST.
|
|
245
|
+
try {
|
|
246
|
+
return await throughGate(CMS_HOST, CMS_GATE_MIN_INTERVAL_MS, () =>
|
|
247
|
+
getJson(url, { label: CMS_LABEL, redirect: "error" }),
|
|
248
|
+
);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
251
|
+
if (e instanceof SyntaxError)
|
|
252
|
+
throw driftError(
|
|
253
|
+
CMS_LABEL,
|
|
254
|
+
"CMS DKAN metastore returned a non-JSON body at HTTP 200 — schema drift.",
|
|
255
|
+
);
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Datastore schema/field mapping ───────────────────────────────
|
|
261
|
+
export type CmsField = {
|
|
262
|
+
name: string;
|
|
263
|
+
type: string | null;
|
|
264
|
+
mysqlType: string | null;
|
|
265
|
+
description: string | null;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function rec(x: unknown): Record<string, unknown> {
|
|
269
|
+
return x !== null && typeof x === "object" && !Array.isArray(x)
|
|
270
|
+
? (x as Record<string, unknown>)
|
|
271
|
+
: {};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Read the fields block from the DKAN schema (keyed by the DISTRIBUTION id, distinct
|
|
276
|
+
* from the datasetId — §fact 8). `Object.values(schema)[0].fields` is the drift
|
|
277
|
+
* anchor. Returns the mapped fields, or `null` when the anchor is missing/malformed
|
|
278
|
+
* (the caller turns a null anchor into driftError — never a fake empty).
|
|
279
|
+
*/
|
|
280
|
+
function readFields(schema: unknown): CmsField[] | null {
|
|
281
|
+
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const distributions = Object.values(schema as Record<string, unknown>);
|
|
285
|
+
if (distributions.length === 0) return null;
|
|
286
|
+
const first = rec(distributions[0]);
|
|
287
|
+
const fields = first.fields;
|
|
288
|
+
if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const out: CmsField[] = [];
|
|
292
|
+
for (const [name, def] of Object.entries(fields as Record<string, unknown>)) {
|
|
293
|
+
const d = rec(def);
|
|
294
|
+
out.push({
|
|
295
|
+
name,
|
|
296
|
+
type: str(d.type),
|
|
297
|
+
mysqlType: str(d.mysql_type),
|
|
298
|
+
description: str(d.description),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Tool 1: cms_query_dataset ────────────────────────────────────
|
|
305
|
+
export type CmsCondition = {
|
|
306
|
+
property: string;
|
|
307
|
+
value: string | number;
|
|
308
|
+
operator?: string;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export type CmsQueryArgs = {
|
|
312
|
+
datasetId: string;
|
|
313
|
+
index?: number;
|
|
314
|
+
conditions?: CmsCondition[];
|
|
315
|
+
properties?: string[];
|
|
316
|
+
limit?: number;
|
|
317
|
+
offset?: number;
|
|
318
|
+
results?: boolean;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Query a DKAN datastore distribution by datasetId + index. Server-side `conditions`
|
|
323
|
+
* filters (self-policing — a bad column 400s → invalid_input, so filtersDropped is
|
|
324
|
+
* always empty), an EXACT `count` (real offset pagination), a `properties`
|
|
325
|
+
* projection, and `results:false` = the count/schema column-discovery mode (M1/S1:
|
|
326
|
+
* rows omitted, pagination disabled — no livelock). count=true is ALWAYS on the wire
|
|
327
|
+
* (S2 — never a caller toggle). Rows pass through VERBATIM (strings). A 400 (bad
|
|
328
|
+
* column/limit) / 404 (bad datasetId/index) / HTML / 5xx / timeout ⇒ THROW.
|
|
329
|
+
*/
|
|
330
|
+
export async function queryDataset(args: CmsQueryArgs): Promise<MetaBundle> {
|
|
331
|
+
// NO .trim() — the CKAN/Socrata precedent: a trailing "\n" must reach the
|
|
332
|
+
// length(36)+UUID_RE guard (trimming would strip it and admit a newline-suffixed id).
|
|
333
|
+
const datasetId = args.datasetId === undefined || args.datasetId === null ? "" : String(args.datasetId);
|
|
334
|
+
const index = args.index ?? 0;
|
|
335
|
+
const limit = args.limit ?? CMS_DEFAULT_LIMIT;
|
|
336
|
+
const offset = args.offset ?? 0;
|
|
337
|
+
const results = args.results ?? true;
|
|
338
|
+
|
|
339
|
+
// ── Client-side caps (belt-and-suspenders behind Zod; SSRF: no fetch on reject). ──
|
|
340
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > CMS_MAX_LIMIT) {
|
|
341
|
+
throw invalidInput(
|
|
342
|
+
`limit ${JSON.stringify(args.limit)} out of range — DKAN caps a datastore page at ${CMS_MAX_LIMIT} (the API 400s over it; this tool rejects it loudly). Use 1..${CMS_MAX_LIMIT}.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > CMS_MAX_OFFSET) {
|
|
346
|
+
throw invalidInput(
|
|
347
|
+
`offset ${JSON.stringify(args.offset)} out of range — this vetting tool caps reach at offset ≤ ${CMS_MAX_OFFSET} (a deliberate targeted-lookup POLICY boundary; Open Payments names physicians + amounts). Narrow your conditions rather than paging deeper.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Build + validate the wire query (SSRF: key-by-key from typed args). ──
|
|
352
|
+
const params = new URLSearchParams();
|
|
353
|
+
// S2 — ALWAYS emit count=true (never a caller toggle; mirror the ckan.ts
|
|
354
|
+
// always-exact-total doctrine).
|
|
355
|
+
params.set("count", "true");
|
|
356
|
+
params.set("results", results ? "true" : "false");
|
|
357
|
+
params.set("limit", String(limit));
|
|
358
|
+
params.set("offset", String(offset));
|
|
359
|
+
|
|
360
|
+
const filtersApplied: string[] = [];
|
|
361
|
+
|
|
362
|
+
// conditions (server-side, self-policing — a bad column 400s, never a silent drop).
|
|
363
|
+
const conditions = args.conditions ?? [];
|
|
364
|
+
if (conditions.length > CMS_MAX_CONDITIONS) {
|
|
365
|
+
throw invalidInput(
|
|
366
|
+
`Too many conditions (${conditions.length}) — cap is ${CMS_MAX_CONDITIONS} to bound URL length.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
conditions.forEach((c, i) => {
|
|
370
|
+
const property = sanitizeText(String(c.property ?? ""));
|
|
371
|
+
if (property.length === 0 || property.length > MAX_COLUMN_LEN || !COLUMN_RE.test(property)) {
|
|
372
|
+
throw invalidInput(
|
|
373
|
+
`Invalid condition[${i}].property ${JSON.stringify(c.property)} — a DKAN column is snake_case lowercase alnum (^[a-z0-9_]+$).`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const operator = c.operator === undefined ? "=" : String(c.operator);
|
|
377
|
+
if (!CMS_OPERATOR_SET.has(operator)) {
|
|
378
|
+
throw invalidInput(
|
|
379
|
+
`Invalid condition[${i}].operator ${JSON.stringify(c.operator)} — expected one of ${CMS_OPERATORS.join(" ")}.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const rawValue = typeof c.value === "number" ? String(c.value) : sanitizeText(String(c.value ?? ""));
|
|
383
|
+
if (rawValue.length > MAX_VALUE_LEN) {
|
|
384
|
+
throw invalidInput(`condition[${i}].value is too long (max ${MAX_VALUE_LEN} chars).`);
|
|
385
|
+
}
|
|
386
|
+
params.set(`conditions[${i}][property]`, property);
|
|
387
|
+
params.set(`conditions[${i}][value]`, rawValue);
|
|
388
|
+
params.set(`conditions[${i}][operator]`, operator);
|
|
389
|
+
filtersApplied.push(`${property} ${operator} ${rawValue}`);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// properties projection (column subset — same snake_case grammar).
|
|
393
|
+
const properties = args.properties ?? [];
|
|
394
|
+
properties.forEach((p, i) => {
|
|
395
|
+
const col = sanitizeText(String(p ?? ""));
|
|
396
|
+
if (col.length === 0 || col.length > MAX_COLUMN_LEN || !COLUMN_RE.test(col)) {
|
|
397
|
+
throw invalidInput(
|
|
398
|
+
`Invalid properties[${i}] ${JSON.stringify(p)} — a DKAN column is snake_case lowercase alnum (^[a-z0-9_]+$).`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
params.append("properties[]", col);
|
|
402
|
+
});
|
|
403
|
+
if (properties.length > 0) filtersApplied.push("properties");
|
|
404
|
+
|
|
405
|
+
const body = await getDatastore(datasetId, index, params);
|
|
406
|
+
const b = rec(body);
|
|
407
|
+
|
|
408
|
+
// ── Schema anchor (the drift anchor, keyed by the distribution id). ──
|
|
409
|
+
const fields = readFields(b.schema);
|
|
410
|
+
if (fields === null) {
|
|
411
|
+
throw driftError(
|
|
412
|
+
CMS_LABEL,
|
|
413
|
+
`${CMS_LABEL} datastore query returned a body without a usable schema anchor (Object.values(schema)[0].fields missing) — treating as schema drift, never a fake empty.`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const notes: string[] = [VALUE_TYPING_NOTE];
|
|
418
|
+
let rows: Record<string, unknown>[];
|
|
419
|
+
let totalAvailable: number | null;
|
|
420
|
+
let hasMore: boolean;
|
|
421
|
+
let returned: number;
|
|
422
|
+
|
|
423
|
+
// ── count (P1/S2): a PRESENT non-number count in either mode ⇒ driftError; an
|
|
424
|
+
// ABSENT count in results:true ⇒ hedge (never fabricate a total). ──
|
|
425
|
+
const rawCount = b.count;
|
|
426
|
+
const countPresent = rawCount !== undefined;
|
|
427
|
+
const countIsNumber = typeof rawCount === "number" && Number.isFinite(rawCount);
|
|
428
|
+
if (countPresent && !countIsNumber) {
|
|
429
|
+
throw driftError(
|
|
430
|
+
CMS_LABEL,
|
|
431
|
+
`${CMS_LABEL} datastore query returned a non-number \`count\` — treating as schema drift (typeof-checked BEFORE num()).`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (results) {
|
|
436
|
+
// ★ M1 — results:true (default): REQUIRE Array.isArray(body.results) else drift.
|
|
437
|
+
if (!Array.isArray(b.results)) {
|
|
438
|
+
throw driftError(
|
|
439
|
+
CMS_LABEL,
|
|
440
|
+
`${CMS_LABEL} datastore query (results:true) returned a body whose \`results\` is missing or not an array — treating as schema drift, never a fake empty.`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
rows = (b.results as unknown[]).map(rec);
|
|
444
|
+
returned = rows.length;
|
|
445
|
+
if (countIsNumber) {
|
|
446
|
+
totalAvailable = num(rawCount);
|
|
447
|
+
hasMore = totalAvailable !== null ? offset + returned < totalAvailable : returned >= limit;
|
|
448
|
+
} else {
|
|
449
|
+
// S2 belt-and-suspenders — count absent from a results-mode body: hedge with
|
|
450
|
+
// page-fullness, never fabricate a total.
|
|
451
|
+
totalAvailable = null;
|
|
452
|
+
hasMore = returned >= limit;
|
|
453
|
+
notes.push(
|
|
454
|
+
"The upstream did not report `count` on this results:true response; totalAvailable is withheld (null) and completeness is inferred from page-fullness (a short page means the result set is exhausted) — never a fabricated total.",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
// ★ M1/S1 — results:false: EXPECT `results` ABSENT (rows omitted, NOT results:[]).
|
|
459
|
+
// Drift anchor = schema presence (already checked) + a number-typed count. Do
|
|
460
|
+
// NOT throw on an absent `results`. Pagination is DISABLED (no livelock — S1).
|
|
461
|
+
if (!countIsNumber) {
|
|
462
|
+
throw driftError(
|
|
463
|
+
CMS_LABEL,
|
|
464
|
+
`${CMS_LABEL} datastore query (results:false) returned a body without a number-typed \`count\` — the count/schema-discovery anchor is absent; treating as schema drift.`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
rows = [];
|
|
468
|
+
returned = 0;
|
|
469
|
+
totalAvailable = num(rawCount);
|
|
470
|
+
hasMore = false; // S1 — results:false is a count/schema mode; no rows to page.
|
|
471
|
+
notes.push(
|
|
472
|
+
"results:false is a COUNT/SCHEMA-discovery mode: no rows are returned and pagination is disabled (hasMore:false). To page rows, set results:true with limit/offset. totalAvailable is the EXACT match count and `fields` describes every column.",
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// W3-6 (dogfood): the raw next offset (`offset + returned`) can exceed
|
|
477
|
+
// CMS_MAX_OFFSET (the deliberate reach cap validated at the top of this fn). If
|
|
478
|
+
// it does, a follow-up call with that offset would be HARD-REJECTED as
|
|
479
|
+
// invalid_input — so advertising it as `nextOffset` is a false promise. Mirror
|
|
480
|
+
// the edgar-FTS / regulations-dockets ceiling doctrine: keep `hasMore` honest
|
|
481
|
+
// (more matches DO exist upstream) but set `nextOffset` to null (not
|
|
482
|
+
// page-reachable via this tool) + a disclosing note. A genuine non-ceiling
|
|
483
|
+
// next page stays reachable.
|
|
484
|
+
const rawNextOffset = offset + returned;
|
|
485
|
+
const nextReachable = rawNextOffset <= CMS_MAX_OFFSET;
|
|
486
|
+
const nextOffset = hasMore && nextReachable ? rawNextOffset : null;
|
|
487
|
+
if (hasMore && !nextReachable) {
|
|
488
|
+
notes.push(
|
|
489
|
+
`More matches exist upstream, but the next page (offset ${rawNextOffset}) exceeds this vetting tool's offset ≤ ${CMS_MAX_OFFSET} reach cap — nextOffset is null (not page-reachable here; a follow-up at that offset would be rejected as invalid_input). Narrow your conditions to reach deeper matches.`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
notes.push(CMS_OPEN_PAYMENTS_NOT_DETERMINATION_NOTE, CMS_OPEN_PAYMENTS_REACH_CAP_NOTE);
|
|
494
|
+
|
|
495
|
+
const meta: Partial<ResponseMeta> = {
|
|
496
|
+
source: SOURCE,
|
|
497
|
+
keylessMode: true,
|
|
498
|
+
returned,
|
|
499
|
+
totalAvailable,
|
|
500
|
+
filtersApplied,
|
|
501
|
+
// P4 — a bad column 400s upstream (→ invalid_input); a filter is never silently
|
|
502
|
+
// dropped, so filtersDropped is provably always empty for this source.
|
|
503
|
+
filtersDropped: [],
|
|
504
|
+
fieldsUnavailable: [],
|
|
505
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
506
|
+
notes,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
return withMeta(
|
|
510
|
+
{ datasetId, index, results, fields, rows },
|
|
511
|
+
meta,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── Tool 2: cms_search_datasets ──────────────────────────────────
|
|
516
|
+
export type CmsDistribution = {
|
|
517
|
+
index: number;
|
|
518
|
+
distId: string | null;
|
|
519
|
+
title: string | null;
|
|
520
|
+
mediaType: string | null;
|
|
521
|
+
downloadURL: string | null;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
export type CmsDataset = {
|
|
525
|
+
datasetId: string | null;
|
|
526
|
+
title: string | null;
|
|
527
|
+
description: string | null;
|
|
528
|
+
distributions: CmsDistribution[];
|
|
529
|
+
keyword: string[];
|
|
530
|
+
modified: string | null;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
/** Map ONE DCAT metastore item → a curated discovery row. */
|
|
534
|
+
function mapDataset(raw: unknown): CmsDataset {
|
|
535
|
+
const r = rec(raw);
|
|
536
|
+
const rawDist = Array.isArray(r.distribution) ? r.distribution : [];
|
|
537
|
+
const distributions: CmsDistribution[] = rawDist.map((d, i) => {
|
|
538
|
+
const dd = rec(d);
|
|
539
|
+
const data = rec(dd.data);
|
|
540
|
+
return {
|
|
541
|
+
index: i,
|
|
542
|
+
distId: str(dd.identifier),
|
|
543
|
+
title: str(data.title),
|
|
544
|
+
mediaType: str(data.mediaType),
|
|
545
|
+
downloadURL: str(data.downloadURL),
|
|
546
|
+
};
|
|
547
|
+
});
|
|
548
|
+
const keyword = Array.isArray(r.keyword)
|
|
549
|
+
? (r.keyword.map(str).filter((k): k is string => k !== null))
|
|
550
|
+
: [];
|
|
551
|
+
return {
|
|
552
|
+
datasetId: str(r.identifier),
|
|
553
|
+
title: str(r.title),
|
|
554
|
+
description: str(r.description),
|
|
555
|
+
distributions,
|
|
556
|
+
keyword,
|
|
557
|
+
modified: str(r.modified),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Case-insensitive substring match over title + description. */
|
|
562
|
+
function matchesQ(d: CmsDataset, q: string): boolean {
|
|
563
|
+
const needle = q.toLowerCase();
|
|
564
|
+
return (
|
|
565
|
+
(d.title !== null && d.title.toLowerCase().includes(needle)) ||
|
|
566
|
+
(d.description !== null && d.description.toLowerCase().includes(needle))
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export type CmsSearchArgs = {
|
|
571
|
+
q?: string;
|
|
572
|
+
limit?: number;
|
|
573
|
+
offset?: number;
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Discover DKAN datasets via the DCAT metastore. ★ M2 — the metastore IGNORES
|
|
578
|
+
* limit/offset/page and always ships the ENTIRE catalog array in one response, so we
|
|
579
|
+
* fetch it ONCE and apply ALL q-substring filtering + limit/offset slicing
|
|
580
|
+
* CLIENT-SIDE against the in-memory array: totalAvailable = the EXACT post-q catalog
|
|
581
|
+
* size (never null), hasMore = offset + returned < filteredLength, nextOffset
|
|
582
|
+
* against the KNOWN length (never a server offset — no false-more, no dead-end).
|
|
583
|
+
*/
|
|
584
|
+
export async function searchDatasets(args: CmsSearchArgs): Promise<MetaBundle> {
|
|
585
|
+
const q = args.q !== undefined && args.q !== null ? sanitizeText(String(args.q)) : "";
|
|
586
|
+
const limit = args.limit ?? 20;
|
|
587
|
+
const offset = args.offset ?? 0;
|
|
588
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
589
|
+
throw invalidInput(`limit ${JSON.stringify(args.limit)} out of range — use 1..100.`);
|
|
590
|
+
}
|
|
591
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
592
|
+
throw invalidInput(`offset ${JSON.stringify(args.offset)} out of range — use a non-negative integer.`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const body = await getMetastore();
|
|
596
|
+
// ★ M2 — the metastore is a BARE ARRAY (no envelope, no total). A non-array body
|
|
597
|
+
// is drift, never a fake empty.
|
|
598
|
+
if (!Array.isArray(body)) {
|
|
599
|
+
throw driftError(
|
|
600
|
+
CMS_LABEL,
|
|
601
|
+
`${CMS_LABEL} metastore returned an unexpected shape (GET /api/1/metastore/schemas/dataset/items must be a JSON array of DCAT datasets).`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const all = (body as unknown[]).map(mapDataset);
|
|
606
|
+
const filtered = q === "" ? all : all.filter((d) => matchesQ(d, q));
|
|
607
|
+
const filteredLength = filtered.length; // EXACT post-q catalog size (M2).
|
|
608
|
+
const page = filtered.slice(offset, offset + limit);
|
|
609
|
+
const returned = page.length;
|
|
610
|
+
const hasMore = offset + returned < filteredLength;
|
|
611
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
612
|
+
|
|
613
|
+
const filtersApplied: string[] = q === "" ? [] : ["q"];
|
|
614
|
+
const notes: string[] = [
|
|
615
|
+
"The DKAN metastore returns the entire dataset catalog in one response; q/limit/offset are applied CLIENT-SIDE and totalAvailable is the exact catalog size (post-q).",
|
|
616
|
+
"Feed a result's datasetId + a distribution index to cms_query_dataset (use results:false there for the column schema before pulling rows).",
|
|
617
|
+
CMS_OPEN_PAYMENTS_NOT_DETERMINATION_NOTE,
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
return withMeta(
|
|
621
|
+
{ query: q === "" ? null : q, results: page },
|
|
622
|
+
{
|
|
623
|
+
source: `${CMS_HOST} DKAN metastore (CMS Open Payments, keyless)`,
|
|
624
|
+
keylessMode: true,
|
|
625
|
+
returned,
|
|
626
|
+
totalAvailable: filteredLength,
|
|
627
|
+
filtersApplied,
|
|
628
|
+
filtersDropped: [],
|
|
629
|
+
fieldsUnavailable: [],
|
|
630
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
631
|
+
notes,
|
|
632
|
+
} satisfies Partial<ResponseMeta>,
|
|
633
|
+
);
|
|
634
|
+
}
|
package/src/coerce.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coerce.ts — the honesty-critical value coercions shared across the keyless
|
|
3
|
+
* DataSources (ADR-0005 v2 FIX-C: map-layer primitives, split out of the
|
|
4
|
+
* fetch-envelope port so each module stays single-concern).
|
|
5
|
+
*
|
|
6
|
+
* `num`/`str` return **null (NEVER 0 / never a fabricated string)** for absent
|
|
7
|
+
* values — a missing amount is an honest "unknown", never a data-absence-as-zero
|
|
8
|
+
* masquerade (the project's forbidden failure class). This is the single most
|
|
9
|
+
* duplicated honesty primitive across the sources; hoisting ONE audited copy
|
|
10
|
+
* removes the 3-way drift risk (a `num` regression now fails Treasury AND Socrata
|
|
11
|
+
* suites at once instead of silently in one).
|
|
12
|
+
*
|
|
13
|
+
* Byte-identical to Treasury's + Socrata's prior LOCAL copies:
|
|
14
|
+
* - `num` is identical in both (and, per ADR-0005 v2, output-equivalent to
|
|
15
|
+
* EDGAR's — "NULL" etc. reach null via Number(...)=NaN either way).
|
|
16
|
+
* - `str` adopts the Treasury/Socrata convention (null for ""/"null"). It is
|
|
17
|
+
* output-identical to BOTH over their real input domains. EDGAR's `str` does
|
|
18
|
+
* NOT null the literal "null" (ADR-0005 v2 FIX-A), so EDGAR keeps its own
|
|
19
|
+
* local `str` and is NOT migrated here (EDGAR is deferred anyway per FIX-B).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Coerce an inconsistently-typed value field to `number | null`.
|
|
24
|
+
*
|
|
25
|
+
* Returns **null (NEVER 0)** for absent values: `null`/`undefined`, the literal
|
|
26
|
+
* string `"null"`, `""`/whitespace (CRITICAL — `Number("")` is 0, so this MUST
|
|
27
|
+
* be caught explicitly), and the `"(-)"`/`"-"` placeholders. Numeric strings
|
|
28
|
+
* parse; numbers pass through (a non-finite number → null).
|
|
29
|
+
*/
|
|
30
|
+
export function num(x: unknown): number | null {
|
|
31
|
+
if (x === null || x === undefined) return null;
|
|
32
|
+
if (typeof x === "number") return Number.isFinite(x) ? x : null;
|
|
33
|
+
if (typeof x === "string") {
|
|
34
|
+
const s = x.trim();
|
|
35
|
+
if (s === "" || s === "null" || s === "(-)" || s === "-") return null;
|
|
36
|
+
const n = Number(s);
|
|
37
|
+
return Number.isFinite(n) ? n : null;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** null for absent (null/undefined/""/whitespace/"null"), else the trimmed string. */
|
|
43
|
+
export function str(x: unknown): string | null {
|
|
44
|
+
if (x === null || x === undefined) return null;
|
|
45
|
+
const s = String(x).trim();
|
|
46
|
+
return s === "" || s === "null" ? null : s;
|
|
47
|
+
}
|