@cliwant/mcp-sam-gov 1.2.0 → 1.3.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/src/dol.ts ADDED
@@ -0,0 +1,515 @@
1
+ /**
2
+ * dol.ts — US Department of Labor Data API v4 (apiprod.dol.gov) — the LABOR
3
+ * ENFORCEMENT lane (ADR-0053). WHD wage/hour violations, OSHA inspections, ILAB
4
+ * child/forced-labor reports, MSHA mine safety … — the compliance/enforcement
5
+ * signal no contract/spending/market source carries.
6
+ *
7
+ * TWO tools with a DELIBERATE key split (the honest reflection of the live API):
8
+ * • `dol_list_datasets` (KEYLESS) — GET /v4/datasets. The dataset CATALOG (and
9
+ * /v4/agencies) is keyless; this tool needs NO key. It returns the machine
10
+ * inventory (name / tablename / api_url / agency / …) you page + filter to find
11
+ * an endpoint, then feed its `apiUrl` into dol_get_dataset.
12
+ * • `dol_get_dataset` (KEY-REQUIRED, DOL_API_KEY) — GET
13
+ * /v4/get/{agency}/{endpoint}/json?… . The DATA endpoint has NO keyless tier, so
14
+ * with NO `DOL_API_KEY` this tool THROWS an invalid_input config error BEFORE any
15
+ * fetch (0 network call; the message names DOL_API_KEY + dol.gov/developer).
16
+ * So DOL is the 4th REQUIRED key — but ONLY for the data tool; the catalog tool
17
+ * (and every other tool on the server) stays keyless.
18
+ *
19
+ * ★LIVE-VERIFIED WIRE FACTS (2026-07-15):
20
+ * - Host `apiprod.dol.gov` (AWS API Gateway) + base `/v4`.
21
+ * - /v4/datasets is KEYLESS 200 → `{ datasets:[…], meta:{ current_page, next_page,
22
+ * prev_page, total_pages, total_count } }`. `limit` is the PER-PAGE size (limit=1000
23
+ * returns the whole 42-row catalog in one page, total_pages:1); server-side agency
24
+ * filtering is NOT honored (verified: ?agency=ILAB still returned the full 42) — so
25
+ * agency/query filtering is CLIENT-SIDE over the fetched catalog.
26
+ * - The DATA route is the QUERY-STYLE form `/v4/get/{agency}/{endpoint}/{format}?…`
27
+ * (NOT the path-style `/…/limit/N/offset/O/format/json`): the query-style URL
28
+ * reached the DOL app and returned a proper `401 {"…key…missing…"}` on a bad key,
29
+ * whereas the path-style URL only ever hit the AWS gateway's generic
30
+ * `403 {"message":"Missing Authentication Token"}` (an unmatched route). The DOL
31
+ * API User Guide (dataportal.dol.gov/pdf/dol-api-user-guide.pdf) confirms the
32
+ * `/get/<agency>/<api_url>/<format>?limit=&offset=&filter_object=&…` template and
33
+ * that `<endpoint>` is the dataset's **api_url** (NOT its tablename).
34
+ *
35
+ * ★UNVERIFIED (key-gated — coded DEFENSIVELY, honestly disclosed): the DATA response
36
+ * body shape could not be observed live (every /v4/get call is 401 without a real
37
+ * key, and the User Guide shows no body example). So `dol_get_dataset` accepts EITHER
38
+ * a bare row array `[…]` OR `{ data:[…] }` OR `{ results:[…] }`; a top-level count
39
+ * (`total_count`/`total`/`count`, or `meta.total_count`) is used for totalAvailable
40
+ * ONLY if actually present, else `totalAvailable = null` (an HONEST unknown — never
41
+ * `returned` passed off as the total). Records are surfaced VERBATIM (each dataset
42
+ * has its own enforcement schema; blind coercion would distort compliance data — a
43
+ * JSON `0` stays `0`, a JSON `null` stays `null`, field names are preserved as-is).
44
+ *
45
+ * ★HONESTY (ADR-0053 P1–P4 + KEY + SSRF):
46
+ * [KEY] dol_get_dataset with NO DOL_API_KEY ⇒ invalid_input THROW pre-fetch (0
47
+ * fetch); the message names DOL_API_KEY + dol.gov/developer. The key rides the
48
+ * `X-API-KEY` HEADER ONLY — NEVER the URL / label / _meta / notes / a log (the
49
+ * K-test). dol_list_datasets is keyless (no key read, no header).
50
+ * [P1] catalog: totalAvailable = meta.total_count (the API's real catalog total)
51
+ * for an unfiltered scan; when a CLIENT-SIDE filter is applied it is the
52
+ * filtered-set size (exact — the whole catalog is fetched in one page). data:
53
+ * totalAvailable = a real count field when present, else null; offset/limit
54
+ * pagination in both.
55
+ * [P2] data: 401/403 (missing/invalid key) ⇒ invalid_input reclassified with the
56
+ * DOL_API_KEY guidance (the AWS "Missing Authentication Token" is a key problem
57
+ * here) — never empty. 400 ⇒ invalid_input. empty rows ⇒ honest empty
58
+ * (returned:0). 429 ⇒ rate_limited THROW (Retry-After honored). 5xx/timeout ⇒
59
+ * upstream_unavailable THROW. 200 non-JSON ⇒ schema_drift.
60
+ * [P3] records verbatim (null-never-0 comes for free — JSON preserves 0/null and
61
+ * every original field name; the tool never introduces a fabricated 0).
62
+ * [P4] the expected array (catalog `datasets`; data's row array) absent/non-array
63
+ * ⇒ driftError. A ToolErrorCarrier (401/5xx/…) is a P2 outcome, rethrown
64
+ * BEFORE the drift check.
65
+ * [SSRF] fixed host `apiprod.dol.gov` + a post-construction hostname/https assert +
66
+ * `redirect:"error"`; agency/endpoint charclass `^[A-Za-z0-9_]+$` (they ride
67
+ * in the PATH); limit/offset integers; filterValue/fields ride URLSearchParams;
68
+ * the key rides the X-API-KEY header only.
69
+ */
70
+
71
+ import { ToolErrorCarrier } from "./errors.js";
72
+ import { getJson, driftError } from "./datasource.js";
73
+ import { num, str } from "./coerce.js";
74
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
75
+
76
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js) so a
77
+ // `num` regression fails together across sources. NO local num/str.
78
+ export { num };
79
+
80
+ // ─── SSRF core: the single fixed host + base path ─────────────────
81
+ export const DOL_HOST = "apiprod.dol.gov";
82
+ const DOL_DATASETS_PATH = "/v4/datasets";
83
+ // HOST+path labels — surface in ToolError.upstreamEndpoint; the key rides ONLY in the
84
+ // X-API-KEY header, so no token can ever appear here.
85
+ const DOL_DATASETS_LABEL = "dol:/v4/datasets";
86
+ const DOL_GET_LABEL = "dol:/v4/get";
87
+
88
+ // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
89
+ // agency (abbr) + endpoint (api_url / tablename) ride in the request PATH — strict
90
+ // alnum+underscore rejects '/', '.', '..', spaces, and any path-steering char.
91
+ const AGENCY_RE = /^[A-Za-z0-9_]+$/;
92
+ const ENDPOINT_RE = /^[A-Za-z0-9_]+$/;
93
+
94
+ // The whole DOL dataset catalog is small (~42 rows) and returns in a single page when
95
+ // `limit` is large. Fetch it in one page so agency/query filtering is over the COMPLETE
96
+ // catalog (and totalAvailable on a filtered result is exact). meta.total_count is the
97
+ // ground truth — if the catalog ever exceeds this, the honest short-fetch is disclosed.
98
+ const CATALOG_FETCH_LIMIT = 1000;
99
+
100
+ const DEFAULT_GET_LIMIT = 10;
101
+ const MAX_GET_LIMIT = 100;
102
+ const DEFAULT_LIST_LIMIT = 25;
103
+
104
+ // ─── Honesty notes ────────────────────────────────────────────────
105
+ const KEY_REQUIRED_NOTE =
106
+ "dol_get_dataset REQUIRES a free DOL_API_KEY (the DOL data endpoint has no keyless tier; the CATALOG — dol_list_datasets — and agency list are keyless). Get a key at https://dol.gov/developer. The key is sent ONLY in the X-API-KEY request header and is NEVER logged, echoed, or placed in this response.";
107
+ const DATA_ENVELOPE_NOTE =
108
+ "The DOL data-record envelope is key-gated and could not be verified live, so records are returned VERBATIM (each dataset has its own enforcement schema — field names and values are preserved as-is; a value is NOT coerced, so a genuine 0 stays 0 and a missing field stays null).";
109
+ const DATA_NO_TOTAL_NOTE =
110
+ "totalAvailable is null: the DOL data endpoint reports no match count in a form this tool could verify. Page with limit/offset — when a full page is returned hasMore is true (page forward to confirm); an empty next page means the end. `returned` is NEVER passed off as the total.";
111
+ const CATALOG_ENDPOINT_NOTE =
112
+ "Feed a row's `apiUrl` (the dataset endpoint) plus its `agencyAbbr` into dol_get_dataset to fetch that dataset's records. agency/query filtering here is CLIENT-SIDE (the DOL catalog API does not filter server-side).";
113
+
114
+ // ─── The key seam (REQUIRED for the data tool; value NEVER leaked past the header) ──
115
+ /** Read DOL_API_KEY from env; trim; return the value or undefined (unset/blank). */
116
+ export function dolApiKey(): string | undefined {
117
+ const raw = process.env.DOL_API_KEY;
118
+ const trimmed = typeof raw === "string" ? raw.trim() : "";
119
+ return trimmed ? trimmed : undefined;
120
+ }
121
+
122
+ // ─── SSRF-guarded URL builder (fixed host + hostname assertion) ────
123
+ /**
124
+ * Build + assert an apiprod.dol.gov URL on the FIXED host. `path` is a fixed/validated
125
+ * path; `params` (URLSearchParams, encoded) carry all caller VALUES. Asserts the
126
+ * CONSTRUCTED URL's hostname === the fixed host over https (belt-and-suspenders behind
127
+ * the fixed literal), throwing invalid_input on any mismatch (SSRF safety).
128
+ */
129
+ function buildDolUrl(path: string, label: string, params: URLSearchParams): string {
130
+ const qs = params.toString();
131
+ const url = `https://${DOL_HOST}${path}${qs ? `?${qs}` : ""}`;
132
+ const built = new URL(url);
133
+ if (built.hostname !== DOL_HOST || built.protocol !== "https:") {
134
+ throw new ToolErrorCarrier({
135
+ kind: "invalid_input",
136
+ retryable: false,
137
+ message: `Constructed DOL URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${DOL_HOST} over https — refusing to fetch (SSRF safety).`,
138
+ upstreamEndpoint: label,
139
+ });
140
+ }
141
+ return url;
142
+ }
143
+
144
+ // ─── Tool: dol_list_datasets (KEYLESS) ─────────────────────────────
145
+ export type DolListDatasetsArgs = {
146
+ agency?: string;
147
+ query?: string;
148
+ limit?: number;
149
+ offset?: number;
150
+ };
151
+
152
+ export type DolDataset = {
153
+ name: string | null;
154
+ tablename: string | null;
155
+ apiUrl: string | null; // the /v4/get endpoint segment — feed to dol_get_dataset
156
+ agency: string | null; // the agency full name
157
+ agencyAbbr: string | null; // the agency abbreviation (used as the /v4/get {agency})
158
+ description: string | null;
159
+ frequency: string | null;
160
+ datasetType: number | null;
161
+ category: string | null;
162
+ };
163
+
164
+ /** Map ONE catalog `datasets[]` row → the curated dataset shape (every scalar via str/num). */
165
+ function mapDataset(row: unknown): DolDataset {
166
+ const r = (row ?? {}) as Record<string, unknown>;
167
+ const agency = (r.agency ?? {}) as Record<string, unknown>;
168
+ const category = (r.category ?? {}) as Record<string, unknown>;
169
+ return {
170
+ name: str(r.name),
171
+ tablename: str(r.tablename),
172
+ apiUrl: str(r.api_url),
173
+ agency: str(agency.name),
174
+ agencyAbbr: str(agency.abbr),
175
+ description: str(r.description),
176
+ frequency: str(r.frequency),
177
+ datasetType: num(r.dataset_type),
178
+ category: str(r.category_name ?? category.name),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * List the DOL Data API v4 dataset catalog (KEYLESS). Fetches the WHOLE catalog in
184
+ * one page, applies optional CLIENT-SIDE `agency` (abbr/name) + `query` (substring over
185
+ * name/description/tags) filters, then offset/limit-slices the filtered set. Honest
186
+ * `_meta`: totalAvailable = the catalog's real total (unfiltered) or the filtered-set
187
+ * size (exact — the whole catalog is in hand); offset pagination over the filtered set.
188
+ */
189
+ export async function listDatasets(args: DolListDatasetsArgs): Promise<MetaBundle> {
190
+ const label = DOL_DATASETS_LABEL;
191
+ const limit = clampLimit(args.limit, DEFAULT_LIST_LIMIT, 200);
192
+ const offset = clampOffset(args.offset);
193
+
194
+ // Fetch the whole catalog in one page (KEYLESS — no key read, no header).
195
+ const params = new URLSearchParams();
196
+ params.set("limit", String(CATALOG_FETCH_LIMIT));
197
+ const url = buildDolUrl(DOL_DATASETS_PATH, label, params);
198
+
199
+ let body: unknown;
200
+ try {
201
+ body = await getJson<unknown>(url, { label, redirect: "error" });
202
+ } catch (e) {
203
+ if (e instanceof ToolErrorCarrier) throw e;
204
+ if (e instanceof SyntaxError)
205
+ throw driftError(
206
+ label,
207
+ "DOL /v4/datasets returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
208
+ );
209
+ throw e;
210
+ }
211
+
212
+ // [P4] `datasets` MUST be an array (a missing/non-array is drift, never a fabricated empty).
213
+ const b = (body ?? {}) as { datasets?: unknown; meta?: unknown };
214
+ if (!Array.isArray(b.datasets)) {
215
+ throw driftError(
216
+ label,
217
+ "DOL /v4/datasets shape drift — `datasets` must be an array.",
218
+ );
219
+ }
220
+
221
+ const meta = (b.meta ?? {}) as Record<string, unknown>;
222
+ const catalogTotal = num(meta.total_count); // the API's real catalog total (P1)
223
+ const fetched = (b.datasets as unknown[]).map(mapDataset);
224
+
225
+ // ── Optional CLIENT-SIDE filters (the DOL catalog API does not filter server-side). ──
226
+ const filtersApplied: string[] = [];
227
+ let filtered = fetched;
228
+ if (args.agency !== undefined && args.agency.trim() !== "") {
229
+ const needle = args.agency.trim().toLowerCase();
230
+ filtered = filtered.filter(
231
+ (d) =>
232
+ (d.agencyAbbr !== null && d.agencyAbbr.toLowerCase() === needle) ||
233
+ (d.agency !== null && d.agency.toLowerCase().includes(needle)),
234
+ );
235
+ filtersApplied.push(`agency:${args.agency.trim()}`);
236
+ }
237
+ if (args.query !== undefined && args.query.trim() !== "") {
238
+ const q = args.query.trim().toLowerCase();
239
+ // Substring over name + description + category (the human-searchable text).
240
+ filtered = filtered.filter((d) =>
241
+ [d.name, d.description, d.category, d.tablename, d.apiUrl]
242
+ .filter((v): v is string => v !== null)
243
+ .some((v) => v.toLowerCase().includes(q)),
244
+ );
245
+ filtersApplied.push(`query:${args.query.trim()}`);
246
+ }
247
+
248
+ const anyFilter = filtersApplied.length > 0;
249
+ // [P1] totalAvailable: unfiltered ⇒ the API's real catalog total; filtered ⇒ the
250
+ // filtered-set size (EXACT — the whole catalog was fetched in one page). NEVER the
251
+ // page length passed off as a total.
252
+ const totalAvailable = anyFilter
253
+ ? filtered.length
254
+ : catalogTotal !== null
255
+ ? catalogTotal
256
+ : filtered.length;
257
+
258
+ const page = filtered.slice(offset, offset + limit);
259
+ const returned = page.length;
260
+ const hasMore = offset + returned < filtered.length;
261
+ const nextOffset = hasMore ? offset + returned : null;
262
+
263
+ const notes: string[] = [CATALOG_ENDPOINT_NOTE];
264
+ // Honest disclosure if the catalog ever exceeds one fetch page (client-side filtering
265
+ // would then be over a subset). Does not happen at the current ~42-row catalog.
266
+ if (catalogTotal !== null && fetched.length < catalogTotal) {
267
+ notes.push(
268
+ `Only the first ${fetched.length} of ${catalogTotal} catalog datasets were retrieved; any agency/query filtering (and totalAvailable) is over that subset. Narrow with agency/query.`,
269
+ );
270
+ }
271
+
272
+ return withMeta(
273
+ { datasets: page },
274
+ {
275
+ source: `${DOL_HOST} /v4/datasets (US DOL Data API v4 catalog; keyless)`,
276
+ keylessMode: true,
277
+ returned,
278
+ totalAvailable,
279
+ filtersApplied,
280
+ filtersDropped: [],
281
+ fieldsUnavailable: [],
282
+ pagination: { offset, limit, hasMore, nextOffset },
283
+ notes,
284
+ } satisfies Partial<ResponseMeta>,
285
+ );
286
+ }
287
+
288
+ // ─── Tool: dol_get_dataset (KEY-REQUIRED) ──────────────────────────
289
+ export type DolGetDatasetArgs = {
290
+ agency?: string;
291
+ table?: string;
292
+ limit?: number;
293
+ offset?: number;
294
+ filterField?: string;
295
+ filterValue?: string;
296
+ fields?: string[];
297
+ };
298
+
299
+ /**
300
+ * Fetch records from one DOL dataset (KEY-REQUIRED). `agency` (abbr) + `table` (the
301
+ * dataset's api_url endpoint) ride the request PATH; limit/offset/filter ride the query;
302
+ * the DOL_API_KEY rides the X-API-KEY header ONLY. Records are surfaced VERBATIM. Honest
303
+ * `_meta`: totalAvailable is a real count field when present, else null (offset
304
+ * pagination). Unset key ⇒ invalid_input THROW pre-fetch (0 fetch).
305
+ */
306
+ export async function getDataset(args: DolGetDatasetArgs): Promise<MetaBundle> {
307
+ const label = DOL_GET_LABEL;
308
+
309
+ // ── [KEY] REQUIRED key — throw an honest config error BEFORE any fetch. ──
310
+ const key = dolApiKey();
311
+ if (key === undefined) {
312
+ throw new ToolErrorCarrier({
313
+ kind: "invalid_input",
314
+ retryable: false,
315
+ message:
316
+ "The DOL data endpoint requires a free DOL_API_KEY (the dataset CATALOG, dol_list_datasets, is keyless). Get one at https://dol.gov/developer and set DOL_API_KEY.",
317
+ upstreamEndpoint: label,
318
+ });
319
+ }
320
+
321
+ // ── Validate inputs (belt-and-suspenders behind the server Zod; a DIRECT handler
322
+ // call bypasses Zod — agency/table ride in the PATH). ──
323
+ const agency = args.agency ?? "";
324
+ if (!AGENCY_RE.test(agency)) {
325
+ throw new ToolErrorCarrier({
326
+ kind: "invalid_input",
327
+ retryable: false,
328
+ message: `Invalid agency ${JSON.stringify(agency)} — expected an agency abbreviation (^[A-Za-z0-9_]+$), e.g. "WHD", "OSHA", "ILAB". Discover it as agencyAbbr from dol_list_datasets. (agency rides in the request PATH; it is strictly validated.)`,
329
+ upstreamEndpoint: label,
330
+ });
331
+ }
332
+ const table = args.table ?? "";
333
+ if (!ENDPOINT_RE.test(table)) {
334
+ throw new ToolErrorCarrier({
335
+ kind: "invalid_input",
336
+ retryable: false,
337
+ message: `Invalid table ${JSON.stringify(table)} — expected a dataset endpoint (^[A-Za-z0-9_]+$), the dataset's api_url from dol_list_datasets. (table rides in the request PATH; it is strictly validated.)`,
338
+ upstreamEndpoint: label,
339
+ });
340
+ }
341
+ // filterField/filterValue are paired — one without the other is a caller error.
342
+ if (
343
+ (args.filterField !== undefined) !== (args.filterValue !== undefined)
344
+ ) {
345
+ throw new ToolErrorCarrier({
346
+ kind: "invalid_input",
347
+ retryable: false,
348
+ message:
349
+ "filterField and filterValue must be supplied TOGETHER (a field to filter on plus the value to match).",
350
+ upstreamEndpoint: label,
351
+ });
352
+ }
353
+
354
+ const limit = clampLimit(args.limit, DEFAULT_GET_LIMIT, MAX_GET_LIMIT);
355
+ const offset = clampOffset(args.offset);
356
+
357
+ // ── Build the query (all VALUES via URLSearchParams — no host/path steer). The
358
+ // format is a PATH segment (/json per the DOL User Guide); the key rides the
359
+ // X-API-KEY HEADER, never the query. ──
360
+ const params = new URLSearchParams();
361
+ params.set("limit", String(limit));
362
+ params.set("offset", String(offset));
363
+ const filtersApplied: string[] = [];
364
+ if (args.filterField !== undefined && args.filterValue !== undefined) {
365
+ // DOL's documented filter mechanism: a JSON filter_object (field/operator/value).
366
+ params.set(
367
+ "filter_object",
368
+ JSON.stringify({
369
+ field: args.filterField,
370
+ operator: "eq",
371
+ value: args.filterValue,
372
+ }),
373
+ );
374
+ filtersApplied.push(`${args.filterField}:${args.filterValue}`);
375
+ }
376
+ if (args.fields !== undefined && args.fields.length > 0) {
377
+ // Best-effort column selection (not documented for v4; the API ignores or 400s an
378
+ // unsupported param — the 400 path surfaces it honestly).
379
+ params.set("fields", args.fields.join(","));
380
+ filtersApplied.push(`fields:${args.fields.join(",")}`);
381
+ }
382
+
383
+ const path = `/v4/get/${agency}/${table}/json`;
384
+ const url = buildDolUrl(path, label, params);
385
+
386
+ // ── Fetch through the shared envelope: the key rides the X-API-KEY header ONLY;
387
+ // redirect:"error" (fail closed on any off-host 3xx — it could carry the key
388
+ // away). The 401/403 key-error and 400 are reclassified below. ──
389
+ let body: unknown;
390
+ try {
391
+ body = await getJson<unknown>(url, {
392
+ label,
393
+ headers: { "X-API-KEY": key },
394
+ redirect: "error",
395
+ });
396
+ } catch (e) {
397
+ if (e instanceof ToolErrorCarrier) {
398
+ const status = e.toolError.upstreamStatus;
399
+ // [P2/KEY] 401/403 (missing/invalid key — the AWS "Missing Authentication Token"
400
+ // or the DOL app's key-rejection) ⇒ invalid_input carrying the DOL_API_KEY
401
+ // guidance, NEVER an empty result.
402
+ if (status === 401 || status === 403) {
403
+ throw new ToolErrorCarrier({
404
+ kind: "invalid_input",
405
+ retryable: false,
406
+ message:
407
+ "DOL rejected the request as unauthorized (HTTP 401/403) — DOL_API_KEY is missing or invalid. Check the key (free at https://dol.gov/developer).",
408
+ upstreamStatus: status,
409
+ upstreamEndpoint: label,
410
+ });
411
+ }
412
+ throw e; // 400 → invalid_input, 429 → rate_limited, 5xx → upstream_unavailable, 404 → not_found …
413
+ }
414
+ if (e instanceof SyntaxError)
415
+ throw driftError(
416
+ label,
417
+ "DOL /v4/get returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).",
418
+ );
419
+ throw e;
420
+ }
421
+
422
+ // ── [P4] Resolve the row array DEFENSIVELY (envelope unverified — key-gated): a bare
423
+ // array, or `{ data:[…] }`, or `{ results:[…] }`. None ⇒ driftError. ──
424
+ const rows = resolveRows(body);
425
+ if (rows === null) {
426
+ throw driftError(
427
+ label,
428
+ "DOL /v4/get body carries no row array (expected a bare array, or a `data`/`results` array) — schema drift (never a fabricated empty).",
429
+ );
430
+ }
431
+
432
+ // [P4] Every row must be a JSON object; surface it VERBATIM (P3 — preserve field
433
+ // names + values; a JSON 0 stays 0, a JSON null stays null, no coercion).
434
+ const records: Record<string, unknown>[] = [];
435
+ for (let i = 0; i < rows.length; i++) {
436
+ const raw = rows[i];
437
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
438
+ throw driftError(
439
+ label,
440
+ `DOL /v4/get record ${i} is not a JSON object — schema drift (never a fabricated empty).`,
441
+ );
442
+ }
443
+ records.push({ ...(raw as Record<string, unknown>) });
444
+ }
445
+
446
+ // ── [P1] totalAvailable: use a real count field ONLY if present (defensive across
447
+ // the unverified envelope), else null (an honest unknown — never `returned`). ──
448
+ const totalAvailable = resolveCount(body);
449
+ const returned = records.length;
450
+ // Unknown total ⇒ a FULL page suggests more (page forward to confirm — an empty next
451
+ // page is the end); a partial page is the last. Never over-claims a fabricated total.
452
+ const hasMore =
453
+ totalAvailable !== null
454
+ ? offset + returned < totalAvailable
455
+ : returned > 0 && returned === limit;
456
+ const nextOffset = hasMore ? offset + returned : null;
457
+
458
+ const notes: string[] = [KEY_REQUIRED_NOTE, DATA_ENVELOPE_NOTE];
459
+ if (totalAvailable === null) notes.push(DATA_NO_TOTAL_NOTE);
460
+
461
+ return withMeta(
462
+ { records },
463
+ {
464
+ source: `${DOL_HOST} /v4/get/${agency}/${table} (US DOL Data API v4; DOL_API_KEY)`,
465
+ keylessMode: false, // ★KEYED — the data endpoint has no keyless tier
466
+ returned,
467
+ totalAvailable,
468
+ filtersApplied,
469
+ filtersDropped: [],
470
+ fieldsUnavailable: [],
471
+ pagination: { offset, limit, hasMore, nextOffset },
472
+ notes,
473
+ } satisfies Partial<ResponseMeta>,
474
+ );
475
+ }
476
+
477
+ /** Defensively resolve the row array from the (unverified) DOL data envelope. null ⇒ none. */
478
+ function resolveRows(body: unknown): unknown[] | null {
479
+ if (Array.isArray(body)) return body;
480
+ const b = (body ?? {}) as { data?: unknown; results?: unknown };
481
+ if (Array.isArray(b.data)) return b.data;
482
+ if (Array.isArray(b.results)) return b.results;
483
+ return null;
484
+ }
485
+
486
+ /**
487
+ * Defensively read a real total-count field from the (unverified) DOL data envelope.
488
+ * Checks the common carriers (top-level total_count/total/count, or meta.total_count);
489
+ * returns null when NONE is present (an honest unknown — never a fabricated total).
490
+ */
491
+ function resolveCount(body: unknown): number | null {
492
+ if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
493
+ const b = body as Record<string, unknown>;
494
+ const meta = (b.meta ?? {}) as Record<string, unknown>;
495
+ for (const v of [b.total_count, b.total, b.count, meta.total_count]) {
496
+ const n = num(v);
497
+ if (n !== null) return n;
498
+ }
499
+ return null;
500
+ }
501
+
502
+ // ─── Small shared clamps (defensive, behind the server Zod bounds) ──
503
+ function clampLimit(v: unknown, def: number, max: number): number {
504
+ if (typeof v !== "number" || !Number.isFinite(v)) return def;
505
+ const n = Math.floor(v);
506
+ if (n < 1) return 1;
507
+ if (n > max) return max;
508
+ return n;
509
+ }
510
+
511
+ function clampOffset(v: unknown): number {
512
+ if (typeof v !== "number" || !Number.isFinite(v)) return 0;
513
+ const n = Math.floor(v);
514
+ return n < 0 ? 0 : n;
515
+ }
package/src/keys.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * Why this exists
5
5
  * ----------------
6
6
  * The server rides 31 federal sources. MOST are fully keyless. But the set of
7
- * *optional* keys (raise a rate limit, unlock one filter) plus the two *required*
8
- * keys (Census business-patterns, FRED) has grown to the point where a user — or
7
+ * *optional* keys (raise a rate limit, unlock one filter) plus the four *required*
8
+ * keys (Census business-patterns, FRED, BEA Regional, DOL data) has grown to the point where a user — or
9
9
  * the AI driving the server — cannot tell, without reading source code:
10
10
  * - which env var each source reads,
11
11
  * - whether a key is REQUIRED or merely OPTIONAL,
@@ -19,8 +19,9 @@
19
19
  * Grounding: every `envVar` below is the exact string the code reads via
20
20
  * `process.env.<NAME>` — DATA_GOV_API_KEY (datagovKey.ts), SAM_GOV_API_KEY
21
21
  * (server.ts), BLS_API_KEY (bls.ts), NVD_API_KEY (nvd.ts), SOCRATA_APP_TOKEN
22
- * (socrata.ts), CENSUS_API_KEY (census-economic.ts), FRED_API_KEY (fred.ts).
23
- * No invented keys, sources, or signup URLs.
22
+ * (socrata.ts), CENSUS_API_KEY (census-economic.ts), FRED_API_KEY (fred.ts),
23
+ * BEA_API_KEY (bea.ts), DOL_API_KEY (dol.ts), LDA_API_KEY (lda.ts). No invented
24
+ * keys, sources, or signup URLs.
24
25
  */
25
26
 
26
27
  import { readFileSync } from "node:fs";
@@ -43,11 +44,12 @@ export type KeyRegistryEntry = {
43
44
  };
44
45
 
45
46
  /**
46
- * The 7 keys the server reads — code-grounded, no inventions.
47
+ * The 10 keys the server reads — code-grounded, no inventions.
47
48
  *
48
- * REQUIRED (2): CENSUS_API_KEY, FRED_API_KEY — those sources have no keyless
49
- * tier, so the tool throws without them. OPTIONAL (5): everything else works
50
- * keyless; a key only raises a rate limit or unlocks a single filter.
49
+ * REQUIRED (4): CENSUS_API_KEY, FRED_API_KEY, BEA_API_KEY, DOL_API_KEY — those sources
50
+ * have no keyless tier, so the tool throws without them (DOL_API_KEY gates ONLY
51
+ * dol_get_dataset; the DOL catalog, dol_list_datasets, is keyless). OPTIONAL (6):
52
+ * everything else works keyless; a key only raises a rate limit or unlocks a single filter.
51
53
  */
52
54
  export const KEY_REGISTRY: readonly KeyRegistryEntry[] = [
53
55
  {
@@ -114,6 +116,35 @@ export const KEY_REGISTRY: readonly KeyRegistryEntry[] = [
114
116
  "the 2 FRED tools (there is no keyless tier — they throw without a key)",
115
117
  note: "REQUIRED: the FRED API has no keyless access.",
116
118
  },
119
+ {
120
+ envVar: "BEA_API_KEY",
121
+ sources: ["BEA Regional Economic Accounts (bea_regional_data)"],
122
+ required: true,
123
+ signupUrl: "https://apps.bea.gov/API/signup/",
124
+ unlocks:
125
+ "the bea_regional_data tool (there is no keyless tier — it throws without a key)",
126
+ note: "REQUIRED: the BEA Data API has no keyless access.",
127
+ },
128
+ {
129
+ envVar: "DOL_API_KEY",
130
+ sources: [
131
+ "US DOL enforcement data (dol_get_dataset; dol_list_datasets is keyless)",
132
+ ],
133
+ required: true,
134
+ signupUrl: "https://dol.gov/developer",
135
+ unlocks:
136
+ "the dol_get_dataset tool (dataset records need a free key; dol_list_datasets works keyless)",
137
+ note: "The DOL data endpoint needs a free key; the dataset CATALOG (dol_list_datasets) and agency list are keyless.",
138
+ },
139
+ {
140
+ envVar: "LDA_API_KEY",
141
+ sources: ["US Senate LDA lobbying (lda_search_filings)"],
142
+ required: false,
143
+ signupUrl: "https://lda.senate.gov/api/register/",
144
+ unlocks:
145
+ "higher LDA API rate limits (anonymous access already works without it)",
146
+ note: "Keyless by default (anonymous 200); a free token only raises the rate limit.",
147
+ },
117
148
  ] as const;
118
149
 
119
150
  /** true iff the env var is set to a non-empty (after-trim) string. */