@cliwant/mcp-sam-gov 1.2.0 → 1.4.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/README.ja.md +22 -9
- package/README.ko.md +22 -9
- package/README.md +70 -12
- package/dist/bea.d.ts +105 -0
- package/dist/bea.d.ts.map +1 -0
- package/dist/bea.js +303 -0
- package/dist/bea.js.map +1 -0
- package/dist/census-economic.d.ts +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +12 -6
- package/dist/census-economic.js.map +1 -1
- package/dist/cms-facility.d.ts +112 -0
- package/dist/cms-facility.d.ts.map +1 -0
- package/dist/cms-facility.js +311 -0
- package/dist/cms-facility.js.map +1 -0
- package/dist/cms-hospital.d.ts +105 -0
- package/dist/cms-hospital.d.ts.map +1 -0
- package/dist/cms-hospital.js +290 -0
- package/dist/cms-hospital.js.map +1 -0
- package/dist/cms-supplier.d.ts +133 -0
- package/dist/cms-supplier.d.ts.map +1 -0
- package/dist/cms-supplier.js +414 -0
- package/dist/cms-supplier.js.map +1 -0
- package/dist/cms-utilization.d.ts +113 -0
- package/dist/cms-utilization.d.ts.map +1 -0
- package/dist/cms-utilization.js +328 -0
- package/dist/cms-utilization.js.map +1 -0
- package/dist/courtlistener.d.ts +115 -0
- package/dist/courtlistener.d.ts.map +1 -0
- package/dist/courtlistener.js +398 -0
- package/dist/courtlistener.js.map +1 -0
- package/dist/cpsc.d.ts +81 -0
- package/dist/cpsc.d.ts.map +1 -0
- package/dist/cpsc.js +283 -0
- package/dist/cpsc.js.map +1 -0
- package/dist/dol.d.ts +118 -0
- package/dist/dol.d.ts.map +1 -0
- package/dist/dol.js +421 -0
- package/dist/dol.js.map +1 -0
- package/dist/epa-envirofacts.d.ts +97 -0
- package/dist/epa-envirofacts.d.ts.map +1 -0
- package/dist/epa-envirofacts.js +292 -0
- package/dist/epa-envirofacts.js.map +1 -0
- package/dist/fred.d.ts +1 -1
- package/dist/fred.js +1 -1
- package/dist/keys.d.ts +11 -8
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +55 -8
- package/dist/keys.js.map +1 -1
- package/dist/lda.d.ts +105 -0
- package/dist/lda.d.ts.map +1 -0
- package/dist/lda.js +317 -0
- package/dist/lda.js.map +1 -0
- package/dist/nhtsa.d.ts +91 -0
- package/dist/nhtsa.d.ts.map +1 -0
- package/dist/nhtsa.js +263 -0
- package/dist/nhtsa.js.map +1 -0
- package/dist/nonprofit.d.ts +116 -0
- package/dist/nonprofit.d.ts.map +1 -0
- package/dist/nonprofit.js +342 -0
- package/dist/nonprofit.js.map +1 -0
- package/dist/openfda-device.d.ts +85 -0
- package/dist/openfda-device.d.ts.map +1 -0
- package/dist/openfda-device.js +277 -0
- package/dist/openfda-device.js.map +1 -0
- package/dist/openfda.d.ts +133 -0
- package/dist/openfda.d.ts.map +1 -0
- package/dist/openfda.js +402 -0
- package/dist/openfda.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +872 -6
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
- package/src/bea.ts +372 -0
- package/src/census-economic.ts +12 -6
- package/src/cms-facility.ts +379 -0
- package/src/cms-hospital.ts +344 -0
- package/src/cms-supplier.ts +527 -0
- package/src/cms-utilization.ts +389 -0
- package/src/courtlistener.ts +465 -0
- package/src/cpsc.ts +333 -0
- package/src/dol.ts +515 -0
- package/src/epa-envirofacts.ts +342 -0
- package/src/fred.ts +1 -1
- package/src/keys.ts +60 -8
- package/src/lda.ts +385 -0
- package/src/nhtsa.ts +352 -0
- package/src/nonprofit.ts +460 -0
- package/src/openfda-device.ts +356 -0
- package/src/openfda.ts +495 -0
- package/src/server.ts +995 -6
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* courtlistener.ts — US FEDERAL COURT OPINIONS via CourtListener (ADR-0055) — the
|
|
3
|
+
* LITIGATION / case-law lane. Federal court decisions (opinions/clusters) — who
|
|
4
|
+
* sued whom in which court, the nature of suit, the disposition — the judicial
|
|
5
|
+
* signal no contract/spending/lobbying source carries (e.g. bid-protest and
|
|
6
|
+
* contract-claim opinions from the US Court of Federal Claims `uscfc` and the
|
|
7
|
+
* Federal Circuit `cafc`).
|
|
8
|
+
*
|
|
9
|
+
* ★ PROVENANCE — THIS IS NOT A .gov API (must be disclosed). The DATA is US federal
|
|
10
|
+
* court PUBLIC RECORDS, but the API is **CourtListener**, operated by the **Free
|
|
11
|
+
* Law Project** (a non-profit) which republishes those records KEYLESS. The .gov
|
|
12
|
+
* primary source (PACER) is PAYWALLED. So every response's `_meta.source` AND a
|
|
13
|
+
* note name CourtListener/Free Law Project and disclose the PACER-paywall — the
|
|
14
|
+
* tool never presents itself as a government API.
|
|
15
|
+
*
|
|
16
|
+
* ★ THIS IS A KEYLESS TOOL WITH AN *OPTIONAL* RATE-LIMIT TOKEN (the lda.ts /
|
|
17
|
+
* socrata app-token lineage, NOT the census/fred/bea key-REQUIRED lineage).
|
|
18
|
+
* Anonymous GETs return HTTP 200 — it works with NO token. A free
|
|
19
|
+
* COURTLISTENER_API_TOKEN only RAISES the shared rate limit; when set it rides
|
|
20
|
+
* ONLY as the `Authorization: Token <value>` request header (never the
|
|
21
|
+
* URL/label/_meta/notes/log — the K-test). When unset, NO auth header is sent
|
|
22
|
+
* (genuine keyless). This mirrors lda.ts's optional-Authorization discipline.
|
|
23
|
+
*
|
|
24
|
+
* GET https://www.courtlistener.com/api/rest/v4/search/
|
|
25
|
+
* ?q=&court=&filed_after=&filed_before=&type=o&order_by=<order>[&cursor=]
|
|
26
|
+
* → { count, next, previous, results:[{ caseName, court, court_id, dateFiled,
|
|
27
|
+
* docketNumber, suitNature, status, judge, citation, absolute_url, … }] }
|
|
28
|
+
*
|
|
29
|
+
* ★ HONESTY (ADR-0055 P1–P5):
|
|
30
|
+
* [P1] totalAvailable = `count` (the API's REAL total for the filter, e.g. the
|
|
31
|
+
* uscfc opinion corpus ~10595) — NEVER results.length. ★CURSOR pagination:
|
|
32
|
+
* `next` is a FULL URL carrying an opaque `cursor=` param (NOT page/offset).
|
|
33
|
+
* We EXTRACT the `cursor` value out of `next` and return it as `nextCursor`
|
|
34
|
+
* (offset/nextOffset null — a numeric offset is meaningless); hasMore = next
|
|
35
|
+
* is a non-null string. CourtListener v4 stops counting on deep cursor pages
|
|
36
|
+
* (`count:null`) — that is DISCLOSED (totalAvailable:null + a note), never
|
|
37
|
+
* fabricated as results.length.
|
|
38
|
+
* [P2] a genuine no-match (results:[]) ⇒ honest empty (returned:0). A 400 (bad
|
|
39
|
+
* param) ⇒ invalid_input surfacing the API's message. A 429 (unauth
|
|
40
|
+
* throttle) ⇒ rate_limited THROW (Retry-After honored, never routed around).
|
|
41
|
+
* A 5xx/timeout ⇒ upstream_unavailable THROW. A 200 non-JSON ⇒ schema_drift.
|
|
42
|
+
* [P3] dates are strings; `citation` may be an array/object ⇒ flattened to a safe
|
|
43
|
+
* string / string[] (never fabricated); judge / natureOfSuit / docketNumber
|
|
44
|
+
* are null when absent (never empty-string); every scalar null-never-empty.
|
|
45
|
+
* [P4] `results` non-array, or `count` neither a number NOR null ⇒ driftError
|
|
46
|
+
* (never a fabricated empty/total).
|
|
47
|
+
* [K-test] OPTIONAL token: when COURTLISTENER_API_TOKEN is set it rides ONLY the
|
|
48
|
+
* `Authorization: Token …` header — NEVER the URL/label/_meta/notes/log.
|
|
49
|
+
* Unset ⇒ anonymous (no auth header at all).
|
|
50
|
+
* [SSRF] fixed host `www.courtlistener.com`; a post-construction hostname/protocol
|
|
51
|
+
* assert + `redirect:"error"`; `court` charclass `^[a-z0-9]+$`; the dates
|
|
52
|
+
* charclass `^\d{4}-\d{2}-\d{2}$`; every VALUE rides URLSearchParams. The
|
|
53
|
+
* `cursor` is opaque but charclass-guarded; and when we EXTRACT the cursor
|
|
54
|
+
* from a `next` URL we RE-ASSERT that URL's host is courtlistener.com — an
|
|
55
|
+
* off-host `next` is REFUSED (schema_drift), never followed.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
59
|
+
import { getJson, driftError } from "./datasource.js";
|
|
60
|
+
import { str } from "./coerce.js";
|
|
61
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
62
|
+
|
|
63
|
+
// ─── SSRF core: the single fixed host + base path ─────────────────
|
|
64
|
+
export const COURTLISTENER_HOST = "www.courtlistener.com";
|
|
65
|
+
const COURTLISTENER_SEARCH_PATH = "/api/rest/v4/search/";
|
|
66
|
+
// HOST+path label — surfaces in ToolError.upstreamEndpoint; the optional token rides
|
|
67
|
+
// ONLY in the Authorization header, so no token can ever appear here.
|
|
68
|
+
const COURTLISTENER_SEARCH_LABEL = "courtlistener:/api/rest/v4/search";
|
|
69
|
+
|
|
70
|
+
// ─── Validation (SSRF + "verify the input" honesty) ───────────────
|
|
71
|
+
// A CourtListener court id — lowercase alphanumerics only (e.g. uscfc, cafc, scotus).
|
|
72
|
+
const COURT_RE = /^[a-z0-9]+$/;
|
|
73
|
+
// An ISO calendar date (→ filed_after / filed_before).
|
|
74
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
75
|
+
// The opaque cursor grammar (SSRF + injection guard). CourtListener's v4 cursor is a
|
|
76
|
+
// URL-safe token; we return the DECODED value (extracted via URLSearchParams), so it
|
|
77
|
+
// carries NO `%`. Reject spaces / `&` / `#` / `?` / quotes / angle-brackets / `\`
|
|
78
|
+
// BEFORE it rides `cursor=` (defense-in-depth — the value can never steer the
|
|
79
|
+
// fixed-host authority, but a malformed cursor must never reach the query).
|
|
80
|
+
export const COURTLISTENER_CURSOR_RE = /^[A-Za-z0-9\-._~=+/:]{1,8192}$/;
|
|
81
|
+
|
|
82
|
+
const DEFAULT_ORDER = "dateFiled desc";
|
|
83
|
+
|
|
84
|
+
// ─── Honesty notes (ADR-0055 required set) ────────────────────────
|
|
85
|
+
const PROVENANCE_NOTE =
|
|
86
|
+
"Data = US FEDERAL COURT PUBLIC RECORDS served by CourtListener (Free Law Project, a non-profit) — NOT a .gov API. CourtListener republishes these records keyless; the .gov primary source (PACER) is PAYWALLED. Treat opinions as of CourtListener's last ingest.";
|
|
87
|
+
const KEYLESS_NOTE =
|
|
88
|
+
"Keyless by default (anonymous CourtListener access returns HTTP 200). An optional free COURTLISTENER_API_TOKEN only RAISES the rate limit; when set it is sent ONLY as the `Authorization: Token …` request header and is NEVER logged, echoed, or placed in this response.";
|
|
89
|
+
const COUNT_TOTAL_NOTE =
|
|
90
|
+
"totalAvailable is CourtListener's real `count` — the total match count for the filter (NOT the rows on this page). Pagination is an OPAQUE cursor: pass _meta.nextCursor back as the `cursor` argument (offset/nextOffset are meaningless/null). nextCursor:null / hasMore:false means this is the last page.";
|
|
91
|
+
const DEEP_PAGE_NO_COUNT_NOTE =
|
|
92
|
+
"CourtListener did not report a `count` on this cursor page (v4 stops counting on deep pages to save cost) — totalAvailable is unknown (null), NOT results.length. Use nextCursor to continue.";
|
|
93
|
+
const NATURE_OF_SUIT_QUERY_NOTE =
|
|
94
|
+
"natureOfSuit was applied as a full-text query term (the v4 opinions search has no verified dedicated nature-of-suit filter), so it matches that text anywhere in the document rather than an exact suitNature-field equality.";
|
|
95
|
+
|
|
96
|
+
// ─── The optional-token seam (value NEVER leaked past the Authorization header) ──
|
|
97
|
+
/**
|
|
98
|
+
* The optional Authorization header (keyless-first, lda.ts/socrata app-token
|
|
99
|
+
* lineage). Present ONLY when COURTLISTENER_API_TOKEN is set (non-blank); the value
|
|
100
|
+
* is NEVER logged / never placed in the URL, label, `_meta`, or a note. When unset,
|
|
101
|
+
* `{}` (no header ⇒ genuine anonymous).
|
|
102
|
+
*/
|
|
103
|
+
export function courtlistenerAuthHeader(): Record<string, string> {
|
|
104
|
+
const raw = process.env.COURTLISTENER_API_TOKEN;
|
|
105
|
+
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
|
106
|
+
return trimmed ? { Authorization: `Token ${trimmed}` } : {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** true iff a CourtListener token is configured (for the `_meta` note — never the value). */
|
|
110
|
+
export function courtlistenerTokenPresent(): boolean {
|
|
111
|
+
const raw = process.env.COURTLISTENER_API_TOKEN;
|
|
112
|
+
return typeof raw === "string" && raw.trim().length > 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Curated opinion shape ────────────────────────────────────────
|
|
116
|
+
export type CourtlistenerOpinion = {
|
|
117
|
+
caseName: string | null;
|
|
118
|
+
court: string | null; // the court's full name (court_citation_string / court)
|
|
119
|
+
courtId: string | null; // court_id (e.g. "uscfc")
|
|
120
|
+
dateFiled: string | null; // dateFiled (a date STRING — P3, never coerced)
|
|
121
|
+
docketNumber: string | null;
|
|
122
|
+
natureOfSuit: string | null; // suitNature — null when absent (never "")
|
|
123
|
+
status: string | null;
|
|
124
|
+
judge: string | null;
|
|
125
|
+
citation: string | string[] | null; // flattened array/object/string (P3)
|
|
126
|
+
absoluteUrl: string | null; // full https://www.courtlistener.com + absolute_url
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Flatten CourtListener's `citation` (which may be a string, an array of strings,
|
|
131
|
+
* or an object) to a safe `string | string[] | null` — NEVER fabricated. An array
|
|
132
|
+
* ⇒ the non-null strings (or null when none); a string ⇒ that string; an object ⇒
|
|
133
|
+
* its non-null string VALUES (or null when none); absent ⇒ null. `[object Object]`
|
|
134
|
+
* can never leak (we never `String()` an object).
|
|
135
|
+
*/
|
|
136
|
+
export function flattenCitation(x: unknown): string | string[] | null {
|
|
137
|
+
if (x === null || x === undefined) return null;
|
|
138
|
+
if (typeof x === "string") return str(x);
|
|
139
|
+
if (Array.isArray(x)) {
|
|
140
|
+
const parts = x.map((v) => str(v)).filter((v): v is string => v !== null);
|
|
141
|
+
return parts.length === 0 ? null : parts;
|
|
142
|
+
}
|
|
143
|
+
if (typeof x === "object") {
|
|
144
|
+
const parts = Object.values(x as Record<string, unknown>)
|
|
145
|
+
.map((v) => str(v))
|
|
146
|
+
.filter((v): v is string => v !== null);
|
|
147
|
+
return parts.length === 0 ? null : parts;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Prefix a relative absolute_url with the fixed host; pass an already-absolute URL through. */
|
|
153
|
+
function resolveAbsoluteUrl(raw: unknown): string | null {
|
|
154
|
+
const path = str(raw);
|
|
155
|
+
if (path === null) return null;
|
|
156
|
+
if (/^https?:\/\//i.test(path)) return path;
|
|
157
|
+
return `https://${COURTLISTENER_HOST}${path.startsWith("/") ? path : `/${path}`}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Map ONE `results[]` opinion row → the curated shape. Every scalar via `str`. */
|
|
161
|
+
function mapOpinion(raw: unknown): CourtlistenerOpinion {
|
|
162
|
+
const o = (raw ?? {}) as Record<string, unknown>;
|
|
163
|
+
return {
|
|
164
|
+
caseName: str(o.caseName) ?? str(o.caseNameFull),
|
|
165
|
+
// court_citation_string is the short display name; fall back to the full `court`.
|
|
166
|
+
court: str(o.court_citation_string) ?? str(o.court),
|
|
167
|
+
courtId: str(o.court_id),
|
|
168
|
+
dateFiled: str(o.dateFiled),
|
|
169
|
+
docketNumber: str(o.docketNumber),
|
|
170
|
+
natureOfSuit: str(o.suitNature),
|
|
171
|
+
status: str(o.status),
|
|
172
|
+
judge: str(o.judge),
|
|
173
|
+
citation: flattenCitation(o.citation),
|
|
174
|
+
absoluteUrl: resolveAbsoluteUrl(o.absolute_url),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract the opaque `cursor` value from a CourtListener `next` URL. RE-ASSERTS the
|
|
180
|
+
* `next` URL's host is courtlistener.com — an OFF-HOST `next` is REFUSED (drift),
|
|
181
|
+
* never parsed/followed (SSRF: a poisoned `next` could otherwise steer the next
|
|
182
|
+
* page off-host). Returns the decoded cursor string, or null when `next` is null /
|
|
183
|
+
* carries no cursor. A non-string / unparseable `next` ⇒ null (treated as "no more").
|
|
184
|
+
*/
|
|
185
|
+
export function extractNextCursor(next: unknown, label: string): string | null {
|
|
186
|
+
if (next === null || next === undefined) return null;
|
|
187
|
+
if (typeof next !== "string" || next === "") return null;
|
|
188
|
+
let u: URL;
|
|
189
|
+
try {
|
|
190
|
+
u = new URL(next);
|
|
191
|
+
} catch {
|
|
192
|
+
// A malformed `next` is drift — never fabricate a continuation from it.
|
|
193
|
+
throw driftError(
|
|
194
|
+
label,
|
|
195
|
+
`CourtListener returned a malformed \`next\` URL (${JSON.stringify(next).slice(0, 120)}) — refusing to derive a cursor from it.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (u.hostname !== COURTLISTENER_HOST || u.protocol !== "https:") {
|
|
199
|
+
// An OFF-HOST next must be refused, not followed (SSRF safety).
|
|
200
|
+
throw driftError(
|
|
201
|
+
label,
|
|
202
|
+
`CourtListener \`next\` points off-host (${JSON.stringify(u.hostname)} ${u.protocol}) — refusing to follow it (SSRF safety).`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return u.searchParams.get("cursor");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Tool: courtlistener_search_opinions ──────────────────────────
|
|
209
|
+
export type CourtlistenerSearchOpinionsArgs = {
|
|
210
|
+
query?: string; // → q
|
|
211
|
+
court?: string; // a court id (e.g. uscfc|cafc|scotus); ^[a-z0-9]+$
|
|
212
|
+
dateFiledAfter?: string; // → filed_after; ^\d{4}-\d{2}-\d{2}$
|
|
213
|
+
dateFiledBefore?: string; // → filed_before; ^\d{4}-\d{2}-\d{2}$
|
|
214
|
+
natureOfSuit?: string; // folded into q (no verified dedicated filter)
|
|
215
|
+
cursor?: string; // opaque passthrough for the next page
|
|
216
|
+
order?: string; // default "dateFiled desc"
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Search US federal court opinions via CourtListener (`/api/rest/v4/search/`,
|
|
221
|
+
* type=o) → curated opinion rows + honest `_meta`. KEYLESS (an optional
|
|
222
|
+
* COURTLISTENER_API_TOKEN only raises the rate limit, sent as the Authorization
|
|
223
|
+
* header only). ★PROVENANCE: this is CourtListener/Free Law Project (a non-profit),
|
|
224
|
+
* NOT a .gov API — PACER (the .gov source) is paywalled. totalAvailable is the
|
|
225
|
+
* API's REAL `count`; CURSOR pagination (nextCursor extracted from `next`).
|
|
226
|
+
*/
|
|
227
|
+
export async function searchOpinions(
|
|
228
|
+
args: CourtlistenerSearchOpinionsArgs,
|
|
229
|
+
): Promise<MetaBundle> {
|
|
230
|
+
const label = COURTLISTENER_SEARCH_LABEL;
|
|
231
|
+
|
|
232
|
+
// ── Validate + default (belt-and-suspenders behind the server Zod; a DIRECT
|
|
233
|
+
// handler call bypasses Zod). court / dates are charclass-guarded; the cursor
|
|
234
|
+
// grammar-guarded; the free-text values ride URLSearchParams (encoded). ──
|
|
235
|
+
if (args.court !== undefined && !COURT_RE.test(args.court)) {
|
|
236
|
+
throw new ToolErrorCarrier({
|
|
237
|
+
kind: "invalid_input",
|
|
238
|
+
retryable: false,
|
|
239
|
+
message: `Invalid court ${JSON.stringify(args.court)} — expected a CourtListener court id (lowercase alphanumerics, ^[a-z0-9]+$), e.g. "uscfc", "cafc", "scotus".`,
|
|
240
|
+
upstreamEndpoint: label,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (
|
|
244
|
+
args.dateFiledAfter !== undefined &&
|
|
245
|
+
!DATE_RE.test(args.dateFiledAfter)
|
|
246
|
+
) {
|
|
247
|
+
throw new ToolErrorCarrier({
|
|
248
|
+
kind: "invalid_input",
|
|
249
|
+
retryable: false,
|
|
250
|
+
message: `Invalid dateFiledAfter ${JSON.stringify(args.dateFiledAfter)} — expected an ISO date (^\\d{4}-\\d{2}-\\d{2}$), e.g. "2020-01-01".`,
|
|
251
|
+
upstreamEndpoint: label,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
args.dateFiledBefore !== undefined &&
|
|
256
|
+
!DATE_RE.test(args.dateFiledBefore)
|
|
257
|
+
) {
|
|
258
|
+
throw new ToolErrorCarrier({
|
|
259
|
+
kind: "invalid_input",
|
|
260
|
+
retryable: false,
|
|
261
|
+
message: `Invalid dateFiledBefore ${JSON.stringify(args.dateFiledBefore)} — expected an ISO date (^\\d{4}-\\d{2}-\\d{2}$), e.g. "2024-12-31".`,
|
|
262
|
+
upstreamEndpoint: label,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (
|
|
266
|
+
args.cursor !== undefined &&
|
|
267
|
+
!COURTLISTENER_CURSOR_RE.test(args.cursor)
|
|
268
|
+
) {
|
|
269
|
+
throw new ToolErrorCarrier({
|
|
270
|
+
kind: "invalid_input",
|
|
271
|
+
retryable: false,
|
|
272
|
+
message: `Invalid cursor (opaque continuation token) — must be a ≤8192-char URL-safe token (no spaces, '%', or steering characters). Pass back the _meta.nextCursor from the previous page.`,
|
|
273
|
+
upstreamEndpoint: label,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Build the query from VALIDATED typed args, key-by-key (SSRF: no raw
|
|
278
|
+
// passthrough; every VALUE is URLSearchParams-encoded). type=o is FIXED. ──
|
|
279
|
+
const params = new URLSearchParams();
|
|
280
|
+
const filtersApplied: string[] = [];
|
|
281
|
+
// natureOfSuit has no verified dedicated filter on the v4 opinions search, so it
|
|
282
|
+
// is folded into the `q` full-text query (disclosed via NATURE_OF_SUIT_QUERY_NOTE).
|
|
283
|
+
const qParts: string[] = [];
|
|
284
|
+
if (args.query !== undefined && args.query !== "") {
|
|
285
|
+
qParts.push(args.query);
|
|
286
|
+
filtersApplied.push("query");
|
|
287
|
+
}
|
|
288
|
+
let natureOfSuitApplied = false;
|
|
289
|
+
if (args.natureOfSuit !== undefined && args.natureOfSuit !== "") {
|
|
290
|
+
qParts.push(args.natureOfSuit);
|
|
291
|
+
filtersApplied.push("natureOfSuit");
|
|
292
|
+
natureOfSuitApplied = true;
|
|
293
|
+
}
|
|
294
|
+
if (qParts.length > 0) params.set("q", qParts.join(" "));
|
|
295
|
+
if (args.court !== undefined) {
|
|
296
|
+
params.set("court", args.court);
|
|
297
|
+
filtersApplied.push("court");
|
|
298
|
+
}
|
|
299
|
+
if (args.dateFiledAfter !== undefined) {
|
|
300
|
+
params.set("filed_after", args.dateFiledAfter);
|
|
301
|
+
filtersApplied.push("dateFiledAfter");
|
|
302
|
+
}
|
|
303
|
+
if (args.dateFiledBefore !== undefined) {
|
|
304
|
+
params.set("filed_before", args.dateFiledBefore);
|
|
305
|
+
filtersApplied.push("dateFiledBefore");
|
|
306
|
+
}
|
|
307
|
+
params.set("type", "o"); // FIXED — opinions only
|
|
308
|
+
params.set("order_by", args.order && args.order !== "" ? args.order : DEFAULT_ORDER);
|
|
309
|
+
if (args.cursor !== undefined) {
|
|
310
|
+
params.set("cursor", args.cursor);
|
|
311
|
+
filtersApplied.push("cursor");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const url = `https://${COURTLISTENER_HOST}${COURTLISTENER_SEARCH_PATH}?${params.toString()}`;
|
|
315
|
+
// Belt-and-suspenders: the fixed host + strictly-built query leave nothing to
|
|
316
|
+
// steer the authority; assert the built URL cannot have been moved off-host.
|
|
317
|
+
const built = new URL(url);
|
|
318
|
+
if (built.hostname !== COURTLISTENER_HOST || built.protocol !== "https:") {
|
|
319
|
+
throw new ToolErrorCarrier({
|
|
320
|
+
kind: "invalid_input",
|
|
321
|
+
retryable: false,
|
|
322
|
+
message: `Constructed CourtListener URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${COURTLISTENER_HOST} over https — refusing to fetch (SSRF safety).`,
|
|
323
|
+
upstreamEndpoint: label,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Fetch through the shared envelope. The optional token rides the Authorization
|
|
328
|
+
// header ONLY (never the URL/label/_meta); redirect:"error" fails closed on any
|
|
329
|
+
// off-host 3xx. A 429 ⇒ rate_limited THROW (Retry-After honored by the shared
|
|
330
|
+
// taxonomy, never routed around); a 5xx/timeout ⇒ upstream_unavailable THROW; a
|
|
331
|
+
// 400 ⇒ invalid_input (re-read below to surface the API message); a 200 non-JSON
|
|
332
|
+
// ⇒ getJson's r.json() throws a SyntaxError ⇒ schema_drift. ──
|
|
333
|
+
const headers = courtlistenerAuthHeader();
|
|
334
|
+
let body: unknown;
|
|
335
|
+
try {
|
|
336
|
+
body = await getJson<unknown>(url, {
|
|
337
|
+
label,
|
|
338
|
+
headers,
|
|
339
|
+
redirect: "error",
|
|
340
|
+
});
|
|
341
|
+
} catch (e) {
|
|
342
|
+
if (e instanceof SyntaxError) {
|
|
343
|
+
throw driftError(
|
|
344
|
+
label,
|
|
345
|
+
"CourtListener /api/rest/v4/search returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
// [P2] A 400 (bad param) carries a DRF error body; fetchWithRetry discarded it.
|
|
349
|
+
// Re-read once on the error path ONLY so the caller learns the REAL reason (never
|
|
350
|
+
// a fake-empty). 5xx/429/404/timeout keep their taxonomy.
|
|
351
|
+
if (e instanceof ToolErrorCarrier && e.toolError.upstreamStatus === 400) {
|
|
352
|
+
const apiMsg = await readCourtlistenerErrorMessage(url, headers);
|
|
353
|
+
throw new ToolErrorCarrier({
|
|
354
|
+
kind: "invalid_input",
|
|
355
|
+
retryable: false,
|
|
356
|
+
message: apiMsg
|
|
357
|
+
? `CourtListener rejected the request (HTTP 400): ${apiMsg}. Check the filter parameters (court, dateFiledAfter/Before, order, cursor).`
|
|
358
|
+
: "CourtListener rejected the request (HTTP 400) — check the filter parameters (court, dateFiledAfter/Before, order, cursor).",
|
|
359
|
+
upstreamStatus: 400,
|
|
360
|
+
upstreamEndpoint: label,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
throw e; // 5xx → upstream_unavailable, 404 → not_found, 429 → rate_limited …
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── [P4] `results` MUST be an array; `count` MUST be a number OR null (v4 returns
|
|
367
|
+
// count:null on deep cursor pages — a documented behavior, NOT drift). A
|
|
368
|
+
// non-array results, or a count that is neither a number nor null (e.g. a
|
|
369
|
+
// string/object), is drift — never a fabricated empty/total. ──
|
|
370
|
+
const b = (body ?? {}) as { results?: unknown; count?: unknown; next?: unknown };
|
|
371
|
+
if (!Array.isArray(b.results)) {
|
|
372
|
+
throw driftError(
|
|
373
|
+
label,
|
|
374
|
+
"CourtListener /api/rest/v4/search shape drift — `results` must be an array.",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const rawCount = b.count;
|
|
378
|
+
const countIsNumber = typeof rawCount === "number" && Number.isFinite(rawCount);
|
|
379
|
+
if (!countIsNumber && rawCount !== null && rawCount !== undefined) {
|
|
380
|
+
throw driftError(
|
|
381
|
+
label,
|
|
382
|
+
"CourtListener /api/rest/v4/search shape drift — `count` must be the total match count (a number) or null (deep cursor page).",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const opinions = (b.results as unknown[]).map(mapOpinion);
|
|
387
|
+
const returned = opinions.length;
|
|
388
|
+
|
|
389
|
+
// ── [P1] totalAvailable is the API's REAL count, NEVER results.length. count:null
|
|
390
|
+
// (deep cursor page) ⇒ totalAvailable:null + a disclosure note. ★CURSOR
|
|
391
|
+
// pagination: hasMore from `next` presence; nextCursor EXTRACTED from the `next`
|
|
392
|
+
// URL (host re-asserted). ──
|
|
393
|
+
const totalAvailable = countIsNumber ? (rawCount as number) : null;
|
|
394
|
+
const nextCursor = extractNextCursor(b.next, label);
|
|
395
|
+
const hasMore = nextCursor !== null;
|
|
396
|
+
|
|
397
|
+
const notes: string[] = [PROVENANCE_NOTE, COUNT_TOTAL_NOTE];
|
|
398
|
+
if (!countIsNumber) notes.push(DEEP_PAGE_NO_COUNT_NOTE);
|
|
399
|
+
if (natureOfSuitApplied) notes.push(NATURE_OF_SUIT_QUERY_NOTE);
|
|
400
|
+
notes.push(KEYLESS_NOTE);
|
|
401
|
+
notes.push(
|
|
402
|
+
`CourtListener token: ${courtlistenerTokenPresent() ? "present (Authorization: Token … sent; value never logged)" : "absent (keyless; a free COURTLISTENER_API_TOKEN lifts the rate limit)"}.`,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
return withMeta(
|
|
406
|
+
{ opinions },
|
|
407
|
+
{
|
|
408
|
+
source: `${COURTLISTENER_HOST} /api/rest/v4/search (CourtListener (Free Law Project) — US federal court records; PACER (.gov) is paywalled; keyless)`,
|
|
409
|
+
keylessMode: true, // ★KEYLESS — the optional token only raises the rate limit
|
|
410
|
+
returned,
|
|
411
|
+
totalAvailable,
|
|
412
|
+
filtersApplied,
|
|
413
|
+
filtersDropped: [],
|
|
414
|
+
fieldsUnavailable: [],
|
|
415
|
+
// Cursor page: offset/nextOffset null (no numeric offset); continuation is
|
|
416
|
+
// nextCursor (the opaque token extracted from `next`, passed back as `cursor`).
|
|
417
|
+
pagination: { offset: null, limit: returned, hasMore, nextOffset: null },
|
|
418
|
+
nextCursor,
|
|
419
|
+
notes,
|
|
420
|
+
} satisfies Partial<ResponseMeta>,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Single bare GET to read a CourtListener 400's DRF error body (error path ONLY).
|
|
426
|
+
* Returns a compact human-readable message, or null on any failure. Sends the SAME
|
|
427
|
+
* headers (so a keyed re-read honors the token) + redirect:"error"; the token stays
|
|
428
|
+
* header-only.
|
|
429
|
+
*/
|
|
430
|
+
async function readCourtlistenerErrorMessage(
|
|
431
|
+
url: string,
|
|
432
|
+
headers: Record<string, string>,
|
|
433
|
+
): Promise<string | null> {
|
|
434
|
+
try {
|
|
435
|
+
const r = await fetch(url, {
|
|
436
|
+
signal: AbortSignal.timeout(15_000),
|
|
437
|
+
headers,
|
|
438
|
+
redirect: "error",
|
|
439
|
+
});
|
|
440
|
+
const body = (await r.json()) as unknown;
|
|
441
|
+
return summarizeDrfError(body);
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Summarize a Django-REST-Framework 400 error body into a compact string. DRF emits
|
|
449
|
+
* `{ detail: "…" }` OR `{ field: ["message", …], … }`. Returns null for a shape we
|
|
450
|
+
* can't read (⇒ the caller falls back to the generic 400 message).
|
|
451
|
+
*/
|
|
452
|
+
function summarizeDrfError(body: unknown): string | null {
|
|
453
|
+
if (typeof body === "string") return str(body);
|
|
454
|
+
if (body === null || typeof body !== "object") return null;
|
|
455
|
+
const obj = body as Record<string, unknown>;
|
|
456
|
+
if (typeof obj.detail === "string") return str(obj.detail);
|
|
457
|
+
const parts: string[] = [];
|
|
458
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
459
|
+
const msg = Array.isArray(v)
|
|
460
|
+
? v.map((x) => str(x)).filter((x): x is string => x !== null).join("; ")
|
|
461
|
+
: str(v);
|
|
462
|
+
if (msg) parts.push(`${k}: ${msg}`);
|
|
463
|
+
}
|
|
464
|
+
return parts.length > 0 ? parts.join(" | ") : null;
|
|
465
|
+
}
|