@cliwant/mcp-sam-gov 1.5.0 → 1.7.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.
Files changed (89) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +248 -231
  3. package/README.ko.md +248 -231
  4. package/README.md +733 -714
  5. package/dist/errors.d.ts +10 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js.map +1 -1
  8. package/dist/feedback.d.ts +64 -0
  9. package/dist/feedback.d.ts.map +1 -0
  10. package/dist/feedback.js +131 -0
  11. package/dist/feedback.js.map +1 -0
  12. package/dist/server.d.ts.map +1 -1
  13. package/dist/server.js +48 -2
  14. package/dist/server.js.map +1 -1
  15. package/dist/update-check.d.ts +38 -0
  16. package/dist/update-check.d.ts.map +1 -0
  17. package/dist/update-check.js +85 -0
  18. package/dist/update-check.js.map +1 -0
  19. package/package.json +111 -111
  20. package/src/attachments.ts +652 -652
  21. package/src/bea.ts +372 -372
  22. package/src/bls.ts +1943 -1943
  23. package/src/cache.ts +73 -73
  24. package/src/cbp-border.ts +177 -177
  25. package/src/census-economic.ts +431 -431
  26. package/src/census.ts +735 -735
  27. package/src/ckan.ts +495 -495
  28. package/src/clinicaltrials.ts +923 -923
  29. package/src/cms-facility.ts +379 -379
  30. package/src/cms-hospital.ts +344 -344
  31. package/src/cms-supplier.ts +527 -527
  32. package/src/cms-utilization.ts +389 -389
  33. package/src/cms.ts +634 -634
  34. package/src/coerce.ts +47 -47
  35. package/src/courtlistener.ts +465 -465
  36. package/src/cpsc.ts +333 -333
  37. package/src/datagov-catalog.ts +312 -312
  38. package/src/datagov.ts +907 -907
  39. package/src/datagovKey.ts +68 -68
  40. package/src/datasource.ts +721 -721
  41. package/src/disclosure.ts +61 -61
  42. package/src/dol.ts +515 -515
  43. package/src/ecfr.ts +248 -248
  44. package/src/echo.ts +496 -496
  45. package/src/edgar.ts +3046 -3046
  46. package/src/epa-envirofacts.ts +358 -358
  47. package/src/errors.ts +324 -314
  48. package/src/fac.ts +529 -529
  49. package/src/far.ts +1009 -1009
  50. package/src/fdic.ts +2052 -2052
  51. package/src/federal-register.ts +725 -725
  52. package/src/feedback.ts +160 -0
  53. package/src/fema.ts +680 -680
  54. package/src/fpds.ts +620 -620
  55. package/src/fred.ts +464 -464
  56. package/src/gao.ts +744 -744
  57. package/src/gov-domains.ts +237 -237
  58. package/src/govinfo.ts +497 -497
  59. package/src/grants.ts +290 -290
  60. package/src/gsa-csv.ts +992 -992
  61. package/src/gsa-perdiem.ts +361 -361
  62. package/src/integrity.ts +928 -928
  63. package/src/keys.ts +268 -268
  64. package/src/lda.ts +385 -385
  65. package/src/meta.ts +292 -292
  66. package/src/nhtsa.ts +352 -352
  67. package/src/nih.ts +375 -375
  68. package/src/nist-controls.ts +219 -219
  69. package/src/nonprofit.ts +460 -460
  70. package/src/nppes.ts +834 -834
  71. package/src/nsf.ts +706 -706
  72. package/src/nvd.ts +1124 -1124
  73. package/src/nws-weather.ts +167 -167
  74. package/src/ofac.ts +1166 -1166
  75. package/src/openfda-device.ts +356 -356
  76. package/src/openfda-drugsfda.ts +313 -313
  77. package/src/openfda.ts +518 -518
  78. package/src/pricing.ts +1075 -1075
  79. package/src/sam-gov/client.ts +774 -774
  80. package/src/sam-gov/index.ts +32 -32
  81. package/src/sam-gov/types.ts +152 -152
  82. package/src/sba.ts +357 -357
  83. package/src/server.ts +6692 -6639
  84. package/src/snapshot.ts +223 -223
  85. package/src/socrata.ts +532 -532
  86. package/src/treasury.ts +582 -582
  87. package/src/update-check.ts +88 -0
  88. package/src/usaspending.ts +2852 -2852
  89. package/src/usitc.ts +420 -420
@@ -1,361 +1,361 @@
1
- /**
2
- * gsa-perdiem.ts — GSA Federal Travel Per-Diem lookup (`api.gsa.gov`, base
3
- * `/travel/perdiem/v2`) — ADR-0050. The lodging + M&IE rate ceilings the federal
4
- * government reimburses for official travel, by city/state or ZIP for a given year.
5
- *
6
- * WHAT IT ADDS: a NEW travel-cost lane (the per-diem authority) on the SAME
7
- * `api.gsa.gov` host as datagov-catalog.ts, so it REUSES the audited `datagovKey.ts`
8
- * key seam VERBATIM (keyHeader / keyModeLabel / pushKeyNote) — keyless by default via
9
- * the shared DEMO_KEY, upgraded by DATA_GOV_API_KEY. The key rides ONLY in the
10
- * X-Api-Key header — NEVER the URL / label / _meta / a log (the K-test). This module
11
- * writes ZERO fetch/coercion/error/meta code: it REUSES `getJson` (redirect:"error",
12
- * the X-Api-Key header) / `driftError` / `num`·`str` (coerce.ts) / `withMeta`·
13
- * `buildMeta`, and MIRRORS the datagov-catalog schema_drift catch-ladder verbatim.
14
- *
15
- * ★ SSRF: the host is a compile-time literal (`GSA_PERDIEM_HOST`). The two lookup
16
- * modes ride FIXED path templates; every caller value (city/state/zip/year) is
17
- * charclass-validated THEN `encodeURIComponent`-escaped into a single path segment
18
- * (no raw passthrough, no query steer). A post-construction hostname/protocol
19
- * assertion + `redirect:"error"` lock it (fail closed on any off-host 3xx — a 3xx
20
- * off api.gsa.gov could carry the X-Api-Key header away).
21
- *
22
- * ★ HONESTY (ADR-0050 P1–P5, live-verified 2026-07-15):
23
- * [INPUT] EITHER (city + state) OR zip — supplying BOTH, or NEITHER, ⇒ invalid_input
24
- * with 0 fetch (an ambiguous/empty lookup is a caller error, never a guess).
25
- * [P1] the API returns the COMPLETE rate set for the lookup (no pagination) ⇒
26
- * totalAvailable = the flattened row count, complete:true. NEVER fabricated.
27
- * [P2] `errors` non-null ⇒ invalid_input surfacing the message (never a fake
28
- * empty); a genuine no-match (rates:[] / rate:[]) ⇒ honest empty (returned:0,
29
- * complete:true); a 429 (DEMO_KEY ~10/hr) ⇒ rate_limited THROW honoring
30
- * Retry-After; a 5xx ⇒ upstream_unavailable THROW; a 200 non-JSON ⇒
31
- * schema_drift. A DOWN service is NEVER a returned:0.
32
- * [P3] `value` (monthly max lodging $) / `meals` (M&IE cap $) via `num` (null-
33
- * never-0 — a genuine 0 stays 0). `standardRate` / `isOconus` are STRING
34
- * booleans "true"/"false" ⇒ coerced to a real boolean (an unrecognized value
35
- * ⇒ null, never a fabricated false). The months array is preserved AS-IS
36
- * (never padded/fabricated to 12).
37
- * [P4] `rates` / a group's `rate` / `months.month` absent or non-array ⇒
38
- * driftError (never a fabricated empty).
39
- */
40
-
41
- import { ToolErrorCarrier } from "./errors.js";
42
- import { getJson, driftError } from "./datasource.js";
43
- import { num, str } from "./coerce.js";
44
- import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
45
- // The SHARED api.data.gov key seam (ADR-0010 §2). api.gsa.gov accepts the SAME
46
- // DATA_GOV_API_KEY / DEMO_KEY via the X-Api-Key header — this is another consumer
47
- // of the audited key discipline (a key-leak regression now fails this suite too).
48
- import { keyHeader, keyModeLabel, pushKeyNote } from "./datagovKey.js";
49
-
50
- // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
51
- export const GSA_PERDIEM_HOST = "api.gsa.gov";
52
- const GSA_PERDIEM_BASE = "/travel/perdiem/v2";
53
- // HOST+path label — surfaces in ToolError.upstreamEndpoint; the key rides ONLY in
54
- // the X-Api-Key header, so no token can ever appear here.
55
- const GSA_PERDIEM_LABEL = "gsa-perdiem:/travel/perdiem/v2/rates";
56
-
57
- const GSA_PERDIEM_SOURCE = (mode: string) =>
58
- `${GSA_PERDIEM_HOST} via GSA Federal Travel Per-Diem API (${mode})`;
59
-
60
- // The default per-diem fiscal year (ADR-0050 — the current confirmed vintage).
61
- export const DEFAULT_PERDIEM_YEAR = "2025";
62
-
63
- // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
64
- // Each rides in a single PATH segment (encodeURIComponent-escaped), so these are
65
- // belt-and-suspenders against a Zod-bypassing direct handler call.
66
- const CITY_RE = /^[A-Za-z .'\-]{1,60}$/;
67
- const STATE_RE = /^[A-Za-z]{2}$/;
68
- const ZIP_RE = /^\d{5}$/;
69
- const YEAR_RE = /^\d{4}$/;
70
-
71
- // ─── Honesty notes (ADR-0050 required set) ────────────────────────
72
- const RATE_MEANING_NOTE =
73
- "lodgingUsd (from the API's monthly `value`) is the MAX nightly lodging reimbursement ceiling for that month — it VARIES SEASONALLY, hence a per-month array; mealsUsd (from `meals`) is the daily Meals & Incidental Expenses (M&IE) ceiling. Both are integer US dollars. A withheld/absent figure is null, NEVER 0 (a genuine 0 is preserved).";
74
- const STANDARD_RATE_NOTE =
75
- "standardRate:true means this location falls under the CONUS STANDARD rate (not an individually-set non-standard rate). standardRate/isOconus are booleans coerced from the API's string 'true'/'false'.";
76
- const NO_PAGINATION_NOTE =
77
- "The per-diem API returns the COMPLETE rate set for the lookup (no pagination); totalAvailable equals the number of rows returned.";
78
-
79
- // ─── STRING-boolean coercion (null-never-fabricate) ───────────────
80
- /** Coerce the API's string 'true'/'false' → a real boolean; anything else ⇒ null. */
81
- function strBool(v: unknown): boolean | null {
82
- if (typeof v === "boolean") return v;
83
- if (typeof v === "string") {
84
- const s = v.trim().toLowerCase();
85
- if (s === "true") return true;
86
- if (s === "false") return false;
87
- }
88
- return null;
89
- }
90
-
91
- // ─── Curated shapes ───────────────────────────────────────────────
92
- export type PerdiemMonth = {
93
- month: number | null; // the month NUMBER (1-12)
94
- monthName: string | null; // the long month name
95
- lodgingUsd: number | null; // the monthly max lodging ceiling ($) — null-never-0
96
- };
97
-
98
- export type PerdiemRate = {
99
- city: string | null;
100
- county: string | null;
101
- state: string | null;
102
- zip: string | null;
103
- year: number | null;
104
- isOconus: boolean | null; // OCONUS (outside-CONUS) flag — coerced from string boolean
105
- standardRate: boolean | null; // CONUS standard-rate flag — coerced from string boolean
106
- mealsUsd: number | null; // M&IE ceiling ($) — null-never-0
107
- monthlyLodgingUsd: PerdiemMonth[]; // per-month lodging ceilings (preserved AS-IS)
108
- };
109
-
110
- export type GsaPerdiemRatesArgs = {
111
- city?: string;
112
- state?: string;
113
- zip?: string;
114
- year?: string;
115
- };
116
-
117
- /**
118
- * Map one `months.month[]` entry → the curated per-month shape. `value` and the
119
- * month `number` via `num` (null-never-0); `long` (month name) via `str`.
120
- */
121
- function mapMonth(m: unknown): PerdiemMonth {
122
- const it = (m ?? {}) as Record<string, unknown>;
123
- return {
124
- month: num(it.number),
125
- monthName: str(it.long),
126
- lodgingUsd: num(it.value),
127
- };
128
- }
129
-
130
- // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
131
- /**
132
- * GET one GSA per-diem JSON resource. `path` is a fully-assembled, pre-escaped
133
- * path (NO query params — the key rides in the X-Api-Key header only). Builds
134
- * `https://${GSA_PERDIEM_HOST}${path}` on the FIXED host, asserts the CONSTRUCTED
135
- * URL's hostname === the host over https (belt-and-suspenders), sets
136
- * `redirect:"error"` (an off-host 3xx must NOT be followed — it could carry the
137
- * X-Api-Key header to a foreign host), and attaches the key ONLY in the header.
138
- */
139
- async function getGsaPerdiem(path: string): Promise<unknown> {
140
- const url = `https://${GSA_PERDIEM_HOST}${path}`;
141
- const built = new URL(url);
142
- if (built.hostname !== GSA_PERDIEM_HOST || built.protocol !== "https:") {
143
- throw new ToolErrorCarrier({
144
- kind: "invalid_input",
145
- message: `Constructed GSA per-diem URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(GSA_PERDIEM_HOST)} over https — refusing to fetch (SSRF safety).`,
146
- retryable: false,
147
- upstreamEndpoint: GSA_PERDIEM_LABEL,
148
- });
149
- }
150
- // The key rides in the X-Api-Key header ONLY (never the URL/label/_meta);
151
- // redirect:"error" (fail closed on any off-host 3xx).
152
- return getJson(url, {
153
- label: GSA_PERDIEM_LABEL,
154
- headers: keyHeader(),
155
- redirect: "error",
156
- });
157
- }
158
-
159
- /**
160
- * Look up GSA Federal Travel per-diem rates by EITHER (city + state) OR zip, for a
161
- * given `year` (default 2025). Returns flattened rate rows (each outer state/year
162
- * group × inner city/rate) + honest `_meta`: totalAvailable = the row count (no
163
- * pagination — P1), lodging/meals as null-never-0 dollars (P3), standardRate/isOconus
164
- * as real booleans, the months array preserved as-is. The DEMO_KEY rate disclosure
165
- * rides in the notes.
166
- */
167
- export async function perdiemRates(
168
- args: GsaPerdiemRatesArgs,
169
- ): Promise<MetaBundle> {
170
- const label = GSA_PERDIEM_LABEL;
171
- const year = args.year ?? DEFAULT_PERDIEM_YEAR;
172
-
173
- // ── [INPUT] EITHER (city + state) OR zip — never both, never neither. This is a
174
- // caller-shape check (0 fetch): an ambiguous or empty lookup is invalid_input,
175
- // never a silent guess. ──
176
- const hasCityState = args.city !== undefined || args.state !== undefined;
177
- const hasZip = args.zip !== undefined;
178
- if (hasCityState && hasZip) {
179
- throw new ToolErrorCarrier({
180
- kind: "invalid_input",
181
- retryable: false,
182
- message:
183
- "Provide EITHER (city + state) OR zip — not both. City/state and ZIP are two distinct lookup modes; supplying both is ambiguous.",
184
- upstreamEndpoint: label,
185
- });
186
- }
187
- if (!hasCityState && !hasZip) {
188
- throw new ToolErrorCarrier({
189
- kind: "invalid_input",
190
- retryable: false,
191
- message:
192
- "Provide a lookup key: EITHER (city + state, e.g. city:'Washington', state:'DC') OR zip (e.g. zip:'20001').",
193
- upstreamEndpoint: label,
194
- });
195
- }
196
-
197
- // ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
198
- // a DIRECT handler call bypasses Zod). year rides in the PATH regardless. ──
199
- if (!YEAR_RE.test(year)) {
200
- throw new ToolErrorCarrier({
201
- kind: "invalid_input",
202
- retryable: false,
203
- message: `Invalid year ${JSON.stringify(year)} — expected a 4-digit year (^\\d{4}$), e.g. "2025". (year rides in the request PATH; it is strictly validated.)`,
204
- upstreamEndpoint: label,
205
- });
206
- }
207
-
208
- let path: string;
209
- const filtersApplied: string[] = [`year:${year}`];
210
- let lookupMode: string;
211
-
212
- if (hasZip) {
213
- const zip = args.zip as string;
214
- if (!ZIP_RE.test(zip)) {
215
- throw new ToolErrorCarrier({
216
- kind: "invalid_input",
217
- retryable: false,
218
- message: `Invalid zip ${JSON.stringify(zip)} — expected a 5-digit ZIP code (^\\d{5}$), e.g. "20001".`,
219
- upstreamEndpoint: label,
220
- });
221
- }
222
- lookupMode = `zip:${zip}`;
223
- filtersApplied.push(lookupMode);
224
- // Fixed template; each segment encodeURIComponent-escaped (belt-and-suspenders
225
- // behind the charclass — no path injection, no query steer).
226
- path = `${GSA_PERDIEM_BASE}/rates/zip/${encodeURIComponent(zip)}/year/${encodeURIComponent(year)}`;
227
- } else {
228
- // city + state — BOTH are required together for this mode.
229
- if (args.city === undefined || args.state === undefined) {
230
- throw new ToolErrorCarrier({
231
- kind: "invalid_input",
232
- retryable: false,
233
- message:
234
- "The city lookup mode requires BOTH city AND state (e.g. city:'Washington', state:'DC'). Provide both, or use zip instead.",
235
- upstreamEndpoint: label,
236
- });
237
- }
238
- const city = args.city;
239
- const state = args.state;
240
- if (!CITY_RE.test(city)) {
241
- throw new ToolErrorCarrier({
242
- kind: "invalid_input",
243
- retryable: false,
244
- message: `Invalid city ${JSON.stringify(city)} — expected 1–60 letters/spaces/.'- (^[A-Za-z .'\\-]{1,60}$), e.g. "Washington".`,
245
- upstreamEndpoint: label,
246
- });
247
- }
248
- if (!STATE_RE.test(state)) {
249
- throw new ToolErrorCarrier({
250
- kind: "invalid_input",
251
- retryable: false,
252
- message: `Invalid state ${JSON.stringify(state)} — expected a 2-letter state code (^[A-Za-z]{2}$), e.g. "DC", "CA".`,
253
- upstreamEndpoint: label,
254
- });
255
- }
256
- lookupMode = `city:${city}, state:${state}`;
257
- filtersApplied.push(`city:${city}`, `state:${state}`);
258
- path = `${GSA_PERDIEM_BASE}/rates/city/${encodeURIComponent(city)}/state/${encodeURIComponent(state)}/year/${encodeURIComponent(year)}`;
259
- }
260
-
261
- // ── The typed catch-ladder (datagov-catalog searchDatasets shape, VERBATIM).
262
- // Preserve the 429/404/5xx/400/timeout ToolErrorCarrier taxonomy FIRST
263
- // (LOAD-BEARING: the DEMO_KEY-~10/hr 429→rate_limited frontier would regress to
264
- // schema_drift under a broader catch); reclassify a 200 non-JSON `.json()`
265
- // SyntaxError to schema_drift SECOND; bare-rethrow LAST. The host-assert
266
- // ToolErrorCarrier is also rethrown first. ──
267
- let body: unknown;
268
- try {
269
- body = await getGsaPerdiem(path);
270
- } catch (e) {
271
- if (e instanceof ToolErrorCarrier) throw e;
272
- if (e instanceof SyntaxError)
273
- throw driftError(
274
- label,
275
- "GSA per-diem returned a non-JSON body at HTTP 200 — schema drift.",
276
- );
277
- throw e;
278
- }
279
-
280
- const b = (body ?? {}) as { errors?: unknown; rates?: unknown };
281
-
282
- // ── [P2] `errors` non-null ⇒ a lookup problem ⇒ invalid_input surfacing the
283
- // message (NEVER a fake empty — swallowing this as empty ⇒ RED). ──
284
- if (b.errors !== null && b.errors !== undefined) {
285
- const msg =
286
- typeof b.errors === "string" ? b.errors : JSON.stringify(b.errors);
287
- throw new ToolErrorCarrier({
288
- kind: "invalid_input",
289
- retryable: false,
290
- message: `GSA per-diem reported a lookup error for ${lookupMode} (year ${year}): ${msg}`,
291
- upstreamEndpoint: label,
292
- });
293
- }
294
-
295
- // ── [P4] `rates` MUST be an array (a missing/object/null rates is drift, never a
296
- // fabricated empty — a TypeError must never mask drift as upstream_unavailable). ──
297
- if (!Array.isArray(b.rates)) {
298
- throw driftError(
299
- label,
300
- "GSA per-diem shape drift — response.rates must be an array.",
301
- );
302
- }
303
-
304
- // ── Flatten: each outer state/year group × its inner rate[]. ──
305
- const rows: PerdiemRate[] = [];
306
- for (const group of b.rates as unknown[]) {
307
- const g = (group ?? {}) as Record<string, unknown>;
308
- // [P4] a group's `rate` MUST be an array (never a fabricated empty).
309
- if (!Array.isArray(g.rate)) {
310
- throw driftError(
311
- label,
312
- "GSA per-diem shape drift — a rates[].rate must be an array.",
313
- );
314
- }
315
- const gState = str(g.state);
316
- const gYear = num(g.year);
317
- const gOconus = strBool(g.isOconus);
318
- for (const rate of g.rate as unknown[]) {
319
- const r = (rate ?? {}) as Record<string, unknown>;
320
- const monthsObj = (r.months ?? {}) as Record<string, unknown>;
321
- // [P4] months.month MUST be an array (never padded/fabricated to 12).
322
- if (!Array.isArray(monthsObj.month)) {
323
- throw driftError(
324
- label,
325
- "GSA per-diem shape drift — a rate's months.month must be an array.",
326
- );
327
- }
328
- rows.push({
329
- city: str(r.city),
330
- county: str(r.county),
331
- state: gState,
332
- zip: str(r.zip),
333
- year: gYear,
334
- isOconus: gOconus,
335
- standardRate: strBool(r.standardRate),
336
- mealsUsd: num(r.meals),
337
- monthlyLodgingUsd: (monthsObj.month as unknown[]).map(mapMonth),
338
- });
339
- }
340
- }
341
-
342
- const returned = rows.length;
343
- const notes: string[] = [RATE_MEANING_NOTE, STANDARD_RATE_NOTE, NO_PAGINATION_NOTE];
344
- pushKeyNote(notes);
345
-
346
- return withMeta(
347
- { rates: rows },
348
- {
349
- source: GSA_PERDIEM_SOURCE(keyModeLabel()),
350
- keylessMode: false, // keyed via the api.data.gov X-Api-Key (DEMO_KEY default)
351
- returned,
352
- // [P1] the COMPLETE set for the lookup (no pagination) ⇒ totalAvailable = the
353
- // row count; complete is DERIVED true by buildMeta (returned === total).
354
- totalAvailable: returned,
355
- filtersApplied,
356
- filtersDropped: [],
357
- fieldsUnavailable: [],
358
- notes,
359
- } satisfies Partial<ResponseMeta>,
360
- );
361
- }
1
+ /**
2
+ * gsa-perdiem.ts — GSA Federal Travel Per-Diem lookup (`api.gsa.gov`, base
3
+ * `/travel/perdiem/v2`) — ADR-0050. The lodging + M&IE rate ceilings the federal
4
+ * government reimburses for official travel, by city/state or ZIP for a given year.
5
+ *
6
+ * WHAT IT ADDS: a NEW travel-cost lane (the per-diem authority) on the SAME
7
+ * `api.gsa.gov` host as datagov-catalog.ts, so it REUSES the audited `datagovKey.ts`
8
+ * key seam VERBATIM (keyHeader / keyModeLabel / pushKeyNote) — keyless by default via
9
+ * the shared DEMO_KEY, upgraded by DATA_GOV_API_KEY. The key rides ONLY in the
10
+ * X-Api-Key header — NEVER the URL / label / _meta / a log (the K-test). This module
11
+ * writes ZERO fetch/coercion/error/meta code: it REUSES `getJson` (redirect:"error",
12
+ * the X-Api-Key header) / `driftError` / `num`·`str` (coerce.ts) / `withMeta`·
13
+ * `buildMeta`, and MIRRORS the datagov-catalog schema_drift catch-ladder verbatim.
14
+ *
15
+ * ★ SSRF: the host is a compile-time literal (`GSA_PERDIEM_HOST`). The two lookup
16
+ * modes ride FIXED path templates; every caller value (city/state/zip/year) is
17
+ * charclass-validated THEN `encodeURIComponent`-escaped into a single path segment
18
+ * (no raw passthrough, no query steer). A post-construction hostname/protocol
19
+ * assertion + `redirect:"error"` lock it (fail closed on any off-host 3xx — a 3xx
20
+ * off api.gsa.gov could carry the X-Api-Key header away).
21
+ *
22
+ * ★ HONESTY (ADR-0050 P1–P5, live-verified 2026-07-15):
23
+ * [INPUT] EITHER (city + state) OR zip — supplying BOTH, or NEITHER, ⇒ invalid_input
24
+ * with 0 fetch (an ambiguous/empty lookup is a caller error, never a guess).
25
+ * [P1] the API returns the COMPLETE rate set for the lookup (no pagination) ⇒
26
+ * totalAvailable = the flattened row count, complete:true. NEVER fabricated.
27
+ * [P2] `errors` non-null ⇒ invalid_input surfacing the message (never a fake
28
+ * empty); a genuine no-match (rates:[] / rate:[]) ⇒ honest empty (returned:0,
29
+ * complete:true); a 429 (DEMO_KEY ~10/hr) ⇒ rate_limited THROW honoring
30
+ * Retry-After; a 5xx ⇒ upstream_unavailable THROW; a 200 non-JSON ⇒
31
+ * schema_drift. A DOWN service is NEVER a returned:0.
32
+ * [P3] `value` (monthly max lodging $) / `meals` (M&IE cap $) via `num` (null-
33
+ * never-0 — a genuine 0 stays 0). `standardRate` / `isOconus` are STRING
34
+ * booleans "true"/"false" ⇒ coerced to a real boolean (an unrecognized value
35
+ * ⇒ null, never a fabricated false). The months array is preserved AS-IS
36
+ * (never padded/fabricated to 12).
37
+ * [P4] `rates` / a group's `rate` / `months.month` absent or non-array ⇒
38
+ * driftError (never a fabricated empty).
39
+ */
40
+
41
+ import { ToolErrorCarrier } from "./errors.js";
42
+ import { getJson, driftError } from "./datasource.js";
43
+ import { num, str } from "./coerce.js";
44
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
45
+ // The SHARED api.data.gov key seam (ADR-0010 §2). api.gsa.gov accepts the SAME
46
+ // DATA_GOV_API_KEY / DEMO_KEY via the X-Api-Key header — this is another consumer
47
+ // of the audited key discipline (a key-leak regression now fails this suite too).
48
+ import { keyHeader, keyModeLabel, pushKeyNote } from "./datagovKey.js";
49
+
50
+ // ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
51
+ export const GSA_PERDIEM_HOST = "api.gsa.gov";
52
+ const GSA_PERDIEM_BASE = "/travel/perdiem/v2";
53
+ // HOST+path label — surfaces in ToolError.upstreamEndpoint; the key rides ONLY in
54
+ // the X-Api-Key header, so no token can ever appear here.
55
+ const GSA_PERDIEM_LABEL = "gsa-perdiem:/travel/perdiem/v2/rates";
56
+
57
+ const GSA_PERDIEM_SOURCE = (mode: string) =>
58
+ `${GSA_PERDIEM_HOST} via GSA Federal Travel Per-Diem API (${mode})`;
59
+
60
+ // The default per-diem fiscal year (ADR-0050 — the current confirmed vintage).
61
+ export const DEFAULT_PERDIEM_YEAR = "2025";
62
+
63
+ // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
64
+ // Each rides in a single PATH segment (encodeURIComponent-escaped), so these are
65
+ // belt-and-suspenders against a Zod-bypassing direct handler call.
66
+ const CITY_RE = /^[A-Za-z .'\-]{1,60}$/;
67
+ const STATE_RE = /^[A-Za-z]{2}$/;
68
+ const ZIP_RE = /^\d{5}$/;
69
+ const YEAR_RE = /^\d{4}$/;
70
+
71
+ // ─── Honesty notes (ADR-0050 required set) ────────────────────────
72
+ const RATE_MEANING_NOTE =
73
+ "lodgingUsd (from the API's monthly `value`) is the MAX nightly lodging reimbursement ceiling for that month — it VARIES SEASONALLY, hence a per-month array; mealsUsd (from `meals`) is the daily Meals & Incidental Expenses (M&IE) ceiling. Both are integer US dollars. A withheld/absent figure is null, NEVER 0 (a genuine 0 is preserved).";
74
+ const STANDARD_RATE_NOTE =
75
+ "standardRate:true means this location falls under the CONUS STANDARD rate (not an individually-set non-standard rate). standardRate/isOconus are booleans coerced from the API's string 'true'/'false'.";
76
+ const NO_PAGINATION_NOTE =
77
+ "The per-diem API returns the COMPLETE rate set for the lookup (no pagination); totalAvailable equals the number of rows returned.";
78
+
79
+ // ─── STRING-boolean coercion (null-never-fabricate) ───────────────
80
+ /** Coerce the API's string 'true'/'false' → a real boolean; anything else ⇒ null. */
81
+ function strBool(v: unknown): boolean | null {
82
+ if (typeof v === "boolean") return v;
83
+ if (typeof v === "string") {
84
+ const s = v.trim().toLowerCase();
85
+ if (s === "true") return true;
86
+ if (s === "false") return false;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ // ─── Curated shapes ───────────────────────────────────────────────
92
+ export type PerdiemMonth = {
93
+ month: number | null; // the month NUMBER (1-12)
94
+ monthName: string | null; // the long month name
95
+ lodgingUsd: number | null; // the monthly max lodging ceiling ($) — null-never-0
96
+ };
97
+
98
+ export type PerdiemRate = {
99
+ city: string | null;
100
+ county: string | null;
101
+ state: string | null;
102
+ zip: string | null;
103
+ year: number | null;
104
+ isOconus: boolean | null; // OCONUS (outside-CONUS) flag — coerced from string boolean
105
+ standardRate: boolean | null; // CONUS standard-rate flag — coerced from string boolean
106
+ mealsUsd: number | null; // M&IE ceiling ($) — null-never-0
107
+ monthlyLodgingUsd: PerdiemMonth[]; // per-month lodging ceilings (preserved AS-IS)
108
+ };
109
+
110
+ export type GsaPerdiemRatesArgs = {
111
+ city?: string;
112
+ state?: string;
113
+ zip?: string;
114
+ year?: string;
115
+ };
116
+
117
+ /**
118
+ * Map one `months.month[]` entry → the curated per-month shape. `value` and the
119
+ * month `number` via `num` (null-never-0); `long` (month name) via `str`.
120
+ */
121
+ function mapMonth(m: unknown): PerdiemMonth {
122
+ const it = (m ?? {}) as Record<string, unknown>;
123
+ return {
124
+ month: num(it.number),
125
+ monthName: str(it.long),
126
+ lodgingUsd: num(it.value),
127
+ };
128
+ }
129
+
130
+ // ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
131
+ /**
132
+ * GET one GSA per-diem JSON resource. `path` is a fully-assembled, pre-escaped
133
+ * path (NO query params — the key rides in the X-Api-Key header only). Builds
134
+ * `https://${GSA_PERDIEM_HOST}${path}` on the FIXED host, asserts the CONSTRUCTED
135
+ * URL's hostname === the host over https (belt-and-suspenders), sets
136
+ * `redirect:"error"` (an off-host 3xx must NOT be followed — it could carry the
137
+ * X-Api-Key header to a foreign host), and attaches the key ONLY in the header.
138
+ */
139
+ async function getGsaPerdiem(path: string): Promise<unknown> {
140
+ const url = `https://${GSA_PERDIEM_HOST}${path}`;
141
+ const built = new URL(url);
142
+ if (built.hostname !== GSA_PERDIEM_HOST || built.protocol !== "https:") {
143
+ throw new ToolErrorCarrier({
144
+ kind: "invalid_input",
145
+ message: `Constructed GSA per-diem URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(GSA_PERDIEM_HOST)} over https — refusing to fetch (SSRF safety).`,
146
+ retryable: false,
147
+ upstreamEndpoint: GSA_PERDIEM_LABEL,
148
+ });
149
+ }
150
+ // The key rides in the X-Api-Key header ONLY (never the URL/label/_meta);
151
+ // redirect:"error" (fail closed on any off-host 3xx).
152
+ return getJson(url, {
153
+ label: GSA_PERDIEM_LABEL,
154
+ headers: keyHeader(),
155
+ redirect: "error",
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Look up GSA Federal Travel per-diem rates by EITHER (city + state) OR zip, for a
161
+ * given `year` (default 2025). Returns flattened rate rows (each outer state/year
162
+ * group × inner city/rate) + honest `_meta`: totalAvailable = the row count (no
163
+ * pagination — P1), lodging/meals as null-never-0 dollars (P3), standardRate/isOconus
164
+ * as real booleans, the months array preserved as-is. The DEMO_KEY rate disclosure
165
+ * rides in the notes.
166
+ */
167
+ export async function perdiemRates(
168
+ args: GsaPerdiemRatesArgs,
169
+ ): Promise<MetaBundle> {
170
+ const label = GSA_PERDIEM_LABEL;
171
+ const year = args.year ?? DEFAULT_PERDIEM_YEAR;
172
+
173
+ // ── [INPUT] EITHER (city + state) OR zip — never both, never neither. This is a
174
+ // caller-shape check (0 fetch): an ambiguous or empty lookup is invalid_input,
175
+ // never a silent guess. ──
176
+ const hasCityState = args.city !== undefined || args.state !== undefined;
177
+ const hasZip = args.zip !== undefined;
178
+ if (hasCityState && hasZip) {
179
+ throw new ToolErrorCarrier({
180
+ kind: "invalid_input",
181
+ retryable: false,
182
+ message:
183
+ "Provide EITHER (city + state) OR zip — not both. City/state and ZIP are two distinct lookup modes; supplying both is ambiguous.",
184
+ upstreamEndpoint: label,
185
+ });
186
+ }
187
+ if (!hasCityState && !hasZip) {
188
+ throw new ToolErrorCarrier({
189
+ kind: "invalid_input",
190
+ retryable: false,
191
+ message:
192
+ "Provide a lookup key: EITHER (city + state, e.g. city:'Washington', state:'DC') OR zip (e.g. zip:'20001').",
193
+ upstreamEndpoint: label,
194
+ });
195
+ }
196
+
197
+ // ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
198
+ // a DIRECT handler call bypasses Zod). year rides in the PATH regardless. ──
199
+ if (!YEAR_RE.test(year)) {
200
+ throw new ToolErrorCarrier({
201
+ kind: "invalid_input",
202
+ retryable: false,
203
+ message: `Invalid year ${JSON.stringify(year)} — expected a 4-digit year (^\\d{4}$), e.g. "2025". (year rides in the request PATH; it is strictly validated.)`,
204
+ upstreamEndpoint: label,
205
+ });
206
+ }
207
+
208
+ let path: string;
209
+ const filtersApplied: string[] = [`year:${year}`];
210
+ let lookupMode: string;
211
+
212
+ if (hasZip) {
213
+ const zip = args.zip as string;
214
+ if (!ZIP_RE.test(zip)) {
215
+ throw new ToolErrorCarrier({
216
+ kind: "invalid_input",
217
+ retryable: false,
218
+ message: `Invalid zip ${JSON.stringify(zip)} — expected a 5-digit ZIP code (^\\d{5}$), e.g. "20001".`,
219
+ upstreamEndpoint: label,
220
+ });
221
+ }
222
+ lookupMode = `zip:${zip}`;
223
+ filtersApplied.push(lookupMode);
224
+ // Fixed template; each segment encodeURIComponent-escaped (belt-and-suspenders
225
+ // behind the charclass — no path injection, no query steer).
226
+ path = `${GSA_PERDIEM_BASE}/rates/zip/${encodeURIComponent(zip)}/year/${encodeURIComponent(year)}`;
227
+ } else {
228
+ // city + state — BOTH are required together for this mode.
229
+ if (args.city === undefined || args.state === undefined) {
230
+ throw new ToolErrorCarrier({
231
+ kind: "invalid_input",
232
+ retryable: false,
233
+ message:
234
+ "The city lookup mode requires BOTH city AND state (e.g. city:'Washington', state:'DC'). Provide both, or use zip instead.",
235
+ upstreamEndpoint: label,
236
+ });
237
+ }
238
+ const city = args.city;
239
+ const state = args.state;
240
+ if (!CITY_RE.test(city)) {
241
+ throw new ToolErrorCarrier({
242
+ kind: "invalid_input",
243
+ retryable: false,
244
+ message: `Invalid city ${JSON.stringify(city)} — expected 1–60 letters/spaces/.'- (^[A-Za-z .'\\-]{1,60}$), e.g. "Washington".`,
245
+ upstreamEndpoint: label,
246
+ });
247
+ }
248
+ if (!STATE_RE.test(state)) {
249
+ throw new ToolErrorCarrier({
250
+ kind: "invalid_input",
251
+ retryable: false,
252
+ message: `Invalid state ${JSON.stringify(state)} — expected a 2-letter state code (^[A-Za-z]{2}$), e.g. "DC", "CA".`,
253
+ upstreamEndpoint: label,
254
+ });
255
+ }
256
+ lookupMode = `city:${city}, state:${state}`;
257
+ filtersApplied.push(`city:${city}`, `state:${state}`);
258
+ path = `${GSA_PERDIEM_BASE}/rates/city/${encodeURIComponent(city)}/state/${encodeURIComponent(state)}/year/${encodeURIComponent(year)}`;
259
+ }
260
+
261
+ // ── The typed catch-ladder (datagov-catalog searchDatasets shape, VERBATIM).
262
+ // Preserve the 429/404/5xx/400/timeout ToolErrorCarrier taxonomy FIRST
263
+ // (LOAD-BEARING: the DEMO_KEY-~10/hr 429→rate_limited frontier would regress to
264
+ // schema_drift under a broader catch); reclassify a 200 non-JSON `.json()`
265
+ // SyntaxError to schema_drift SECOND; bare-rethrow LAST. The host-assert
266
+ // ToolErrorCarrier is also rethrown first. ──
267
+ let body: unknown;
268
+ try {
269
+ body = await getGsaPerdiem(path);
270
+ } catch (e) {
271
+ if (e instanceof ToolErrorCarrier) throw e;
272
+ if (e instanceof SyntaxError)
273
+ throw driftError(
274
+ label,
275
+ "GSA per-diem returned a non-JSON body at HTTP 200 — schema drift.",
276
+ );
277
+ throw e;
278
+ }
279
+
280
+ const b = (body ?? {}) as { errors?: unknown; rates?: unknown };
281
+
282
+ // ── [P2] `errors` non-null ⇒ a lookup problem ⇒ invalid_input surfacing the
283
+ // message (NEVER a fake empty — swallowing this as empty ⇒ RED). ──
284
+ if (b.errors !== null && b.errors !== undefined) {
285
+ const msg =
286
+ typeof b.errors === "string" ? b.errors : JSON.stringify(b.errors);
287
+ throw new ToolErrorCarrier({
288
+ kind: "invalid_input",
289
+ retryable: false,
290
+ message: `GSA per-diem reported a lookup error for ${lookupMode} (year ${year}): ${msg}`,
291
+ upstreamEndpoint: label,
292
+ });
293
+ }
294
+
295
+ // ── [P4] `rates` MUST be an array (a missing/object/null rates is drift, never a
296
+ // fabricated empty — a TypeError must never mask drift as upstream_unavailable). ──
297
+ if (!Array.isArray(b.rates)) {
298
+ throw driftError(
299
+ label,
300
+ "GSA per-diem shape drift — response.rates must be an array.",
301
+ );
302
+ }
303
+
304
+ // ── Flatten: each outer state/year group × its inner rate[]. ──
305
+ const rows: PerdiemRate[] = [];
306
+ for (const group of b.rates as unknown[]) {
307
+ const g = (group ?? {}) as Record<string, unknown>;
308
+ // [P4] a group's `rate` MUST be an array (never a fabricated empty).
309
+ if (!Array.isArray(g.rate)) {
310
+ throw driftError(
311
+ label,
312
+ "GSA per-diem shape drift — a rates[].rate must be an array.",
313
+ );
314
+ }
315
+ const gState = str(g.state);
316
+ const gYear = num(g.year);
317
+ const gOconus = strBool(g.isOconus);
318
+ for (const rate of g.rate as unknown[]) {
319
+ const r = (rate ?? {}) as Record<string, unknown>;
320
+ const monthsObj = (r.months ?? {}) as Record<string, unknown>;
321
+ // [P4] months.month MUST be an array (never padded/fabricated to 12).
322
+ if (!Array.isArray(monthsObj.month)) {
323
+ throw driftError(
324
+ label,
325
+ "GSA per-diem shape drift — a rate's months.month must be an array.",
326
+ );
327
+ }
328
+ rows.push({
329
+ city: str(r.city),
330
+ county: str(r.county),
331
+ state: gState,
332
+ zip: str(r.zip),
333
+ year: gYear,
334
+ isOconus: gOconus,
335
+ standardRate: strBool(r.standardRate),
336
+ mealsUsd: num(r.meals),
337
+ monthlyLodgingUsd: (monthsObj.month as unknown[]).map(mapMonth),
338
+ });
339
+ }
340
+ }
341
+
342
+ const returned = rows.length;
343
+ const notes: string[] = [RATE_MEANING_NOTE, STANDARD_RATE_NOTE, NO_PAGINATION_NOTE];
344
+ pushKeyNote(notes);
345
+
346
+ return withMeta(
347
+ { rates: rows },
348
+ {
349
+ source: GSA_PERDIEM_SOURCE(keyModeLabel()),
350
+ keylessMode: false, // keyed via the api.data.gov X-Api-Key (DEMO_KEY default)
351
+ returned,
352
+ // [P1] the COMPLETE set for the lookup (no pagination) ⇒ totalAvailable = the
353
+ // row count; complete is DERIVED true by buildMeta (returned === total).
354
+ totalAvailable: returned,
355
+ filtersApplied,
356
+ filtersDropped: [],
357
+ fieldsUnavailable: [],
358
+ notes,
359
+ } satisfies Partial<ResponseMeta>,
360
+ );
361
+ }