@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/usitc.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* USITC Harmonized Tariff Schedule (HTS) — the IMPORT-TARIFF / supply-chain PRICE
|
|
3
|
+
* lane on a NEW keyless REST source (ADR-0039). Source #30; tool snapshot 102 →
|
|
4
|
+
* 103. A Price-lane sibling of pricing.ts: it extends the THIN Price lane with a
|
|
5
|
+
* NON-labor cost input — a good's HTS classification + its Column-1 General /
|
|
6
|
+
* Special (preferential/FTA) / Column-2 duty-rate TEXT + the Section 301/232 /
|
|
7
|
+
* Chapter-99 additional-duty provisions. Fully PUBLIC open data; KEYLESS (no key,
|
|
8
|
+
* no header gate); no PII (product/tariff/classification data only).
|
|
9
|
+
*
|
|
10
|
+
* ONE tool — `hts_lookup`: a single `query` (a KEYWORD or an HTS NUMBER — both
|
|
11
|
+
* ride `keyword=`) → the matching classification rows across the HTS hierarchy.
|
|
12
|
+
* Wire: GET https://hts.usitc.gov/reststop/search?keyword=<query>
|
|
13
|
+
* Body: a BARE JSON ARRAY (NO envelope, NO total, NO server pagination) of rows
|
|
14
|
+
* { htsno, statisticalSuffix, description, indent, units[], footnotes[],
|
|
15
|
+
* general, other, special, additionalDuties, quotaQuantity, effectivePeriod,
|
|
16
|
+
* status, … }.
|
|
17
|
+
*
|
|
18
|
+
* ★ THE DUTY-RATE HONESTY CRUX (P0 — load-bearing).
|
|
19
|
+
* `general` (Column-1 General), `special` (Special/preferential), `other`
|
|
20
|
+
* (Column-2) are AUTHORITATIVE VERBATIM TEXT — surfaced via `str` (trim only),
|
|
21
|
+
* NEVER via `num`. `num("Free")`→null, `num("35%")`→NaN→null; a coerced 0/null
|
|
22
|
+
* would render a FALSE "duty-free" (the money-lie class, inverted — here the
|
|
23
|
+
* authoritative value IS text and coercion DESTROYS it). This module deliberately
|
|
24
|
+
* does NOT import `num`, so no rate field can ever be numeric (the absence of
|
|
25
|
+
* `num` is itself the guard; ofac.ts/pricing.ts omit it too). An empty `special`
|
|
26
|
+
* ("") → str→null, disclosed as "no special-program rate" — NEVER read as Free.
|
|
27
|
+
*
|
|
28
|
+
* HONESTY (writes ZERO fetch/coerce/error/meta code — REUSES getJson/throughGate/
|
|
29
|
+
* driftError + coerce.str + withMeta/buildMeta):
|
|
30
|
+
* P1 (completeness) — NO upstream total + NO server pagination (the `offset`
|
|
31
|
+
* param is IGNORED). `totalAvailable = served array.length` (the EXACT count
|
|
32
|
+
* the endpoint served — honest relative to what the source returns); paging is
|
|
33
|
+
* CLIENT-SIDE over the held array (never trust a server offset). The M2
|
|
34
|
+
* disclosure states there is no fixed cap and a single-char/common fragment
|
|
35
|
+
* can serve 10,000–16,000+ rows (several MB).
|
|
36
|
+
* ★M1 (hierarchy) — the duty rate is stated ONCE at a shallower level (usually the
|
|
37
|
+
* 6/8-digit subheading) and inherits DOWNWARD to the blank statistical-suffix
|
|
38
|
+
* lines. A header/blank row's empty rate stays `null` (never fabricated); the
|
|
39
|
+
* hierarchy note tells the consumer to read UP to the nearest ANCESTOR line
|
|
40
|
+
* (SHALLOWER indent, same htsno prefix) with a non-empty rate — never to read
|
|
41
|
+
* the (blank) deepest line as no/unknown duty.
|
|
42
|
+
* ★M2 (no false ceiling) — the completeness note NEVER claims a "~900" cap; a
|
|
43
|
+
* MINIMUM query floor (≥3 non-whitespace chars) rejects a 1–2 char query as
|
|
44
|
+
* invalid_input BEFORE the fetch (closes the multi-MB "s"/"e" path); a
|
|
45
|
+
* belt-and-suspenders size bound (HTS_MAX_ROWS) discloses a very-large result.
|
|
46
|
+
* P2 (empty-vs-outage) — a no-match `[ ]` (HTTP 200) ⇒ honest empty; a
|
|
47
|
+
* 400(→S2)/404/5xx/timeout/non-array/HTML(SyntaxError→schema_drift) ⇒ THROW
|
|
48
|
+
* (never a fake empty). The SyntaxError→schema_drift wrap mirrors fdic.ts.
|
|
49
|
+
* ★S1 (additionalDuties) — the per-line `additionalDuties` field is frequently
|
|
50
|
+
* null even when Section 301/232 duties apply; the additional duty rides the
|
|
51
|
+
* separate Chapter-99 (`isChapter99`) rows + the footnotes — disclosed.
|
|
52
|
+
* ★S2 (transient 400) — the query is PRE-VALIDATED (≥3 chars, control-stripped,
|
|
53
|
+
* ≤100), so an HTTP 400 is not a caller fault: it is remapped to
|
|
54
|
+
* upstream_unavailable (transient, retryable) for this source.
|
|
55
|
+
* ★S3 (fieldsUnavailable) — per-line empty rate fields are NOT hoisted into
|
|
56
|
+
* top-level `_meta.fieldsUnavailable` (rate nullity is per-line-normal per M1);
|
|
57
|
+
* that field is reserved for a field genuinely absent across the WHOLE response.
|
|
58
|
+
*
|
|
59
|
+
* SSRF — fixed host `hts.usitc.gov` (compile-time constant + Set.has recheck),
|
|
60
|
+
* FIXED path `/reststop/search` (never caller free-text), the `query` value rides
|
|
61
|
+
* `keyword=` via URLSearchParams (percent-encoded — a crafted `../exportList` /
|
|
62
|
+
* `x&format=…` / off-host value cannot alter the host/path or inject a param); the
|
|
63
|
+
* value is control-stripped + length-bounded (≥3, ≤100); a post-construction
|
|
64
|
+
* hostname/protocol assert + `redirect:"error"` fail closed.
|
|
65
|
+
*
|
|
66
|
+
* Not-a-customs-ruling caveat (the Price-lane honesty boundary — the analog of
|
|
67
|
+
* pricing.ts's "not the rate paid" + the FAC/OFAC *_NOT_DETERMINATION_NOTE): every
|
|
68
|
+
* response carries HTS_NOT_A_RULING_NOTE.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
72
|
+
import { getJson, throughGate, driftError } from "./datasource.js";
|
|
73
|
+
import { str } from "./coerce.js";
|
|
74
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
75
|
+
|
|
76
|
+
// NOTE: `num` is DELIBERATELY NOT imported. The three duty-rate fields
|
|
77
|
+
// (general/special/other) are authoritative VERBATIM TEXT and must NEVER be
|
|
78
|
+
// coerced to a number (num("Free")→null / num("35%")→NaN = a destroyed rate / a
|
|
79
|
+
// false duty-free). The absence of `num` in this module is the honesty guard —
|
|
80
|
+
// no rate field can ever be numeric. (num-parity: usitc.ts OMITS `export { num }`
|
|
81
|
+
// exactly like ofac.ts / pricing.ts — consistent, not a regression.)
|
|
82
|
+
|
|
83
|
+
// ─── Fixed host allowlist + path (SSRF core — compile-time CONSTANTS) ──────────
|
|
84
|
+
// Single-entry allowlist, structured for a later host-add exactly like CMS_HOSTS.
|
|
85
|
+
export const USITC_HOSTS = ["hts.usitc.gov"] as const;
|
|
86
|
+
export type UsitcHost = (typeof USITC_HOSTS)[number];
|
|
87
|
+
const USITC_HOST: UsitcHost = USITC_HOSTS[0];
|
|
88
|
+
const USITC_HOST_SET: ReadonlySet<string> = new Set(USITC_HOSTS);
|
|
89
|
+
|
|
90
|
+
// The FIXED search path — the TOOL chooses it; NEVER caller free-text (the
|
|
91
|
+
// `exportList` range-export path is a separate DEFERRED constant, never selected
|
|
92
|
+
// by caller input).
|
|
93
|
+
const USITC_SEARCH_PATH = "/reststop/search";
|
|
94
|
+
|
|
95
|
+
// HOST-only label. Surfaces in ToolError.upstreamEndpoint; keyless → no token.
|
|
96
|
+
const USITC_LABEL = "usitc:" + USITC_HOST;
|
|
97
|
+
|
|
98
|
+
// The throughGate KEY — host-scoped. Grep-confirmed unique (no other source uses
|
|
99
|
+
// `usitc`). A modest self-throttle as courtesy to the single shared USITC host.
|
|
100
|
+
const USITC_GATE_KEY = "usitc";
|
|
101
|
+
const USITC_MIN_INTERVAL_MS = 200;
|
|
102
|
+
|
|
103
|
+
// ─── Query bounds (M2 floor + SSRF cap) + client-side paging bounds ────────────
|
|
104
|
+
// ★M2 — a MINIMUM query floor: a query with < 3 non-whitespace chars is rejected
|
|
105
|
+
// BEFORE the fetch (a 1–2 char / single-char fragment can force a 10,000–16,000+
|
|
106
|
+
// row / multi-MB fetch+parse against the single shared host).
|
|
107
|
+
const HTS_MIN_QUERY_CHARS = 3;
|
|
108
|
+
// SSRF length cap — the `keyword` value rides URLSearchParams; a length bound keeps
|
|
109
|
+
// the wire clean.
|
|
110
|
+
const HTS_MAX_QUERY_LEN = 100;
|
|
111
|
+
|
|
112
|
+
const HTS_DEFAULT_LIMIT = 50;
|
|
113
|
+
const HTS_MAX_LIMIT = 200;
|
|
114
|
+
|
|
115
|
+
// ★M2 belt-and-suspenders — a hard row-count ceiling. When the served array
|
|
116
|
+
// exceeds this we still set `totalAvailable` to the TRUE served length but return
|
|
117
|
+
// only the client-side page + an explicit "result set is very large" disclosure.
|
|
118
|
+
export const HTS_MAX_ROWS = 5000;
|
|
119
|
+
|
|
120
|
+
// ─── Disclosure constants (honesty obligations — verbatim, fault-asserted) ─────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* ★ The mandatory not-a-customs-ruling caveat carried on EVERY hts_lookup response
|
|
124
|
+
* (mirrors pricing.ts's CALC caveats + FAC_NOT_DETERMINATION_NOTE /
|
|
125
|
+
* OFAC_NOT_DETERMINATION_NOTE). Kept verbatim so the fault suite can assert it.
|
|
126
|
+
*/
|
|
127
|
+
export const HTS_NOT_A_RULING_NOTE =
|
|
128
|
+
"Harmonized Tariff Schedule data (USITC HTS). These are the PUBLISHED schedule rates — Column-1 General, Special (preferential/FTA), and Column-2 — NOT a binding CBP classification ruling and NOT a landed-cost quote. The duty actually owed depends on the good's correct classification, its COUNTRY OF ORIGIN, the applicable trade program/FTA, and ADDITIONAL duties (Section 301/232, antidumping/countervailing, and any Chapter 99 [`99..`] provisions returned alongside the base line). Rate values are VERBATIM text (`Free`, a percentage, a specific `$/unit`, or a compound rate) — read them in context, never as a bare number. Confirm the classification and obtain a binding ruling via CBP (CROSS / eRulings) before pricing an import.";
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* ★M1 — the hierarchy rate-inheritance disclosure. The rate inherits DOWNWARD; the
|
|
132
|
+
* consumer must read UP to the nearest ANCESTOR line (NOT the blank deepest line).
|
|
133
|
+
* Carried on EVERY response. RED if this ever says "read the deepest line".
|
|
134
|
+
*/
|
|
135
|
+
export const HTS_HIERARCHY_NOTE =
|
|
136
|
+
"A lookup returns rows across hierarchy levels. The duty rate is stated at one level (usually the 6/8-digit subheading) and APPLIES TO ALL DEEPER statistical-suffix lines below it, which are typically blank. To find the rate for a specific statistical line, read UP to the nearest ANCESTOR line (SHALLOWER indent, same htsno prefix) that has a non-empty rate — never conclude a blank deepest line means no/unknown duty.";
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* ★M2 — the completeness disclosure. NO server total, NO working pagination (the
|
|
140
|
+
* `offset` param is ignored); `totalAvailable` is the EXACT served array length;
|
|
141
|
+
* paging is client-side. NO "~900" ceiling — a single-char/common fragment can
|
|
142
|
+
* serve 10,000–16,000+ rows / several MB. Carried on EVERY response.
|
|
143
|
+
*/
|
|
144
|
+
export const HTS_COMPLETENESS_NOTE =
|
|
145
|
+
"The USITC HTS search endpoint returns the FULL match set for a query with NO server-side total and NO working pagination (the `offset` param is IGNORED); `totalAvailable` is the EXACT length of the array the endpoint served for this query, and paging is applied CLIENT-SIDE over that held array. There is NO fixed row cap — a single-character or common fragment can return 10,000–16,000+ rows (several MB) in one response — so narrow the query (a more specific keyword or a fuller HTS number) for a targeted classification.";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* ★S1 / P4 — the additional-duty disclosure. `additionalDuties` is frequently null
|
|
149
|
+
* even when Section 301/232 duties apply; the real additional duty rides the
|
|
150
|
+
* Chapter-99 rows + the footnotes. Carried on EVERY response.
|
|
151
|
+
*/
|
|
152
|
+
export const HTS_ADDITIONAL_DUTY_NOTE =
|
|
153
|
+
"The per-line `additionalDuties` field is frequently null even when Section 301/232 additional duties apply — it is NOT a reliable signal for their absence. Additional duties are carried by the separate Chapter-99 rows (`isChapter99:true`, htsno beginning `99`) returned alongside the base line and by the per-line `footnotes`; check those, not `additionalDuties`. A Chapter-99 provision STACKS on top of the base-line rate — the base classification's landed duty is NOT complete without checking them.";
|
|
154
|
+
|
|
155
|
+
/** Conditional — surfaced when a returned line has an empty Special rate. */
|
|
156
|
+
export const HTS_EMPTY_SPECIAL_NOTE =
|
|
157
|
+
"One or more lines have an empty Special (preferential/FTA/GSP) rate, surfaced as `specialPreferential: null` — this means NO special-program rate is published for that line (the Column-1 General rate applies unless a specific trade program qualifies). An empty/null Special is NEVER 'Free'.";
|
|
158
|
+
|
|
159
|
+
/** Conditional — surfaced when a returned line is a Chapter-99 provision. */
|
|
160
|
+
export const HTS_CHAPTER99_PRESENT_NOTE =
|
|
161
|
+
"This result set includes Chapter-99 rows (`isChapter99:true`, htsno beginning `99`) — ADDITIONAL-duty provisions (Section 301/232, safeguards, temporary/other special duties) that STACK on top of the base-line rate; include them when computing landed duty.";
|
|
162
|
+
|
|
163
|
+
/** ★M2 belt — surfaced when the served array exceeds HTS_MAX_ROWS. */
|
|
164
|
+
export const HTS_LARGE_RESULT_NOTE =
|
|
165
|
+
`This result set is VERY LARGE (over ${HTS_MAX_ROWS.toLocaleString()} rows served in one response); only the requested \`limit\`/\`offset\` page is returned (client-side) while \`totalAvailable\` reflects the TRUE served length. Narrow the query (a more specific keyword or a fuller HTS number) for a targeted, complete result set.`;
|
|
166
|
+
|
|
167
|
+
/** Conditional — surfaced when a returned description had `<il>` markup stripped. */
|
|
168
|
+
export const HTS_MARKUP_NOTE =
|
|
169
|
+
"Line descriptions had USITC inline markup (`<il>…</il>`) stripped to plain text (the inner text is preserved); the semantic content is unchanged.";
|
|
170
|
+
|
|
171
|
+
const SOURCE = "hts.usitc.gov/reststop/search (USITC HTS, keyless)";
|
|
172
|
+
|
|
173
|
+
// ─── invalid_input helper ──────────────────────────────────────────────────────
|
|
174
|
+
function invalidInput(message: string): ToolErrorCarrier {
|
|
175
|
+
return new ToolErrorCarrier({
|
|
176
|
+
kind: "invalid_input",
|
|
177
|
+
message,
|
|
178
|
+
retryable: false,
|
|
179
|
+
upstreamEndpoint: USITC_LABEL,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Strip C0 control chars + DEL and trim (URLSearchParams encodes the rest). */
|
|
184
|
+
function sanitizeQuery(v: string): string {
|
|
185
|
+
let out = "";
|
|
186
|
+
for (const ch of v) {
|
|
187
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
188
|
+
if (code < 0x20 || code === 0x7f) continue;
|
|
189
|
+
out += ch;
|
|
190
|
+
}
|
|
191
|
+
return out.trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Minimal, documented HTS markup strip: `<il>…</il>` → inner text preserved.
|
|
196
|
+
* Only these presentation tags are removed (never the inner text). Returns
|
|
197
|
+
* `{ value, stripped }` so the handler can disclose that a strip occurred.
|
|
198
|
+
*/
|
|
199
|
+
function stripHtsMarkup(raw: unknown): { value: string | null; stripped: boolean } {
|
|
200
|
+
const s = str(raw);
|
|
201
|
+
if (s === null) return { value: null, stripped: false };
|
|
202
|
+
if (!/<\/?il>/i.test(s)) return { value: s, stripped: false };
|
|
203
|
+
const cleaned = s.replace(/<\/?il>/gi, "").replace(/\s+/g, " ").trim();
|
|
204
|
+
return { value: cleaned === "" ? null : cleaned, stripped: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── SSRF-guarded fetch layer ──────────────────────────────────────────────────
|
|
208
|
+
/**
|
|
209
|
+
* GET the HTS search endpoint. SSRF: host ∈ allowlist (belt-and-suspenders),
|
|
210
|
+
* FIXED path constant (no caller value on the path), the CONSTRUCTED URL's
|
|
211
|
+
* hostname === host over https, `redirect:"error"` (off-host 3xx fails closed).
|
|
212
|
+
* Keyless — NO headers. Returns the parsed JSON (the caller validates the array).
|
|
213
|
+
*
|
|
214
|
+
* Error mapping:
|
|
215
|
+
* - a ToolErrorCarrier from the taxonomy propagates, EXCEPT ★S2: an HTTP 400 on
|
|
216
|
+
* the PRE-VALIDATED query is remapped invalid_input → upstream_unavailable
|
|
217
|
+
* (transient, retryable — a validated ≥3-char/control-stripped/≤100 query is
|
|
218
|
+
* not a caller fault; live USITC 400s transiently and recovers on retry).
|
|
219
|
+
* - a 200 non-JSON body makes getJson's r.json() throw a SyntaxError →
|
|
220
|
+
* driftError (schema_drift), the fdic.ts getFdic pattern.
|
|
221
|
+
*/
|
|
222
|
+
async function getHts(query: string): Promise<unknown> {
|
|
223
|
+
// Belt-and-suspenders host recheck (behind the const host).
|
|
224
|
+
if (!USITC_HOST_SET.has(USITC_HOST)) {
|
|
225
|
+
throw invalidInput(
|
|
226
|
+
`USITC host ${JSON.stringify(USITC_HOST)} is not on the curated allowlist.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const params = new URLSearchParams();
|
|
230
|
+
params.set("keyword", query); // percent-encoded — cannot alter host/path/params.
|
|
231
|
+
const url = `https://${USITC_HOST}${USITC_SEARCH_PATH}?${params.toString()}`;
|
|
232
|
+
const built = new URL(url);
|
|
233
|
+
if (built.hostname !== USITC_HOST || built.protocol !== "https:") {
|
|
234
|
+
throw invalidInput(
|
|
235
|
+
`Constructed USITC URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match ${USITC_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return await throughGate(USITC_GATE_KEY, USITC_MIN_INTERVAL_MS, () =>
|
|
240
|
+
getJson(url, { label: USITC_LABEL, redirect: "error" }),
|
|
241
|
+
);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (e instanceof ToolErrorCarrier) {
|
|
244
|
+
// ★S2 — a transient HTTP 400 on an ALREADY-VALIDATED query is not a caller
|
|
245
|
+
// fault; remap to a retryable upstream_unavailable (never invalid_input).
|
|
246
|
+
if (e.toolError.upstreamStatus === 400) {
|
|
247
|
+
throw new ToolErrorCarrier({
|
|
248
|
+
kind: "upstream_unavailable",
|
|
249
|
+
message: `USITC HTS returned HTTP 400 for an already-validated query — treating as a TRANSIENT upstream error (retryable), not a caller fault (the query was control-stripped, length-bounded, and ≥${HTS_MIN_QUERY_CHARS} chars before the fetch).`,
|
|
250
|
+
retryable: true,
|
|
251
|
+
retryAfterSeconds: 15,
|
|
252
|
+
upstreamEndpoint: USITC_LABEL,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
// A 200 non-JSON body (a maintenance/wrong-route HTML page) makes r.json()
|
|
258
|
+
// throw a raw SyntaxError → reclassify as schema_drift (never a fake empty).
|
|
259
|
+
if (e instanceof SyntaxError) {
|
|
260
|
+
throw driftError(
|
|
261
|
+
USITC_LABEL,
|
|
262
|
+
"USITC HTS returned a non-JSON body at HTTP 200 — treating as schema drift.",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
throw e;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Row mapping ───────────────────────────────────────────────────────────────
|
|
270
|
+
export type HtsLine = {
|
|
271
|
+
htsno: string | null;
|
|
272
|
+
statisticalSuffix: string | null;
|
|
273
|
+
indent: string | null;
|
|
274
|
+
description: string | null;
|
|
275
|
+
units: string[];
|
|
276
|
+
/** Column-1 General duty rate — VERBATIM TEXT (str only; NEVER a number). */
|
|
277
|
+
columnOneGeneral: string | null;
|
|
278
|
+
/** Special (preferential/FTA/GSP) rate — VERBATIM TEXT; "" → null = none published. */
|
|
279
|
+
specialPreferential: string | null;
|
|
280
|
+
/** Column-2 (statutory/non-NTR) rate — VERBATIM TEXT (str only; NEVER a number). */
|
|
281
|
+
columnTwo: string | null;
|
|
282
|
+
additionalDuties: string | null;
|
|
283
|
+
/** Per-line footnote markers, passed through verbatim (deeper decode deferred). */
|
|
284
|
+
footnotes: unknown[];
|
|
285
|
+
quotaQuantity: string | null;
|
|
286
|
+
/** Effective-period block, passed through verbatim. */
|
|
287
|
+
effectivePeriod: unknown;
|
|
288
|
+
status: string | null;
|
|
289
|
+
/** Derived: htsno begins "99" — a Chapter-99 additional-duty provision. */
|
|
290
|
+
isChapter99: boolean;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
function rec(x: unknown): Record<string, unknown> {
|
|
294
|
+
return x !== null && typeof x === "object" && !Array.isArray(x)
|
|
295
|
+
? (x as Record<string, unknown>)
|
|
296
|
+
: {};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Map ONE raw HTS row → a curated line. Rates are str-only (NEVER num). */
|
|
300
|
+
function mapLine(raw: unknown): { line: HtsLine; markupStripped: boolean } {
|
|
301
|
+
const r = rec(raw);
|
|
302
|
+
const htsno = str(r.htsno);
|
|
303
|
+
const desc = stripHtsMarkup(r.description);
|
|
304
|
+
const units = Array.isArray(r.units)
|
|
305
|
+
? r.units.map(str).filter((u): u is string => u !== null)
|
|
306
|
+
: [];
|
|
307
|
+
const footnotes = Array.isArray(r.footnotes) ? (r.footnotes as unknown[]) : [];
|
|
308
|
+
const line: HtsLine = {
|
|
309
|
+
htsno,
|
|
310
|
+
statisticalSuffix: str(r.statisticalSuffix),
|
|
311
|
+
indent: str(r.indent),
|
|
312
|
+
description: desc.value,
|
|
313
|
+
units,
|
|
314
|
+
// ★ THE CRUX — verbatim str, NEVER num. An empty ("") rate → str→null (a
|
|
315
|
+
// header/blank row stays null; the M1 note points the consumer UP an ancestor).
|
|
316
|
+
columnOneGeneral: str(r.general),
|
|
317
|
+
specialPreferential: str(r.special),
|
|
318
|
+
columnTwo: str(r.other),
|
|
319
|
+
additionalDuties: str(r.additionalDuties),
|
|
320
|
+
footnotes,
|
|
321
|
+
quotaQuantity: str(r.quotaQuantity),
|
|
322
|
+
effectivePeriod: r.effectivePeriod ?? null,
|
|
323
|
+
status: str(r.status),
|
|
324
|
+
isChapter99: htsno !== null && htsno.startsWith("99"),
|
|
325
|
+
};
|
|
326
|
+
return { line, markupStripped: desc.stripped };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── The tool: hts_lookup ───────────────────────────────────────────────────────
|
|
330
|
+
export type HtsLookupArgs = {
|
|
331
|
+
query: string;
|
|
332
|
+
limit?: number;
|
|
333
|
+
offset?: number;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* hts_lookup — keyless USITC HTS classification + duty-rate lookup. A single
|
|
338
|
+
* `query` (a keyword OR an HTS number) → the matching classification rows across
|
|
339
|
+
* the hierarchy, with the Column-1 General / Special / Column-2 duty-rate TEXT +
|
|
340
|
+
* the Chapter-99 additional-duty provisions. The full array is fetched ONCE and
|
|
341
|
+
* paginated CLIENT-SIDE (the server serves no total and ignores `offset`).
|
|
342
|
+
*/
|
|
343
|
+
export async function htsLookup(args: HtsLookupArgs): Promise<MetaBundle> {
|
|
344
|
+
// ── Validate the query (M2 floor + SSRF cap) — BEFORE any fetch. ──
|
|
345
|
+
const rawQuery = args.query === undefined || args.query === null ? "" : String(args.query);
|
|
346
|
+
const query = sanitizeQuery(rawQuery);
|
|
347
|
+
const nonWs = query.replace(/\s+/g, "");
|
|
348
|
+
if (nonWs.length < HTS_MIN_QUERY_CHARS) {
|
|
349
|
+
throw invalidInput(
|
|
350
|
+
`\`query\` must have at least ${HTS_MIN_QUERY_CHARS} non-whitespace characters (after trimming/control-stripping) — a shorter query (a single character or common fragment) can make the USITC HTS endpoint serve 10,000–16,000+ rows / several MB in one response. Use a more specific keyword or a fuller HTS number.`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (query.length > HTS_MAX_QUERY_LEN) {
|
|
354
|
+
throw invalidInput(
|
|
355
|
+
`\`query\` is too long (${query.length} chars; max ${HTS_MAX_QUERY_LEN}).`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Client-side paging bounds (belt-and-suspenders behind Zod). ──
|
|
360
|
+
const limit = args.limit ?? HTS_DEFAULT_LIMIT;
|
|
361
|
+
const offset = args.offset ?? 0;
|
|
362
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > HTS_MAX_LIMIT) {
|
|
363
|
+
throw invalidInput(`limit ${JSON.stringify(args.limit)} out of range — use 1..${HTS_MAX_LIMIT}.`);
|
|
364
|
+
}
|
|
365
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
366
|
+
throw invalidInput(`offset ${JSON.stringify(args.offset)} out of range — use a non-negative integer.`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Fetch the FULL array once (P1 — no server total / no server pagination). ──
|
|
370
|
+
const body = await getHts(query);
|
|
371
|
+
// ★P2 — a success body that is NOT a JSON array is drift, never a fake empty.
|
|
372
|
+
if (!Array.isArray(body)) {
|
|
373
|
+
throw driftError(
|
|
374
|
+
USITC_LABEL,
|
|
375
|
+
`${USITC_LABEL} returned an unexpected shape — GET ${USITC_SEARCH_PATH} must return a bare JSON ARRAY of HTS rows. Treating as schema drift (never a fake empty).`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ★P1 — totalAvailable is the EXACT served array length (honest relative to what
|
|
380
|
+
// the endpoint returns); paging is CLIENT-SIDE over the held array (the server
|
|
381
|
+
// IGNORES offset — never trust a server offset).
|
|
382
|
+
const totalAvailable = body.length;
|
|
383
|
+
const page = (body as unknown[]).slice(offset, offset + limit);
|
|
384
|
+
const mapped = page.map(mapLine);
|
|
385
|
+
const lines = mapped.map((m) => m.line);
|
|
386
|
+
const returned = lines.length;
|
|
387
|
+
const hasMore = offset + returned < totalAvailable;
|
|
388
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
389
|
+
|
|
390
|
+
// ── Notes (mandatory + conditional). ──
|
|
391
|
+
const notes: string[] = [
|
|
392
|
+
HTS_NOT_A_RULING_NOTE,
|
|
393
|
+
HTS_HIERARCHY_NOTE, // ★M1 — upward-ancestor inheritance
|
|
394
|
+
HTS_COMPLETENESS_NOTE, // ★M2 — no "~900" ceiling; exact served length
|
|
395
|
+
HTS_ADDITIONAL_DUTY_NOTE, // ★S1 / P4
|
|
396
|
+
];
|
|
397
|
+
if (lines.some((l) => l.specialPreferential === null)) notes.push(HTS_EMPTY_SPECIAL_NOTE);
|
|
398
|
+
if (lines.some((l) => l.isChapter99)) notes.push(HTS_CHAPTER99_PRESENT_NOTE);
|
|
399
|
+
if (mapped.some((m) => m.markupStripped)) notes.push(HTS_MARKUP_NOTE);
|
|
400
|
+
// ★M2 belt — a very-large served array.
|
|
401
|
+
if (totalAvailable > HTS_MAX_ROWS) notes.push(HTS_LARGE_RESULT_NOTE);
|
|
402
|
+
|
|
403
|
+
return withMeta(
|
|
404
|
+
{ query, lines },
|
|
405
|
+
{
|
|
406
|
+
source: SOURCE,
|
|
407
|
+
keylessMode: true,
|
|
408
|
+
returned,
|
|
409
|
+
totalAvailable,
|
|
410
|
+
filtersApplied: ["query"],
|
|
411
|
+
filtersDropped: [],
|
|
412
|
+
// ★S3 — per-line empty rate fields are NOT hoisted here (rate nullity is
|
|
413
|
+
// per-line-normal per M1); this is reserved for a field genuinely absent
|
|
414
|
+
// across the WHOLE response.
|
|
415
|
+
fieldsUnavailable: [],
|
|
416
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
417
|
+
notes,
|
|
418
|
+
} satisfies Partial<ResponseMeta>,
|
|
419
|
+
);
|
|
420
|
+
}
|