@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/federal-register.ts
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* Rate-limit: documented as ~1000 req/hour per IP (informal).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { fetchWithRetry } from "./errors.js";
|
|
20
|
+
import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
|
|
21
21
|
import { memoize } from "./cache.js";
|
|
22
|
+
import { withMeta } from "./meta.js";
|
|
23
|
+
import { num, str } from "./coerce.js";
|
|
22
24
|
|
|
23
25
|
const FED_REG = "https://www.federalregister.gov/api/v1";
|
|
24
26
|
|
|
@@ -103,9 +105,40 @@ export async function searchDocuments(args: {
|
|
|
103
105
|
}[];
|
|
104
106
|
};
|
|
105
107
|
const json = await fetchJson<Resp>(url.toString());
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
// The Federal Register API HARD-CAPS `count` at 10,000 (50 pages × 200) —
|
|
109
|
+
// LIVE-VERIFIED 2026-07-06: an empty/nonsense term → count 0, but ANY broad term
|
|
110
|
+
// AND the no-term "all documents ever" query both return exactly 10,000 (the FR
|
|
111
|
+
// has published FAR more than 10k documents since 1994). So a count of 10,000 is
|
|
112
|
+
// a SATURATION FLOOR ("≥10,000"), NOT an exact total — reporting it as exact
|
|
113
|
+
// overstates precision and understates the true count.
|
|
114
|
+
const FR_COUNT_CAP = 10000;
|
|
115
|
+
// COUNT-DRIFT GUARD (defense-in-depth). The FR API's `count` is the match total
|
|
116
|
+
// (a JSON NUMBER). A 200 body that LACKS `count` (or carries a non-number) is
|
|
117
|
+
// SCHEMA DRIFT — the old `?? 0` coerced it to 0, fabricating an honest-empty
|
|
118
|
+
// (totalAvailable:0, truncated:false) on a drift body (a latent P2 violation).
|
|
119
|
+
// THROW schema_drift instead — mirrors this file's assertInspectionEnvelope
|
|
120
|
+
// (C132) and the W3-2 cms/nppes/regulations siblings (which THROW on a non-number
|
|
121
|
+
// count). A GENUINE `count:0` (a real, finite number) is NOT drift — it flows
|
|
122
|
+
// through untouched as an honest empty below.
|
|
123
|
+
if (typeof json.count !== "number" || !Number.isFinite(json.count)) {
|
|
124
|
+
throw new ToolErrorCarrier({
|
|
125
|
+
kind: "schema_drift",
|
|
126
|
+
message: `Federal Register documents.json returned HTTP 200 without a numeric \`count\` (got ${JSON.stringify(json.count)}) — the match-total field is missing/malformed; refusing rather than fabricating totalAvailable:0 on a drift body.`,
|
|
127
|
+
retryable: false,
|
|
128
|
+
upstreamEndpoint: "federal-register:documents.json",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const rawCount = json.count;
|
|
132
|
+
const countSaturated = rawCount >= FR_COUNT_CAP;
|
|
133
|
+
const data = {
|
|
134
|
+
totalRecords: rawCount,
|
|
135
|
+
// true ⇒ totalRecords is a FLOOR (≥10,000), not an exact count (API cap).
|
|
136
|
+
totalRecordsSaturated: countSaturated,
|
|
108
137
|
totalPages: json.total_pages ?? 0,
|
|
138
|
+
// total_pages ALSO saturates at the API cap (50) for the same broad queries —
|
|
139
|
+
// flag it so a consumer never estimates the true dataset size from a capped
|
|
140
|
+
// page count either.
|
|
141
|
+
totalPagesSaturated: countSaturated,
|
|
109
142
|
documents: (json.results ?? []).map((d) => ({
|
|
110
143
|
documentNumber: d.document_number ?? "",
|
|
111
144
|
title: d.title ?? "",
|
|
@@ -122,6 +155,51 @@ export async function searchDocuments(args: {
|
|
|
122
155
|
})),
|
|
123
156
|
})),
|
|
124
157
|
};
|
|
158
|
+
|
|
159
|
+
// Truthful `_meta` (spec §1.2 A5, §2.3). The Federal Register API reports a
|
|
160
|
+
// match total (`count`) — REAL below the 10,000 cap, a saturation FLOOR at it
|
|
161
|
+
// (handled below). A5: an unknown/misspelled agency slug is silently ignored by
|
|
162
|
+
// the API and yields zero rows that look identical to "no matching rules" —
|
|
163
|
+
// call that out so the AI can re-check the slug against fed_register_list_agencies
|
|
164
|
+
// instead of concluding no such rule exists.
|
|
165
|
+
const returned = data.documents.length;
|
|
166
|
+
const notes: string[] = [];
|
|
167
|
+
if (countSaturated) {
|
|
168
|
+
notes.push(
|
|
169
|
+
`The Federal Register API caps its match count at ${FR_COUNT_CAP.toLocaleString()} (and total_pages at 50) — both are FLOORS, not exact totals: this query matches AT LEAST ${FR_COUNT_CAP.toLocaleString()} documents (the true total is unknown and likely higher). totalAvailable is null (not ${FR_COUNT_CAP.toLocaleString()}); narrow with agency/type/publicationDate filters to bring the result set BELOW ${FR_COUNT_CAP.toLocaleString()} for an exact count.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if ((args.agencySlugs?.length ?? 0) > 0) {
|
|
173
|
+
notes.push(
|
|
174
|
+
`Filtered by agency slug(s): ${args.agencySlugs!.join(", ")}. An unknown or misspelled slug is silently ignored by the API and yields zero results indistinguishable from "no matching documents" — verify slugs via fed_register_list_agencies if the result is unexpectedly empty.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
// D4 (no-silent-filter): populate filtersApplied from the filters actually
|
|
178
|
+
// FORWARDED to the API (each was appended to the request above) — never a
|
|
179
|
+
// hard-coded [] that lies about which facets constrained the result. Only a
|
|
180
|
+
// filter that was truly sent is listed (an empty agencySlugs[] forwards nothing);
|
|
181
|
+
// no filter here is dropped (all supplied filters are forwarded), so
|
|
182
|
+
// filtersDropped stays [].
|
|
183
|
+
const filtersApplied: string[] = [];
|
|
184
|
+
if (args.query) filtersApplied.push("query");
|
|
185
|
+
if ((args.agencySlugs?.length ?? 0) > 0) filtersApplied.push("agencySlugs");
|
|
186
|
+
if (args.type) filtersApplied.push("type");
|
|
187
|
+
if (args.publicationDateFrom) filtersApplied.push("publicationDateFrom");
|
|
188
|
+
if (args.publicationDateTo) filtersApplied.push("publicationDateTo");
|
|
189
|
+
if (args.effectiveDateFrom) filtersApplied.push("effectiveDateFrom");
|
|
190
|
+
return withMeta(data, {
|
|
191
|
+
source: "federalregister.gov/api/v1",
|
|
192
|
+
keylessMode: true,
|
|
193
|
+
returned,
|
|
194
|
+
// The REAL total when known; null (unknown exact) when the count saturated at
|
|
195
|
+
// the FR cap — never a capped number presented as the real total.
|
|
196
|
+
totalAvailable: countSaturated ? null : rawCount,
|
|
197
|
+
truncated: countSaturated ? true : returned < rawCount,
|
|
198
|
+
filtersApplied,
|
|
199
|
+
filtersDropped: [],
|
|
200
|
+
fieldsUnavailable: [],
|
|
201
|
+
notes,
|
|
202
|
+
});
|
|
125
203
|
}
|
|
126
204
|
|
|
127
205
|
export async function getDocument(documentNumber: string) {
|
|
@@ -195,3 +273,434 @@ export async function listAgencies(args: { perPage?: number }) {
|
|
|
195
273
|
};
|
|
196
274
|
});
|
|
197
275
|
}
|
|
276
|
+
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
278
|
+
// Public Inspection desk — the "filed but NOT YET published" leading indicator
|
|
279
|
+
// (ADR-0043). Additive: everything below is NEW; `fetchJson`/`TYPE_MAP`/the three
|
|
280
|
+
// existing tools above are UNTOUCHED. Documents FILED with the Office of the
|
|
281
|
+
// Federal Register are on PUBLIC INSPECTION hours-to-days BEFORE their official
|
|
282
|
+
// `publication_date` — the earliest legal signal of an upcoming rule/notice.
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
const PI_BASE = `${FED_REG}/public-inspection-documents`;
|
|
286
|
+
const PI_HOST = "www.federalregister.gov";
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The mandatory pre-publication caveat — rides `_meta.notes` on EVERY response.
|
|
290
|
+
* A public-inspection doc is FILED, not PUBLISHED: no final FR citation/page yet,
|
|
291
|
+
* and it may CHANGE or be WITHDRAWN before publication. Load-bearing honesty:
|
|
292
|
+
* presenting a PI doc as the authoritative published rule is the forbidden lie.
|
|
293
|
+
*/
|
|
294
|
+
export const PRE_PUBLICATION_CAVEAT =
|
|
295
|
+
"These are PRE-PUBLICATION documents on PUBLIC INSPECTION — filed with the Office of the Federal Register but NOT YET published. This is a LEADING INDICATOR, not the authoritative published rule/notice: there is NO final Federal Register citation or page number yet, and the content CAN CHANGE or be WITHDRAWN before its publication_date. After the publication_date, cross-check fed_register_get_document (by document_number) for the authoritative published version.";
|
|
296
|
+
|
|
297
|
+
/** Discloses how `leadDays` is derived (null-never-0). */
|
|
298
|
+
export const LEADDAYS_METHOD_NOTE =
|
|
299
|
+
"leadDays = publication_date minus the calendar date of filed_at, in whole days (the pre-publication head-start); null when either date is missing/unparseable, a genuine same-day filing is 0, a negative value (publication before filing) is a surfaced-verbatim anomaly.";
|
|
300
|
+
|
|
301
|
+
/** Discloses the special-vs-regular distinction (never conflated). */
|
|
302
|
+
export const SPECIAL_REGULAR_NOTE =
|
|
303
|
+
"filing_type 'special' = filed OFF-CYCLE for immediate/emergency public inspection (a stronger, SOONER signal — often same/next-day publication); 'regular' = filed for the next regular business-day inspection. Surfaced verbatim; the two are not conflated.";
|
|
304
|
+
|
|
305
|
+
export type FedRegPublicInspectionMode = "current" | "date" | "search";
|
|
306
|
+
|
|
307
|
+
export type FedRegPublicInspectionInput = {
|
|
308
|
+
mode?: FedRegPublicInspectionMode;
|
|
309
|
+
date?: string;
|
|
310
|
+
term?: string;
|
|
311
|
+
type?: "RULE" | "PRORULE" | "NOTICE" | "PRESDOCU";
|
|
312
|
+
agency?: string;
|
|
313
|
+
specialOnly?: boolean;
|
|
314
|
+
limit?: number;
|
|
315
|
+
offset?: number;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
type RawAgency = {
|
|
319
|
+
raw_name?: string;
|
|
320
|
+
name?: string;
|
|
321
|
+
id?: number;
|
|
322
|
+
url?: string;
|
|
323
|
+
json_url?: string;
|
|
324
|
+
parent_id?: number | null;
|
|
325
|
+
slug?: string;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
type RawInspectionRow = {
|
|
329
|
+
document_number?: string;
|
|
330
|
+
type?: string;
|
|
331
|
+
title?: string;
|
|
332
|
+
filed_at?: string;
|
|
333
|
+
publication_date?: string;
|
|
334
|
+
filing_type?: string;
|
|
335
|
+
agencies?: RawAgency[];
|
|
336
|
+
agency_names?: string[];
|
|
337
|
+
docket_numbers?: string[];
|
|
338
|
+
html_url?: string;
|
|
339
|
+
pdf_url?: string;
|
|
340
|
+
raw_text_url?: string;
|
|
341
|
+
json_url?: string;
|
|
342
|
+
num_pages?: number;
|
|
343
|
+
subject_1?: string;
|
|
344
|
+
subject_2?: string;
|
|
345
|
+
subject_3?: string;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
type InspectionEnvelope = {
|
|
349
|
+
count: number;
|
|
350
|
+
results: RawInspectionRow[];
|
|
351
|
+
special_filings_updated_at?: string;
|
|
352
|
+
regular_filings_updated_at?: string;
|
|
353
|
+
total_pages?: number;
|
|
354
|
+
next_page_url?: string;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const PI_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `leadDays` = publication_date − the CALENDAR DATE of filed_at, in whole days.
|
|
361
|
+
* tz-immune (compares the sliced YYYY-MM-DD strings — both on FR's Eastern basis,
|
|
362
|
+
* so no -04:00/-05:00 DST off-by-one). NULL-NEVER-0: null when either date is
|
|
363
|
+
* missing/unparseable; a genuine same-day filing survives as 0; a negative
|
|
364
|
+
* (publication before filing — a data anomaly) is surfaced VERBATIM, never
|
|
365
|
+
* clamped or nulled.
|
|
366
|
+
*/
|
|
367
|
+
export function computeLeadDays(
|
|
368
|
+
filedAt: string | null | undefined,
|
|
369
|
+
publicationDate: string | null | undefined,
|
|
370
|
+
): number | null {
|
|
371
|
+
if (
|
|
372
|
+
filedAt === null ||
|
|
373
|
+
filedAt === undefined ||
|
|
374
|
+
publicationDate === null ||
|
|
375
|
+
publicationDate === undefined
|
|
376
|
+
) {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
const filedDate = String(filedAt).slice(0, 10);
|
|
380
|
+
const pubDate = String(publicationDate).slice(0, 10);
|
|
381
|
+
if (!PI_DATE_RE.test(filedDate) || !PI_DATE_RE.test(pubDate)) return null;
|
|
382
|
+
const fp = filedDate.split("-");
|
|
383
|
+
const pp = pubDate.split("-");
|
|
384
|
+
const ms =
|
|
385
|
+
Date.UTC(Number(pp[0]), Number(pp[1]) - 1, Number(pp[2])) -
|
|
386
|
+
Date.UTC(Number(fp[0]), Number(fp[1]) - 1, Number(fp[2]));
|
|
387
|
+
return Math.round(ms / 86_400_000);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* SSRF host re-assertion (defense-in-depth; ADR-0043 §Q7). The only caller
|
|
392
|
+
* values that reach the wire are `date` (→ conditions[available_on]) and `term`
|
|
393
|
+
* (→ conditions[term]), both URLSearchParams QUERY params on the fixed host — no
|
|
394
|
+
* caller-controlled PATH segment. Re-assert the constructed URL is https on
|
|
395
|
+
* www.federalregister.gov; anything else → invalid_input (never fetched).
|
|
396
|
+
*/
|
|
397
|
+
export function assertFedRegHost(urlStr: string): void {
|
|
398
|
+
let u: URL;
|
|
399
|
+
try {
|
|
400
|
+
u = new URL(urlStr);
|
|
401
|
+
} catch {
|
|
402
|
+
throw new ToolErrorCarrier({
|
|
403
|
+
kind: "invalid_input",
|
|
404
|
+
message: `Refusing to fetch a non-URL public-inspection target.`,
|
|
405
|
+
retryable: false,
|
|
406
|
+
upstreamEndpoint: "federal-register:public-inspection",
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (u.protocol !== "https:" || u.hostname !== PI_HOST) {
|
|
410
|
+
throw new ToolErrorCarrier({
|
|
411
|
+
kind: "invalid_input",
|
|
412
|
+
message: `Refusing to fetch a non-${PI_HOST} public-inspection URL (${u.protocol}//${u.hostname}).`,
|
|
413
|
+
retryable: false,
|
|
414
|
+
upstreamEndpoint: "federal-register:public-inspection",
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Build the wire URL per mode. ★The date rides `conditions[available_on]` (a
|
|
421
|
+
* QUERY param), NEVER a `/{date}.json` path segment (fact 6: that path silently
|
|
422
|
+
* returns a WRONG unrelated doc). `term` rides `conditions[term]`. Both via
|
|
423
|
+
* URLSearchParams. `new URL` + host re-assert closes host-escape/downgrade.
|
|
424
|
+
*/
|
|
425
|
+
export function buildPublicInspectionUrl(
|
|
426
|
+
mode: FedRegPublicInspectionMode,
|
|
427
|
+
opts: { date?: string; term?: string } = {},
|
|
428
|
+
): string {
|
|
429
|
+
let url: URL;
|
|
430
|
+
if (mode === "current") {
|
|
431
|
+
url = new URL(`${PI_BASE}/current.json`);
|
|
432
|
+
} else if (mode === "date") {
|
|
433
|
+
url = new URL(`${PI_BASE}.json`);
|
|
434
|
+
url.searchParams.set("conditions[available_on]", opts.date ?? "");
|
|
435
|
+
} else {
|
|
436
|
+
url = new URL(`${PI_BASE}.json`);
|
|
437
|
+
if (opts.term) url.searchParams.set("conditions[term]", opts.term);
|
|
438
|
+
// per_page ≫ any observed on-inspection count (live ≤95) → one page.
|
|
439
|
+
url.searchParams.set("per_page", "1000");
|
|
440
|
+
}
|
|
441
|
+
const built = url.toString();
|
|
442
|
+
assertFedRegHost(built);
|
|
443
|
+
return built;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Envelope-shape guard (M2). The list envelopes (`current`/`date`/`search`) are
|
|
448
|
+
* `{count:number, results:array, …}`. `fetchJson` casts `as T` without checking,
|
|
449
|
+
* so a 200 body of the wrong shape would map to garbage. Throw a `schema_drift`
|
|
450
|
+
* ToolErrorCarrier (NOT a plain Error — `toToolError` has no branch for a bare
|
|
451
|
+
* Error, so it would degrade to `unknown`). The two `*_filings_updated_at` stamps
|
|
452
|
+
* are OPTIONAL (S4: a zero-result day omits them) and are NOT asserted here.
|
|
453
|
+
*/
|
|
454
|
+
export function assertInspectionEnvelope(
|
|
455
|
+
json: unknown,
|
|
456
|
+
endpoint: string,
|
|
457
|
+
): asserts json is InspectionEnvelope {
|
|
458
|
+
const j = json as { count?: unknown; results?: unknown } | null;
|
|
459
|
+
if (
|
|
460
|
+
json === null ||
|
|
461
|
+
typeof json !== "object" ||
|
|
462
|
+
typeof j?.count !== "number" ||
|
|
463
|
+
!Array.isArray(j?.results)
|
|
464
|
+
) {
|
|
465
|
+
throw new ToolErrorCarrier({
|
|
466
|
+
kind: "schema_drift",
|
|
467
|
+
message: `Federal Register public-inspection envelope drift at ${endpoint}: expected {count:number, results:array}.`,
|
|
468
|
+
retryable: false,
|
|
469
|
+
upstreamEndpoint: endpoint,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function mapInspectionRow(r: RawInspectionRow) {
|
|
475
|
+
const typeVerbatim = str(r.type);
|
|
476
|
+
return {
|
|
477
|
+
documentNumber: str(r.document_number),
|
|
478
|
+
type: typeVerbatim,
|
|
479
|
+
typeCode: TYPE_MAP[r.type ?? ""] ?? "UNKNOWN",
|
|
480
|
+
title: str(r.title),
|
|
481
|
+
filedAt: str(r.filed_at),
|
|
482
|
+
publicationDate: str(r.publication_date),
|
|
483
|
+
// null-never-0; same-day survives as 0; negative anomaly verbatim.
|
|
484
|
+
leadDays: computeLeadDays(r.filed_at, r.publication_date),
|
|
485
|
+
filingType: str(r.filing_type),
|
|
486
|
+
isSpecialFiling: r.filing_type === "special",
|
|
487
|
+
// agencies[] surfaced UNFLATTENED — a doc can name multiple agencies.
|
|
488
|
+
agencies: (Array.isArray(r.agencies) ? r.agencies : []).map((a) => ({
|
|
489
|
+
rawName: str(a.raw_name),
|
|
490
|
+
name: str(a.name),
|
|
491
|
+
id: num(a.id),
|
|
492
|
+
slug: str(a.slug),
|
|
493
|
+
url: str(a.url),
|
|
494
|
+
jsonUrl: str(a.json_url),
|
|
495
|
+
parentId: num(a.parent_id),
|
|
496
|
+
})),
|
|
497
|
+
htmlUrl: str(r.html_url),
|
|
498
|
+
pdfUrl: str(r.pdf_url),
|
|
499
|
+
rawTextUrl: str(r.raw_text_url),
|
|
500
|
+
docketNumbers: (Array.isArray(r.docket_numbers) ? r.docket_numbers : [])
|
|
501
|
+
.map((s) => str(s))
|
|
502
|
+
.filter((s): s is string => s !== null),
|
|
503
|
+
numPages: num(r.num_pages),
|
|
504
|
+
subjects: [r.subject_1, r.subject_2, r.subject_3]
|
|
505
|
+
.map((s) => str(s))
|
|
506
|
+
.filter((s): s is string => s !== null),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* `fed_register_public_inspection` — the pre-publication leading indicator.
|
|
512
|
+
* mode {current, date, search}; fetch-once + client-side window; every response
|
|
513
|
+
* carries the pre-publication caveat + the leadDays/special-vs-regular notes.
|
|
514
|
+
*/
|
|
515
|
+
export async function publicInspection(input: FedRegPublicInspectionInput) {
|
|
516
|
+
const mode: FedRegPublicInspectionMode = input.mode ?? "current";
|
|
517
|
+
const limit = input.limit ?? 20;
|
|
518
|
+
const offset = input.offset ?? 0;
|
|
519
|
+
|
|
520
|
+
// `term` is only meaningful in search mode — never silently full-text a
|
|
521
|
+
// non-search mode.
|
|
522
|
+
if (input.term && mode !== "search") {
|
|
523
|
+
throw new ToolErrorCarrier({
|
|
524
|
+
kind: "invalid_input",
|
|
525
|
+
message: `\`term\` is only valid in mode='search' (got mode='${mode}').`,
|
|
526
|
+
retryable: false,
|
|
527
|
+
upstreamEndpoint: "federal-register:public-inspection",
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── mode=date validation: regex + real-calendar round-trip (S1) + plausibility
|
|
532
|
+
// bound (S5), ALL pre-fetch. An off-calendar available_on (2026-02-30)
|
|
533
|
+
// returns HTTP 500 that fetchWithRetry would mis-taxonomize as a retryable
|
|
534
|
+
// outage — so a bad date MUST be rejected with `invalid_input` and ZERO fetch.
|
|
535
|
+
let asOfDate: string | null = null;
|
|
536
|
+
if (mode === "date") {
|
|
537
|
+
const date = input.date;
|
|
538
|
+
if (!date || !PI_DATE_RE.test(date)) {
|
|
539
|
+
throw new ToolErrorCarrier({
|
|
540
|
+
kind: "invalid_input",
|
|
541
|
+
message: `mode='date' requires a \`date\` matching YYYY-MM-DD (got ${JSON.stringify(date)}).`,
|
|
542
|
+
retryable: false,
|
|
543
|
+
upstreamEndpoint: "federal-register:public-inspection:date",
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
const parts = date.split("-");
|
|
547
|
+
const y = Number(parts[0]);
|
|
548
|
+
const m = Number(parts[1]);
|
|
549
|
+
const d = Number(parts[2]);
|
|
550
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
551
|
+
if (
|
|
552
|
+
dt.getUTCFullYear() !== y ||
|
|
553
|
+
dt.getUTCMonth() !== m - 1 ||
|
|
554
|
+
dt.getUTCDate() !== d
|
|
555
|
+
) {
|
|
556
|
+
throw new ToolErrorCarrier({
|
|
557
|
+
kind: "invalid_input",
|
|
558
|
+
message: `date '${date}' is not a real calendar date (off-calendar available_on returns HTTP 500 upstream — rejected before any fetch).`,
|
|
559
|
+
retryable: false,
|
|
560
|
+
upstreamEndpoint: "federal-register:public-inspection:date",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const currentYear = new Date().getUTCFullYear();
|
|
564
|
+
if (y < 1994 || y > currentYear + 1) {
|
|
565
|
+
throw new ToolErrorCarrier({
|
|
566
|
+
kind: "invalid_input",
|
|
567
|
+
message: `date year ${y} is outside the plausible window [1994, ${currentYear + 1}].`,
|
|
568
|
+
retryable: false,
|
|
569
|
+
upstreamEndpoint: "federal-register:public-inspection:date",
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
asOfDate = date;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const url = buildPublicInspectionUrl(mode, {
|
|
576
|
+
date: asOfDate ?? undefined,
|
|
577
|
+
term: input.term,
|
|
578
|
+
});
|
|
579
|
+
const endpoint = `federal-register:public-inspection:${mode}`;
|
|
580
|
+
|
|
581
|
+
// M2: a 200 body that is NOT valid JSON → fetchJson's `.json()` throws a
|
|
582
|
+
// SyntaxError → reclassify as `schema_drift` THROW (never a fake empty). A
|
|
583
|
+
// ToolErrorCarrier from fetchWithRetry (503/404/429) is rethrown UNCHANGED.
|
|
584
|
+
let json: unknown;
|
|
585
|
+
try {
|
|
586
|
+
json = await fetchJson<unknown>(url);
|
|
587
|
+
} catch (e) {
|
|
588
|
+
if (e instanceof SyntaxError) {
|
|
589
|
+
throw new ToolErrorCarrier({
|
|
590
|
+
kind: "schema_drift",
|
|
591
|
+
message: `Federal Register public-inspection returned a 200 non-JSON body at ${endpoint}.`,
|
|
592
|
+
retryable: false,
|
|
593
|
+
upstreamEndpoint: endpoint,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
throw e;
|
|
597
|
+
}
|
|
598
|
+
assertInspectionEnvelope(json, endpoint);
|
|
599
|
+
const env = json;
|
|
600
|
+
|
|
601
|
+
const rawRows = env.results;
|
|
602
|
+
const servedTotal = env.count;
|
|
603
|
+
// S4: both freshness stamps are OPTIONAL — null-safe (absent → null).
|
|
604
|
+
const specialFilingsUpdatedAt = str(env.special_filings_updated_at);
|
|
605
|
+
const regularFilingsUpdatedAt = str(env.regular_filings_updated_at);
|
|
606
|
+
|
|
607
|
+
// Client-side filters (uniform across all modes — dodges the API quirks where
|
|
608
|
+
// available_on ignores type/per_page and the date-in-path trap).
|
|
609
|
+
let rows = rawRows.map(mapInspectionRow);
|
|
610
|
+
if (input.type) rows = rows.filter((r) => r.typeCode === input.type);
|
|
611
|
+
if (input.agency)
|
|
612
|
+
rows = rows.filter((r) => r.agencies.some((a) => a.slug === input.agency));
|
|
613
|
+
if (input.specialOnly) rows = rows.filter((r) => r.isSpecialFiling === true);
|
|
614
|
+
const filteredTotal = rows.length;
|
|
615
|
+
const hasClientFilter = Boolean(
|
|
616
|
+
input.type || input.agency || input.specialOnly,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// Overflow (S2): search corpus > per_page (server paginated; we fetched one
|
|
620
|
+
// page). Live-UNREACHABLE at the ~75-doc corpus, defensive-only.
|
|
621
|
+
const overflow = mode === "search" && servedTotal > rawRows.length;
|
|
622
|
+
|
|
623
|
+
let totalAvailable: number;
|
|
624
|
+
let totalIsLowerBound = false;
|
|
625
|
+
if (overflow && hasClientFilter) {
|
|
626
|
+
// A client filter over ONLY the fetched first page → the exact server total
|
|
627
|
+
// (all types) would MISREPRESENT the filtered set. Report the filtered
|
|
628
|
+
// first-page count as a documented LOWER BOUND.
|
|
629
|
+
totalAvailable = filteredTotal;
|
|
630
|
+
totalIsLowerBound = true;
|
|
631
|
+
} else if (overflow) {
|
|
632
|
+
// No client filter → the server's exact total is honest (returned rows are
|
|
633
|
+
// the lower bound, disclosed via truncated + note).
|
|
634
|
+
totalAvailable = servedTotal;
|
|
635
|
+
} else {
|
|
636
|
+
// The exact count of the client-filtered set (never the page length).
|
|
637
|
+
totalAvailable = filteredTotal;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const pageRows = rows.slice(offset, offset + limit);
|
|
641
|
+
const returned = pageRows.length;
|
|
642
|
+
const hasMore = offset + returned < totalAvailable;
|
|
643
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
644
|
+
|
|
645
|
+
const notes: string[] = [
|
|
646
|
+
PRE_PUBLICATION_CAVEAT,
|
|
647
|
+
LEADDAYS_METHOD_NOTE,
|
|
648
|
+
SPECIAL_REGULAR_NOTE,
|
|
649
|
+
];
|
|
650
|
+
notes.push(
|
|
651
|
+
`Served ${servedTotal} document(s); after client filters (type/agency/specialOnly) → ${totalAvailable}${totalIsLowerBound ? "+ (lower bound)" : ""} pageable. The full ${mode} set was fetched in ONE request; limit/offset is a client-side window over the fetched rows.`,
|
|
652
|
+
);
|
|
653
|
+
notes.push(
|
|
654
|
+
"Filters type/agency/specialOnly are applied CLIENT-SIDE over the fetched set; date (→ conditions[available_on]) and term (→ conditions[term]) are server-side query params.",
|
|
655
|
+
);
|
|
656
|
+
if (pageRows.some((r) => typeof r.leadDays === "number" && r.leadDays < 0)) {
|
|
657
|
+
notes.push(
|
|
658
|
+
"At least one row has a NEGATIVE leadDays (publication_date precedes the filing date) — a source data anomaly, surfaced verbatim (not clamped/nulled).",
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (filteredTotal === 0) {
|
|
662
|
+
notes.push(
|
|
663
|
+
`No documents on public inspection for mode='${mode}'${asOfDate ? ` (available_on=${asOfDate})` : ""}${hasClientFilter ? " matching the requested filters" : ""}. This is an honest empty result, not an error.`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (overflow) {
|
|
667
|
+
notes.push(
|
|
668
|
+
hasClientFilter
|
|
669
|
+
? `Server matched ${servedTotal} before client filters but only the first ${rawRows.length} rows were retrieved — totalAvailable is a LOWER BOUND on the client-filtered set. Narrow with a more specific term/type.`
|
|
670
|
+
: `Only the first ${rawRows.length} of ${servedTotal} documents were retrieved (server pagination) — totalAvailable is the exact server total; returned rows are a lower bound. Narrow with a more specific term/type.`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const filtersApplied: string[] = ["mode"];
|
|
675
|
+
if (asOfDate) filtersApplied.push("date");
|
|
676
|
+
if (input.term) filtersApplied.push("term");
|
|
677
|
+
if (input.type) filtersApplied.push("type");
|
|
678
|
+
if (input.agency) filtersApplied.push("agency");
|
|
679
|
+
if (input.specialOnly) filtersApplied.push("specialOnly");
|
|
680
|
+
|
|
681
|
+
const data = {
|
|
682
|
+
mode,
|
|
683
|
+
asOfDate,
|
|
684
|
+
specialFilingsUpdatedAt,
|
|
685
|
+
regularFilingsUpdatedAt,
|
|
686
|
+
servedTotal,
|
|
687
|
+
totalAvailable,
|
|
688
|
+
returned,
|
|
689
|
+
documents: pageRows,
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
return withMeta(data, {
|
|
693
|
+
source:
|
|
694
|
+
"www.federalregister.gov/api/v1/public-inspection-documents (keyless)",
|
|
695
|
+
keylessMode: true,
|
|
696
|
+
returned,
|
|
697
|
+
totalAvailable,
|
|
698
|
+
...(totalIsLowerBound ? { totalIsLowerBound: true } : {}),
|
|
699
|
+
...(overflow ? { truncated: true } : {}),
|
|
700
|
+
filtersApplied,
|
|
701
|
+
filtersDropped: [],
|
|
702
|
+
fieldsUnavailable: [],
|
|
703
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
704
|
+
notes,
|
|
705
|
+
});
|
|
706
|
+
}
|