@cliwant/mcp-sam-gov 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +279 -67
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +42 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +64 -0
- package/dist/cache.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +92 -17
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +247 -0
- package/dist/errors.js.map +1 -0
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +432 -15
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +114 -11
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3897 -295
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1664 -228
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +73 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +231 -127
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +303 -0
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +706 -191
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +290 -155
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4559 -327
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +2680 -925
- package/src/usitc.ts +420 -0
package/dist/nppes.js
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NPPES NPI Registry — CMS/HHS healthcare-provider identity/credentialing lane
|
|
3
|
+
* (keyless). Source #27 on the R2 `getJson` port (ADR-0036).
|
|
4
|
+
*
|
|
5
|
+
* ONE tool `nppes_lookup_provider` over the CMS/HHS National Plan & Provider
|
|
6
|
+
* Enumeration System (`https://npiregistry.cms.hhs.gov/api/?version=2.1`) — the
|
|
7
|
+
* authoritative PUBLIC registry of every US healthcare provider (individual
|
|
8
|
+
* NPI-1 + organization NPI-2). The B2G unlock: vet a healthcare
|
|
9
|
+
* subcontractor/provider/org for a VA/HHS/CMS contract — validate an NPI,
|
|
10
|
+
* confirm taxonomy (specialty), enumeration status (Active), practice state, and
|
|
11
|
+
* org/name match for credentialing / teaming due-diligence.
|
|
12
|
+
*
|
|
13
|
+
* TWO modes, inferred from `number` (NO mode flag):
|
|
14
|
+
* - EXACT-NPI (`number` supplied): validate `^\d{10}$` + the CMS Luhn
|
|
15
|
+
* (Luhn over `80840` + the first 9 digits, 14 total) CLIENT-SIDE →
|
|
16
|
+
* `invalid_input` on failure (a typo must NEVER fake a not-found; NPPES
|
|
17
|
+
* validates ONLY length, so the Luhn pre-check is LOAD-BEARING). ★ M1: the
|
|
18
|
+
* outgoing query carries `number` (+`version`) as the SOLE param — a
|
|
19
|
+
* co-supplied filter is NEVER forwarded (NPPES AND-combines a number with
|
|
20
|
+
* filters, so `number=<active NPI>&last_name=Zztypo` → result_count:0, a
|
|
21
|
+
* FALSE "does not exist"). Co-filters are dropped from the wire and surfaced
|
|
22
|
+
* as a CLIENT-SIDE post-match annotation (`data.filterMatch`).
|
|
23
|
+
* - SEARCH: by first_name / last_name / organization_name /
|
|
24
|
+
* taxonomy_description / city / postal_code (REQUIRED-one set), refined by
|
|
25
|
+
* state / enumeration_type (REFINERS — never sufficient alone, S2),
|
|
26
|
+
* paginated (limit ≤ 200, skip ≤ 1000 — our POLICY reach cap, S3).
|
|
27
|
+
*
|
|
28
|
+
* HONESTY (writes ZERO fetch/coerce/error/meta code — REUSES getJson/throughGate/
|
|
29
|
+
* driftError + coerce.num/str + withMeta/buildMeta):
|
|
30
|
+
* P1 result_count === results.length (else driftError); NPPES exposes NO
|
|
31
|
+
* grand-total field → totalAvailable is a LOWER BOUND on a full page
|
|
32
|
+
* (totalIsLowerBound) + the ≤200/≤1000 policy caps disclosed.
|
|
33
|
+
* P2 `^\d{10}$` + CMS-Luhn → invalid_input (typo never fakes not-found); a
|
|
34
|
+
* genuine {result_count:0} → honest found:false; a {Errors:[…]} 200 body
|
|
35
|
+
* (NO results key — the NSF serviceNotification twin) → THROW; any
|
|
36
|
+
* 4xx/5xx/timeout/off-host-redirect/non-JSON → THROW.
|
|
37
|
+
* P3 active = basic.status === "A" (deactivated/absent ⇒ NOT active); epochs
|
|
38
|
+
* (created_epoch/last_updated_epoch, ms numeric STRINGS) via coerce.num
|
|
39
|
+
* (null-never-0).
|
|
40
|
+
* P4 no silent filter drop (per-mode filtersApplied/filtersDropped; the M1
|
|
41
|
+
* exact-mode drop disclosed via filterMatch + a note).
|
|
42
|
+
*
|
|
43
|
+
* SSRF (the NSF fixed-host idiom, COPIED not imported): host + path + version are
|
|
44
|
+
* compile-time CONSTANTS; every caller input rides in a MODULE-BUILT
|
|
45
|
+
* URLSearchParams assembled key-by-key from validated typed args (NO raw-query
|
|
46
|
+
* passthrough) + a post-construction hostname/protocol assert + redirect:"error".
|
|
47
|
+
*
|
|
48
|
+
* PII boundary (S3): NPPES public professional-registration data is IN-SCOPE per
|
|
49
|
+
* the shipped NSF-PI-name precedent (src/nsf.ts surfaces PI names/emails),
|
|
50
|
+
* bounded to a per-query targeted lookup (the ≤1,200 reach is a courtesy cap;
|
|
51
|
+
* cross-query enumeration is NOT architecturally prevented, matching NSF). The
|
|
52
|
+
* mandatory not-a-fitness/cross-check-SAM+OFAC caveat rides EVERY response.
|
|
53
|
+
*/
|
|
54
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
55
|
+
import { getJson, throughGate, driftError } from "./datasource.js";
|
|
56
|
+
import { num, str } from "./coerce.js";
|
|
57
|
+
import { withMeta } from "./meta.js";
|
|
58
|
+
// Re-export the shared honesty coercion (single audited copy in ./coerce.js —
|
|
59
|
+
// ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
|
|
60
|
+
// `num` (nppes.num === coerce.num — a num regression fails together).
|
|
61
|
+
export { num };
|
|
62
|
+
// ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
|
|
63
|
+
const NPPES_HOST = "npiregistry.cms.hhs.gov";
|
|
64
|
+
const NPPES_PATH = "/api/";
|
|
65
|
+
const NPPES_URL = `https://${NPPES_HOST}${NPPES_PATH}`;
|
|
66
|
+
// `version=2.1` is REQUIRED by NPPES (omitting it → {Errors:"Unsupported Version"}).
|
|
67
|
+
const NPPES_VERSION = "2.1";
|
|
68
|
+
// HOST-only label. Surfaces in ToolError.upstreamEndpoint; keyless → no token.
|
|
69
|
+
const NPPES_LABEL = "nppes:/api";
|
|
70
|
+
// Modest self-throttle (courteous to a single public gov host; matches the
|
|
71
|
+
// ECHO/CKAN defensive posture — no documented hard rate limit was hit).
|
|
72
|
+
const NPPES_GATE_MIN_INTERVAL_MS = 200;
|
|
73
|
+
// ─── Pagination caps (the honesty frontier) ──────────────────────
|
|
74
|
+
const NPPES_MAX_LIMIT = 200; // the API silently CLAMPS >200 → we reject loudly.
|
|
75
|
+
const NPPES_DEFAULT_LIMIT = 10; // NPPES default page size.
|
|
76
|
+
// ★ Our OWN server POLICY cap (S3): NPPES no longer enforces a skip ceiling
|
|
77
|
+
// (skip=100000 works live) → we impose ≤1000 as the anti-bulk-harvest boundary.
|
|
78
|
+
const NPPES_MAX_SKIP = 1000;
|
|
79
|
+
// ─── Client-side value grammars (SSRF + honesty guards) ──────────
|
|
80
|
+
const NPI_RE = /^\d{10}$/;
|
|
81
|
+
const ENUM_TYPES = new Set(["NPI-1", "NPI-2"]);
|
|
82
|
+
const MAX_TEXT_LEN = 100;
|
|
83
|
+
// Frozen US state/territory 2-letter USPS enum (UPPERCASE-only). Built FROM this
|
|
84
|
+
// array by the Zod enum in server.ts (single source of truth). state is a REFINER
|
|
85
|
+
// only (S2) — never sufficient alone.
|
|
86
|
+
export const NPPES_STATES = [
|
|
87
|
+
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC", "FL",
|
|
88
|
+
"GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME",
|
|
89
|
+
"MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH",
|
|
90
|
+
"NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI",
|
|
91
|
+
"SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI",
|
|
92
|
+
"WY", "AS", "GU", "MP", "PR", "VI",
|
|
93
|
+
];
|
|
94
|
+
const NPPES_STATES_SET = new Set(NPPES_STATES);
|
|
95
|
+
// The REQUIRED-one criterion set (S2). `number` triggers exact mode; the rest are
|
|
96
|
+
// the search-mode required-one set. state + enumeration_type are REFINERS ONLY.
|
|
97
|
+
const SEARCH_REQUIRED_FIELDS = [
|
|
98
|
+
"first_name",
|
|
99
|
+
"last_name",
|
|
100
|
+
"organization_name",
|
|
101
|
+
"taxonomy_description",
|
|
102
|
+
"city",
|
|
103
|
+
"postal_code",
|
|
104
|
+
];
|
|
105
|
+
// Free-text fields that accept a trailing `*` wildcard (≥2 leading literal chars).
|
|
106
|
+
const TEXT_FIELDS = [
|
|
107
|
+
"first_name",
|
|
108
|
+
"last_name",
|
|
109
|
+
"organization_name",
|
|
110
|
+
"taxonomy_description",
|
|
111
|
+
"city",
|
|
112
|
+
];
|
|
113
|
+
// The co-filters an exact-NPI query may carry (all DROPPED from the wire, M1).
|
|
114
|
+
const CO_FILTER_FIELDS = [
|
|
115
|
+
"first_name",
|
|
116
|
+
"last_name",
|
|
117
|
+
"organization_name",
|
|
118
|
+
"taxonomy_description",
|
|
119
|
+
"city",
|
|
120
|
+
"postal_code",
|
|
121
|
+
"state",
|
|
122
|
+
"enumeration_type",
|
|
123
|
+
];
|
|
124
|
+
// ─── Disclosure constants (honesty obligations — verbatim, fault-asserted) ──
|
|
125
|
+
/**
|
|
126
|
+
* ★ S3 — the mandatory not-a-fitness-determination caveat carried in EVERY
|
|
127
|
+
* response (mirrors OFAC_NOT_DETERMINATION_NOTE). Kept verbatim so the fault
|
|
128
|
+
* suite can assert it. It discloses (1) not a determination + cross-check SAM/OFAC;
|
|
129
|
+
* (2) individual (NPI-1) records may surface personal/home addresses + phone/fax
|
|
130
|
+
* verbatim, with NO enrichment or cross-source join.
|
|
131
|
+
*/
|
|
132
|
+
export const NPPES_NOT_DETERMINATION_NOTE = "Public professional-registration data (CMS NPPES NPI Registry). Confirms enumeration / identity / taxonomy only — it is NOT a fitness, exclusion, licensure, or sanctions determination. Cross-check SAM exclusions + OFAC for debarment/sanctions and the state licensing board for licensure. Individual (NPI-1) records may include personal / home practice or mailing addresses plus telephone/fax surfaced VERBATIM from the public registry; this tool performs NO enrichment and NO cross-source join on them.";
|
|
133
|
+
/** ★ S3 — the per-query reach-cap POLICY disclosure carried on EVERY response. */
|
|
134
|
+
export const NPPES_REACH_CAP_NOTE = "This vetting tool reaches at most the first ~1,200 matches per query (limit ≤ 200, skip ≤ 1,000) as a deliberate targeted-lookup boundary — NPPES itself no longer enforces a skip ceiling. This is a PER-QUERY cap only; cross-query enumeration (iterating name/city/postal filters) is NOT architecturally prevented (inherent to any search API), matching the NSF precedent. Narrow your filters (name + state + taxonomy) for a complete, targeted result set rather than paging deeper.";
|
|
135
|
+
/** P1 — the lower-bound disclosure (built with the observed floor N). */
|
|
136
|
+
function lowerBoundNote(atLeast) {
|
|
137
|
+
return `NPPES does not expose a match total; a full page was returned, so this query has AT LEAST ${atLeast} matching provider(s) and the exact total is unknown — narrow filters (name + state + taxonomy) for a complete, targeted result.`;
|
|
138
|
+
}
|
|
139
|
+
const SOURCE = "npiregistry.cms.hhs.gov /api (NPPES NPI Registry, keyless)";
|
|
140
|
+
/** "true"/true → true, "false"/false → false, absent/other → null. */
|
|
141
|
+
function boolOrNull(x) {
|
|
142
|
+
if (x === true)
|
|
143
|
+
return true;
|
|
144
|
+
if (x === false)
|
|
145
|
+
return false;
|
|
146
|
+
if (x === "true")
|
|
147
|
+
return true;
|
|
148
|
+
if (x === "false")
|
|
149
|
+
return false;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function rec(x) {
|
|
153
|
+
return x !== null && typeof x === "object" && !Array.isArray(x)
|
|
154
|
+
? x
|
|
155
|
+
: {};
|
|
156
|
+
}
|
|
157
|
+
/** Map ONE address/practiceLocation entry (identical mapping — S1: NEVER merged). */
|
|
158
|
+
function mapAddress(raw) {
|
|
159
|
+
const a = rec(raw);
|
|
160
|
+
return {
|
|
161
|
+
purpose: str(a.address_purpose),
|
|
162
|
+
address1: str(a.address_1),
|
|
163
|
+
address2: str(a.address_2),
|
|
164
|
+
city: str(a.city),
|
|
165
|
+
state: str(a.state),
|
|
166
|
+
postalCode: str(a.postal_code),
|
|
167
|
+
telephone: str(a.telephone_number),
|
|
168
|
+
fax: str(a.fax_number),
|
|
169
|
+
countryCode: str(a.country_code),
|
|
170
|
+
countryName: str(a.country_name),
|
|
171
|
+
addressType: str(a.address_type),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function mapTaxonomy(raw) {
|
|
175
|
+
const t = rec(raw);
|
|
176
|
+
return {
|
|
177
|
+
code: str(t.code),
|
|
178
|
+
desc: str(t.desc),
|
|
179
|
+
primary: boolOrNull(t.primary),
|
|
180
|
+
state: str(t.state),
|
|
181
|
+
license: str(t.license),
|
|
182
|
+
taxonomyGroup: str(t.taxonomy_group),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function mapArray(raw, fn) {
|
|
186
|
+
if (!Array.isArray(raw))
|
|
187
|
+
return [];
|
|
188
|
+
return raw.map(fn);
|
|
189
|
+
}
|
|
190
|
+
/** Verbatim array passthrough (identifiers/otherNames/endpoints — never flattened). */
|
|
191
|
+
function verbatimArray(raw) {
|
|
192
|
+
return Array.isArray(raw) ? raw : [];
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Map ONE NPPES results[] record → the curated provider shape. Every scalar is
|
|
196
|
+
* null-never-fabricated (str/num). `active` is derived ONLY from status === "A"
|
|
197
|
+
* (P3); epochs are ms numeric STRINGS → num (null-never-0). Arrays are surfaced
|
|
198
|
+
* verbatim / mapped element-wise — NEVER flattened, addresses[] and
|
|
199
|
+
* practiceLocations[] kept SEPARATE (S1).
|
|
200
|
+
*/
|
|
201
|
+
function mapProvider(raw) {
|
|
202
|
+
const r = rec(raw);
|
|
203
|
+
const basic = rec(r.basic);
|
|
204
|
+
const status = str(basic.status);
|
|
205
|
+
return {
|
|
206
|
+
number: str(r.number),
|
|
207
|
+
enumerationType: str(r.enumeration_type),
|
|
208
|
+
active: status === "A",
|
|
209
|
+
status,
|
|
210
|
+
basic: {
|
|
211
|
+
firstName: str(basic.first_name),
|
|
212
|
+
lastName: str(basic.last_name),
|
|
213
|
+
middleName: str(basic.middle_name),
|
|
214
|
+
namePrefix: str(basic.name_prefix),
|
|
215
|
+
nameSuffix: str(basic.name_suffix),
|
|
216
|
+
credential: str(basic.credential),
|
|
217
|
+
sex: str(basic.sex),
|
|
218
|
+
soleProprietor: str(basic.sole_proprietor),
|
|
219
|
+
organizationName: str(basic.organization_name),
|
|
220
|
+
organizationalSubpart: str(basic.organizational_subpart),
|
|
221
|
+
authorizedOfficialFirstName: str(basic.authorized_official_first_name),
|
|
222
|
+
authorizedOfficialLastName: str(basic.authorized_official_last_name),
|
|
223
|
+
authorizedOfficialMiddleName: str(basic.authorized_official_middle_name),
|
|
224
|
+
authorizedOfficialTitleOrPosition: str(basic.authorized_official_title_or_position),
|
|
225
|
+
authorizedOfficialTelephoneNumber: str(basic.authorized_official_telephone_number),
|
|
226
|
+
status,
|
|
227
|
+
enumerationDate: str(basic.enumeration_date),
|
|
228
|
+
certificationDate: str(basic.certification_date),
|
|
229
|
+
lastUpdated: str(basic.last_updated),
|
|
230
|
+
},
|
|
231
|
+
taxonomies: Array.isArray(r.taxonomies)
|
|
232
|
+
? r.taxonomies.map(mapTaxonomy)
|
|
233
|
+
: [],
|
|
234
|
+
addresses: mapArray(r.addresses, mapAddress),
|
|
235
|
+
practiceLocations: mapArray(r.practiceLocations, mapAddress),
|
|
236
|
+
identifiers: verbatimArray(r.identifiers),
|
|
237
|
+
otherNames: verbatimArray(r.other_names),
|
|
238
|
+
endpoints: verbatimArray(r.endpoints),
|
|
239
|
+
createdEpoch: num(r.created_epoch),
|
|
240
|
+
lastUpdatedEpoch: num(r.last_updated_epoch),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// ─── CMS NPI Luhn check (LOAD-BEARING — NPPES validates ONLY length) ──
|
|
244
|
+
/**
|
|
245
|
+
* Validate the CMS NPI check digit: the Luhn algorithm over `80840` + the first 9
|
|
246
|
+
* NPI digits (14 digits total, ISO/IEC 7812) must reproduce the 10th NPI digit.
|
|
247
|
+
* `npi` MUST already be `^\d{10}$`. A Luhn-FAILING 10-digit string is provably
|
|
248
|
+
* NOT a valid NPI ⇒ invalid_input (a typo must NOT read as found:false).
|
|
249
|
+
*/
|
|
250
|
+
export function cmsLuhnValid(npi) {
|
|
251
|
+
if (!NPI_RE.test(npi))
|
|
252
|
+
return false;
|
|
253
|
+
const base = "80840" + npi.slice(0, 9); // 14 digits
|
|
254
|
+
let sum = 0;
|
|
255
|
+
let doubleIt = true; // the rightmost base digit is the 2nd-from-right in the full 15-digit number → doubled
|
|
256
|
+
for (let i = base.length - 1; i >= 0; i--) {
|
|
257
|
+
let d = base.charCodeAt(i) - 48;
|
|
258
|
+
if (doubleIt) {
|
|
259
|
+
d *= 2;
|
|
260
|
+
if (d > 9)
|
|
261
|
+
d -= 9;
|
|
262
|
+
}
|
|
263
|
+
sum += d;
|
|
264
|
+
doubleIt = !doubleIt;
|
|
265
|
+
}
|
|
266
|
+
const check = (10 - (sum % 10)) % 10;
|
|
267
|
+
return check === npi.charCodeAt(9) - 48;
|
|
268
|
+
}
|
|
269
|
+
// ─── Text sanitization + wildcard grammar (S2) ──────────────────
|
|
270
|
+
function invalidInput(message) {
|
|
271
|
+
return new ToolErrorCarrier({
|
|
272
|
+
kind: "invalid_input",
|
|
273
|
+
message,
|
|
274
|
+
retryable: false,
|
|
275
|
+
upstreamEndpoint: NPPES_LABEL,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/** Strip control chars + trim. URLSearchParams encodes the rest (no SSRF surface). */
|
|
279
|
+
function sanitizeText(v) {
|
|
280
|
+
let out = "";
|
|
281
|
+
for (const ch of v) {
|
|
282
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
283
|
+
if (code < 0x20 || code === 0x7f)
|
|
284
|
+
continue; // drop C0 control chars + DEL
|
|
285
|
+
out += ch;
|
|
286
|
+
}
|
|
287
|
+
return out.trim();
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* ★ S2 — a trailing `*` wildcard requires ≥2 leading literal chars (NPPES live:
|
|
291
|
+
* `last_name=a*` → {Errors:"Wildcards require at least two leading characters"}).
|
|
292
|
+
* A `*` anywhere but a single trailing position is rejected (NPPES supports only a
|
|
293
|
+
* trailing wildcard).
|
|
294
|
+
*/
|
|
295
|
+
function checkWildcard(field, v) {
|
|
296
|
+
if (!v.includes("*"))
|
|
297
|
+
return;
|
|
298
|
+
if (v.indexOf("*") !== v.length - 1 || v.indexOf("*") !== v.lastIndexOf("*")) {
|
|
299
|
+
throw invalidInput(`Invalid wildcard in ${field} ${JSON.stringify(v)} — NPPES supports only a SINGLE TRAILING '*' wildcard.`);
|
|
300
|
+
}
|
|
301
|
+
const literal = v.slice(0, -1);
|
|
302
|
+
if (literal.length < 2) {
|
|
303
|
+
throw invalidInput(`Invalid wildcard in ${field} ${JSON.stringify(v)} — a trailing '*' requires at least 2 leading literal characters (NPPES rejects a single-leading-char wildcard).`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// ─── SSRF-guarded fetch (module-built URLSearchParams + hostname assert) ──
|
|
307
|
+
/**
|
|
308
|
+
* GET NPPES with the module-built query. SSRF: host+path+version are constants;
|
|
309
|
+
* every value rides in URLSearchParams (no host-alteration surface); then the
|
|
310
|
+
* CONSTRUCTED URL's hostname === NPPES_HOST (https) assertion. redirect:"error"
|
|
311
|
+
* (off-host 3xx fails closed — body never read). Keyless — the only header is
|
|
312
|
+
* implicit; NO apiKey. Returns the parsed JSON (caller validates the envelope).
|
|
313
|
+
*/
|
|
314
|
+
async function nppesGet(params) {
|
|
315
|
+
const built = new URL(`${NPPES_URL}?${params.toString()}`);
|
|
316
|
+
if (built.hostname !== NPPES_HOST || built.protocol !== "https:") {
|
|
317
|
+
throw invalidInput(`Constructed NPPES URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${NPPES_HOST} over https — refusing to fetch (SSRF safety).`);
|
|
318
|
+
}
|
|
319
|
+
// ★ W3-2 — the SyntaxError→schema_drift catch-ladder (fema.ts:262-275 shape).
|
|
320
|
+
// getJson's r.json() runs OUTSIDE fetchWithRetry, so a 200 non-JSON body (an
|
|
321
|
+
// npiregistry HTML/WAF/maintenance masquerade) throws a raw SyntaxError; toToolError
|
|
322
|
+
// has NO schema_drift branch → it would degrade to kind:"unknown". Preserve the
|
|
323
|
+
// fetchWithRetry taxonomy (429/404/5xx/400/timeout ToolErrorCarrier) FIRST (a
|
|
324
|
+
// broader catch would reclassify a 429 to schema_drift), reclassify the SyntaxError
|
|
325
|
+
// SECOND, bare-rethrow LAST.
|
|
326
|
+
try {
|
|
327
|
+
return await throughGate("nppes", NPPES_GATE_MIN_INTERVAL_MS, () => getJson(built.toString(), { label: NPPES_LABEL, redirect: "error" }));
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
if (e instanceof ToolErrorCarrier)
|
|
331
|
+
throw e;
|
|
332
|
+
if (e instanceof SyntaxError)
|
|
333
|
+
throw driftError(NPPES_LABEL, "NPPES returned a non-JSON body at HTTP 200 — schema drift.");
|
|
334
|
+
throw e;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Validate the HTTP-200 body envelope. Guard ORDER (a 200 body can carry an error):
|
|
339
|
+
* (a) NON-object body ⇒ driftError.
|
|
340
|
+
* (b) `Errors` present (array, non-empty) ⇒ THROW invalid_input surfacing
|
|
341
|
+
* Errors[0].description — the NSF serviceNotification twin (NEVER read the
|
|
342
|
+
* missing `results` as an empty result).
|
|
343
|
+
* (c) `results` absent / non-array (and no Errors) ⇒ driftError (nothing
|
|
344
|
+
* trustworthy — never a fake empty).
|
|
345
|
+
* (d) `result_count` absent / non-finite ⇒ driftError.
|
|
346
|
+
* (e) result_count !== results.length ⇒ driftError (the count-parity guard, P1).
|
|
347
|
+
* A genuine {result_count:0, results:[]} (both keys) is DISTINCT — it returns 0
|
|
348
|
+
* honestly (the caller renders found:false / an empty list).
|
|
349
|
+
*/
|
|
350
|
+
function parseNppesBody(body) {
|
|
351
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
352
|
+
throw driftError(NPPES_LABEL, "NPPES returned a 200 body that is not an object {result_count, results} — refusing to report it as an empty result.");
|
|
353
|
+
}
|
|
354
|
+
const b = body;
|
|
355
|
+
const errors = b.Errors;
|
|
356
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
357
|
+
const first = rec(errors[0]);
|
|
358
|
+
const desc = str(first.description) ?? "(no description)";
|
|
359
|
+
const field = str(first.field);
|
|
360
|
+
throw invalidInput(`NPPES rejected the request [Errors]: ${desc}${field ? ` (field: ${field})` : ""}. (A 200 body carrying Errors and NO results key is a body-level loud-fail — never read as an empty result.)`);
|
|
361
|
+
}
|
|
362
|
+
if (!Array.isArray(b.results)) {
|
|
363
|
+
throw driftError(NPPES_LABEL, "NPPES 200 body is missing the `results` array with no `Errors` — nothing trustworthy to report (treating as schema drift, never a fake empty).");
|
|
364
|
+
}
|
|
365
|
+
const results = b.results;
|
|
366
|
+
const rc = b.result_count;
|
|
367
|
+
if (typeof rc !== "number" || !Number.isFinite(rc)) {
|
|
368
|
+
throw driftError(NPPES_LABEL, "NPPES `result_count` absent/non-numeric — cannot report a trustworthy count (schema drift). typeof-checked BEFORE any coercion.");
|
|
369
|
+
}
|
|
370
|
+
if (rc !== results.length) {
|
|
371
|
+
throw driftError(NPPES_LABEL, `NPPES result_count (${rc}) !== results.length (${results.length}) — the count-parity guard tripped (never present a page count that disagrees with the rows).`);
|
|
372
|
+
}
|
|
373
|
+
return { resultCount: rc, results };
|
|
374
|
+
}
|
|
375
|
+
// ─── The M1 client-side post-match (exact-NPI co-filter annotation) ──
|
|
376
|
+
/** Uppercase-normalize + drop a trailing `*` for a client-side compare. */
|
|
377
|
+
function normCmp(v) {
|
|
378
|
+
const t = v.trim();
|
|
379
|
+
const wild = t.endsWith("*");
|
|
380
|
+
return { target: (wild ? t.slice(0, -1) : t).toUpperCase(), wild };
|
|
381
|
+
}
|
|
382
|
+
function scalarMatches(candidate, target, wild) {
|
|
383
|
+
if (candidate === null)
|
|
384
|
+
return false;
|
|
385
|
+
const c = candidate.trim().toUpperCase();
|
|
386
|
+
return wild ? c.startsWith(target) : c === target;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* ★ M1 (option b) — did the returned record match ONE co-supplied filter? Computed
|
|
390
|
+
* CLIENT-SIDE (the filter was NOT sent to the wire). A `false` means "the NPI is
|
|
391
|
+
* valid/active but does NOT match your supplied value for this field" — NEVER a
|
|
392
|
+
* reason to zero the record into found:false.
|
|
393
|
+
*/
|
|
394
|
+
function fieldMatches(field, value, p) {
|
|
395
|
+
const { target, wild } = normCmp(value);
|
|
396
|
+
const allAddr = [...p.addresses, ...p.practiceLocations];
|
|
397
|
+
switch (field) {
|
|
398
|
+
case "first_name":
|
|
399
|
+
return (scalarMatches(p.basic.firstName, target, wild) ||
|
|
400
|
+
scalarMatches(p.basic.authorizedOfficialFirstName, target, wild));
|
|
401
|
+
case "last_name":
|
|
402
|
+
return (scalarMatches(p.basic.lastName, target, wild) ||
|
|
403
|
+
scalarMatches(p.basic.authorizedOfficialLastName, target, wild));
|
|
404
|
+
case "organization_name":
|
|
405
|
+
return scalarMatches(p.basic.organizationName, target, wild);
|
|
406
|
+
case "city":
|
|
407
|
+
return allAddr.some((a) => scalarMatches(a.city, target, wild));
|
|
408
|
+
case "state":
|
|
409
|
+
return (allAddr.some((a) => scalarMatches(a.state, target, wild)) ||
|
|
410
|
+
p.taxonomies.some((t) => scalarMatches(t.state, target, wild)));
|
|
411
|
+
case "postal_code":
|
|
412
|
+
// Postal codes vary (5 vs 9 digit) → a prefix match on the supplied value.
|
|
413
|
+
return allAddr.some((a) => a.postalCode !== null && a.postalCode.trim().toUpperCase().startsWith(target));
|
|
414
|
+
case "enumeration_type":
|
|
415
|
+
return p.enumerationType === value;
|
|
416
|
+
case "taxonomy_description":
|
|
417
|
+
return p.taxonomies.some((t) => t.desc !== null && t.desc.trim().toUpperCase().includes(target));
|
|
418
|
+
default:
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// ─── The tool ─────────────────────────────────────────────────────
|
|
423
|
+
/**
|
|
424
|
+
* `nppes_lookup_provider` — keyless NPPES NPI Registry lookup. Mode inferred from
|
|
425
|
+
* `number`: EXACT-NPI detail (Luhn-validated, number-only wire, M1 co-filter
|
|
426
|
+
* annotation) OR a filtered SEARCH (required-one gate + refiners, S2; ≤200/≤1000
|
|
427
|
+
* pagination policy caps, S3). NEVER fakes a not-found: a typo'd NPI ⇒
|
|
428
|
+
* invalid_input; a {Errors} body ⇒ THROW; a genuine {result_count:0} ⇒ honest
|
|
429
|
+
* found:false / empty. The not-a-fitness caveat + reach-cap policy ride EVERY
|
|
430
|
+
* response.
|
|
431
|
+
*/
|
|
432
|
+
export async function lookupProvider(args) {
|
|
433
|
+
const hasNumber = args.number !== undefined && args.number !== null && String(args.number).trim() !== "";
|
|
434
|
+
// ── Sanitize + wildcard-validate the free-text fields up front (both modes). ──
|
|
435
|
+
const text = {};
|
|
436
|
+
for (const f of TEXT_FIELDS) {
|
|
437
|
+
const raw = args[f];
|
|
438
|
+
if (raw === undefined || raw === null)
|
|
439
|
+
continue;
|
|
440
|
+
const v = sanitizeText(String(raw));
|
|
441
|
+
if (v === "")
|
|
442
|
+
continue;
|
|
443
|
+
if (v.length > MAX_TEXT_LEN) {
|
|
444
|
+
throw invalidInput(`${f} exceeds ${MAX_TEXT_LEN} characters — narrow the value.`);
|
|
445
|
+
}
|
|
446
|
+
checkWildcard(f, v);
|
|
447
|
+
text[f] = v;
|
|
448
|
+
}
|
|
449
|
+
if (args.postal_code !== undefined && args.postal_code !== null) {
|
|
450
|
+
const pc = sanitizeText(String(args.postal_code));
|
|
451
|
+
if (pc !== "") {
|
|
452
|
+
if (pc.length > MAX_TEXT_LEN)
|
|
453
|
+
throw invalidInput("postal_code is too long.");
|
|
454
|
+
checkWildcard("postal_code", pc);
|
|
455
|
+
text.postal_code = pc;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ── enumeration_type / state refiner validation (REFINERS ONLY — S2). ──
|
|
459
|
+
let enumType;
|
|
460
|
+
if (args.enumeration_type !== undefined && args.enumeration_type !== null && String(args.enumeration_type).trim() !== "") {
|
|
461
|
+
enumType = String(args.enumeration_type).trim();
|
|
462
|
+
if (!ENUM_TYPES.has(enumType)) {
|
|
463
|
+
throw invalidInput(`Invalid enumeration_type ${JSON.stringify(enumType)} — expected 'NPI-1' or 'NPI-2'.`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
let state;
|
|
467
|
+
if (args.state !== undefined && args.state !== null && String(args.state).trim() !== "") {
|
|
468
|
+
state = String(args.state).trim().toUpperCase();
|
|
469
|
+
if (!NPPES_STATES_SET.has(state)) {
|
|
470
|
+
throw invalidInput(`Invalid state ${JSON.stringify(args.state)} — expected a USPS 2-letter state/territory code (a non-state value silently returns 0 on NPPES, indistinguishable from 'no provider').`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// ═══════════════════════════════════════════════════════════════
|
|
474
|
+
// EXACT-NPI MODE (M1) — number-only wire; co-filters annotated client-side.
|
|
475
|
+
// ═══════════════════════════════════════════════════════════════
|
|
476
|
+
if (hasNumber) {
|
|
477
|
+
const npi = String(args.number).trim();
|
|
478
|
+
if (!NPI_RE.test(npi)) {
|
|
479
|
+
throw invalidInput(`Invalid number ${JSON.stringify(npi)} — an NPI is exactly 10 digits (^\\d{10}$).`);
|
|
480
|
+
}
|
|
481
|
+
if (!cmsLuhnValid(npi)) {
|
|
482
|
+
throw invalidInput(`NPI ${JSON.stringify(npi)} fails the CMS check-digit (Luhn over 80840+first-9) — it is provably NOT a valid NPI (a typo must never read as 'provider does not exist'). NPPES validates only length, so this pre-check is load-bearing.`);
|
|
483
|
+
}
|
|
484
|
+
// ★ M1 — build the wire query with `number` (+`version`) as the SOLE params.
|
|
485
|
+
// NO co-supplied filter is ever forwarded (NPPES AND-combines a number with
|
|
486
|
+
// filters → a mismatched filter would zero a real, active provider).
|
|
487
|
+
const params = new URLSearchParams();
|
|
488
|
+
params.set("version", NPPES_VERSION);
|
|
489
|
+
params.set("number", npi);
|
|
490
|
+
// The co-filters the caller supplied (DROPPED from the wire, annotated below).
|
|
491
|
+
const coFilters = {};
|
|
492
|
+
for (const f of CO_FILTER_FIELDS) {
|
|
493
|
+
if (f === "state") {
|
|
494
|
+
if (state !== undefined)
|
|
495
|
+
coFilters.state = state;
|
|
496
|
+
}
|
|
497
|
+
else if (f === "enumeration_type") {
|
|
498
|
+
if (enumType !== undefined)
|
|
499
|
+
coFilters.enumeration_type = enumType;
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
const tv = text[f];
|
|
503
|
+
if (tv !== undefined)
|
|
504
|
+
coFilters[f] = tv;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const droppedFilters = Object.keys(coFilters);
|
|
508
|
+
const parsed = parseNppesBody(await nppesGet(params));
|
|
509
|
+
const notes = [];
|
|
510
|
+
if (parsed.resultCount === 0) {
|
|
511
|
+
// Genuine not-found (honest found:false — NEVER a fake or a thrown empty).
|
|
512
|
+
notes.push(`No NPPES record for NPI ${JSON.stringify(npi)} (found:false — the NPI is Luhn-valid but not in the ACTIVE NPPES registry: it may never have been assigned OR was deactivated; NPPES search returns active records). This is an honest not-found, not a fitness determination.`);
|
|
513
|
+
if (droppedFilters.length > 0) {
|
|
514
|
+
notes.push(exactDropNote(droppedFilters));
|
|
515
|
+
}
|
|
516
|
+
notes.push(NPPES_NOT_DETERMINATION_NOTE, NPPES_REACH_CAP_NOTE);
|
|
517
|
+
return withMeta({ found: false, provider: null }, {
|
|
518
|
+
source: SOURCE,
|
|
519
|
+
keylessMode: true,
|
|
520
|
+
returned: 0,
|
|
521
|
+
totalAvailable: 0,
|
|
522
|
+
filtersApplied: ["number"],
|
|
523
|
+
filtersDropped: droppedFilters,
|
|
524
|
+
fieldsUnavailable: [],
|
|
525
|
+
notes,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
// result_count ≥ 1 (an NPI is unique — take the first; parity already asserted).
|
|
529
|
+
const provider = mapProvider(parsed.results[0]);
|
|
530
|
+
// ★ M1 (option b) — client-side post-match annotation for each dropped filter.
|
|
531
|
+
let filterMatch;
|
|
532
|
+
if (droppedFilters.length > 0) {
|
|
533
|
+
filterMatch = {};
|
|
534
|
+
for (const f of droppedFilters) {
|
|
535
|
+
filterMatch[f] = fieldMatches(f, coFilters[f], provider);
|
|
536
|
+
}
|
|
537
|
+
notes.push(exactDropNote(droppedFilters));
|
|
538
|
+
const mismatched = droppedFilters.filter((f) => filterMatch[f] === false);
|
|
539
|
+
if (mismatched.length > 0) {
|
|
540
|
+
notes.push(`The NPI is active/enumerated, but it does NOT match your supplied ${mismatched.join(", ")} (checked client-side — see data.filterMatch). A mismatch does NOT mean the NPI is invalid; it means the registry record differs from your expectation for that field.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (!provider.active) {
|
|
544
|
+
notes.push(`provider.active is false (basic.status = ${JSON.stringify(provider.status)}); status is NOT 'A'. Not a fitness determination.`);
|
|
545
|
+
}
|
|
546
|
+
notes.push(NPPES_NOT_DETERMINATION_NOTE, NPPES_REACH_CAP_NOTE);
|
|
547
|
+
const data = { found: true, provider };
|
|
548
|
+
if (filterMatch !== undefined) {
|
|
549
|
+
data.filterMatch = filterMatch;
|
|
550
|
+
data.filtersDropped = droppedFilters;
|
|
551
|
+
}
|
|
552
|
+
return withMeta(data, {
|
|
553
|
+
source: SOURCE,
|
|
554
|
+
keylessMode: true,
|
|
555
|
+
returned: 1,
|
|
556
|
+
totalAvailable: 1,
|
|
557
|
+
filtersApplied: ["number"],
|
|
558
|
+
filtersDropped: droppedFilters,
|
|
559
|
+
fieldsUnavailable: [],
|
|
560
|
+
notes,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
// ═══════════════════════════════════════════════════════════════
|
|
564
|
+
// SEARCH MODE — required-one gate (S2) + refiners; ≤200/≤1000 caps (S3).
|
|
565
|
+
// ═══════════════════════════════════════════════════════════════
|
|
566
|
+
// ── Required-criterion gate (S2): at least ONE of the required-one set. state +
|
|
567
|
+
// enumeration_type are REFINERS ONLY — never sufficient alone. ──
|
|
568
|
+
const suppliedRequired = SEARCH_REQUIRED_FIELDS.filter((f) => text[f] !== undefined);
|
|
569
|
+
if (suppliedRequired.length === 0) {
|
|
570
|
+
const hadRefinerOnly = state !== undefined || enumType !== undefined;
|
|
571
|
+
throw invalidInput(hadRefinerOnly
|
|
572
|
+
? "state and enumeration_type are REFINERS ONLY — NPPES rejects them as the sole criterion. Supply at least one of: number, first_name, last_name, organization_name, taxonomy_description, city, postal_code."
|
|
573
|
+
: "At least one search criterion is required: number, first_name, last_name, organization_name, taxonomy_description, city, or postal_code (state / enumeration_type alone are not valid NPPES criteria).");
|
|
574
|
+
}
|
|
575
|
+
// ── Limit + skip guards (S3 policy caps — belt-and-suspenders behind Zod). ──
|
|
576
|
+
const limit = args.limit ?? NPPES_DEFAULT_LIMIT;
|
|
577
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > NPPES_MAX_LIMIT) {
|
|
578
|
+
throw invalidInput(`limit ${JSON.stringify(args.limit)} out of range — NPPES caps a page at ${NPPES_MAX_LIMIT} (the API silently clamps >${NPPES_MAX_LIMIT}; this tool rejects it loudly). Use 1..${NPPES_MAX_LIMIT}.`);
|
|
579
|
+
}
|
|
580
|
+
const skip = args.skip ?? 0;
|
|
581
|
+
if (!Number.isInteger(skip) || skip < 0 || skip > NPPES_MAX_SKIP) {
|
|
582
|
+
throw invalidInput(`skip ${JSON.stringify(args.skip)} out of range — this vetting tool caps reach at skip ≤ ${NPPES_MAX_SKIP} (a deliberate targeted-lookup POLICY boundary; NPPES itself no longer enforces a skip ceiling). Narrow your filters rather than paging deeper.`);
|
|
583
|
+
}
|
|
584
|
+
// ── Build the wire query key-by-key from validated typed args (SSRF: no raw
|
|
585
|
+
// passthrough). Refiners ride along ONLY alongside a required criterion. ──
|
|
586
|
+
const params = new URLSearchParams();
|
|
587
|
+
params.set("version", NPPES_VERSION);
|
|
588
|
+
const filtersApplied = [];
|
|
589
|
+
for (const f of SEARCH_REQUIRED_FIELDS) {
|
|
590
|
+
const v = text[f];
|
|
591
|
+
if (v !== undefined) {
|
|
592
|
+
params.set(f, v);
|
|
593
|
+
filtersApplied.push(f);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (state !== undefined) {
|
|
597
|
+
params.set("state", state);
|
|
598
|
+
filtersApplied.push("state");
|
|
599
|
+
}
|
|
600
|
+
if (enumType !== undefined) {
|
|
601
|
+
params.set("enumeration_type", enumType);
|
|
602
|
+
filtersApplied.push("enumeration_type");
|
|
603
|
+
}
|
|
604
|
+
params.set("limit", String(limit));
|
|
605
|
+
params.set("skip", String(skip));
|
|
606
|
+
const parsed = parseNppesBody(await nppesGet(params));
|
|
607
|
+
const providers = parsed.results.map(mapProvider);
|
|
608
|
+
const returned = providers.length;
|
|
609
|
+
// ── Pagination + lower-bound honesty (P1). No grand total exists → a full page
|
|
610
|
+
// means MORE may exist; totalAvailable is the KNOWN lower bound skip+returned. ──
|
|
611
|
+
const pageFull = returned === limit;
|
|
612
|
+
const candidateNext = skip + returned;
|
|
613
|
+
const nextSkip = pageFull && candidateNext <= NPPES_MAX_SKIP ? candidateNext : null;
|
|
614
|
+
const hasMore = nextSkip !== null;
|
|
615
|
+
const totalAvailable = skip + returned;
|
|
616
|
+
const notes = [];
|
|
617
|
+
if (pageFull) {
|
|
618
|
+
notes.push(lowerBoundNote(totalAvailable));
|
|
619
|
+
if (nextSkip === null) {
|
|
620
|
+
notes.push(`A full page was returned but the next page would exceed the skip ≤ ${NPPES_MAX_SKIP} policy cap — additional matches exist but are NOT reachable via this tool. Narrow your filters for a complete set.`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
else if (returned === 0) {
|
|
624
|
+
notes.push(`No NPPES providers matched (found:false / empty — an honest zero, distinct from an outage or a body-level error). Not a fitness determination.`);
|
|
625
|
+
}
|
|
626
|
+
notes.push(NPPES_NOT_DETERMINATION_NOTE, NPPES_REACH_CAP_NOTE);
|
|
627
|
+
const meta = {
|
|
628
|
+
source: SOURCE,
|
|
629
|
+
keylessMode: true,
|
|
630
|
+
// A full page is never complete (more may exist, reachable or not).
|
|
631
|
+
truncated: pageFull,
|
|
632
|
+
returned,
|
|
633
|
+
totalAvailable,
|
|
634
|
+
filtersApplied,
|
|
635
|
+
filtersDropped: [],
|
|
636
|
+
fieldsUnavailable: [],
|
|
637
|
+
pagination: { offset: skip, limit, hasMore, nextOffset: nextSkip },
|
|
638
|
+
notes,
|
|
639
|
+
};
|
|
640
|
+
if (pageFull)
|
|
641
|
+
meta.totalIsLowerBound = true;
|
|
642
|
+
return withMeta({ providers }, meta);
|
|
643
|
+
}
|
|
644
|
+
/** The M1 exact-mode filter-drop disclosure (dropped filters + why). */
|
|
645
|
+
function exactDropNote(dropped) {
|
|
646
|
+
return `\`number\` is an EXACT NPI lookup: your other supplied filter(s) [${dropped.join(", ")}] were NOT sent to the wire — NPPES AND-combines a number with filters, so a mismatched filter would falsely zero a real, active provider into 'not found'. They were checked CLIENT-SIDE against the returned record instead (see data.filterMatch). Remove them, or use search mode (no number) to filter on the wire.`;
|
|
647
|
+
}
|
|
648
|
+
//# sourceMappingURL=nppes.js.map
|