@cliwant/mcp-sam-gov 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +5 -5
- package/README.ko.md +5 -5
- package/README.md +19 -7
- package/dist/arcgis-feature.d.ts +68 -0
- package/dist/arcgis-feature.d.ts.map +1 -0
- package/dist/arcgis-feature.js +206 -0
- package/dist/arcgis-feature.js.map +1 -0
- package/dist/arcgis-hub.d.ts +90 -0
- package/dist/arcgis-hub.d.ts.map +1 -0
- package/dist/arcgis-hub.js +210 -0
- package/dist/arcgis-hub.js.map +1 -0
- package/dist/bonfire.d.ts +69 -0
- package/dist/bonfire.d.ts.map +1 -0
- package/dist/bonfire.js +212 -0
- package/dist/bonfire.js.map +1 -0
- package/dist/cbp-border.d.ts +9 -5
- package/dist/cbp-border.d.ts.map +1 -1
- package/dist/cbp-border.js +31 -11
- package/dist/cbp-border.js.map +1 -1
- package/dist/gao.d.ts.map +1 -1
- package/dist/gao.js +25 -0
- package/dist/gao.js.map +1 -1
- package/dist/grants.js +1 -1
- package/dist/grants.js.map +1 -1
- package/dist/gsa-perdiem.d.ts +16 -2
- package/dist/gsa-perdiem.d.ts.map +1 -1
- package/dist/gsa-perdiem.js +25 -4
- package/dist/gsa-perdiem.js.map +1 -1
- package/dist/integrity.d.ts.map +1 -1
- package/dist/integrity.js +18 -3
- package/dist/integrity.js.map +1 -1
- package/dist/lda.d.ts.map +1 -1
- package/dist/lda.js +17 -4
- package/dist/lda.js.map +1 -1
- package/dist/nhtsa.d.ts +9 -5
- package/dist/nhtsa.d.ts.map +1 -1
- package/dist/nhtsa.js +90 -26
- package/dist/nhtsa.js.map +1 -1
- package/dist/nist-controls.d.ts +3 -1
- package/dist/nist-controls.d.ts.map +1 -1
- package/dist/nist-controls.js +34 -12
- package/dist/nist-controls.js.map +1 -1
- package/dist/ofac.d.ts.map +1 -1
- package/dist/ofac.js +10 -1
- package/dist/ofac.js.map +1 -1
- package/dist/opengov.d.ts +96 -0
- package/dist/opengov.d.ts.map +1 -0
- package/dist/opengov.js +244 -0
- package/dist/opengov.js.map +1 -0
- package/dist/pricing.d.ts +1 -1
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +13 -4
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +199 -8
- package/dist/server.js.map +1 -1
- package/dist/socrata.d.ts +27 -16
- package/dist/socrata.d.ts.map +1 -1
- package/dist/socrata.js +149 -18
- package/dist/socrata.js.map +1 -1
- package/package.json +11 -3
- package/src/arcgis-feature.ts +232 -0
- package/src/arcgis-hub.ts +270 -0
- package/src/bonfire.ts +249 -0
- package/src/cbp-border.ts +30 -9
- package/src/gao.ts +27 -0
- package/src/grants.ts +1 -1
- package/src/gsa-perdiem.ts +28 -4
- package/src/integrity.ts +18 -3
- package/src/lda.ts +21 -5
- package/src/nhtsa.ts +92 -27
- package/src/nist-controls.ts +58 -9
- package/src/ofac.ts +12 -1
- package/src/opengov.ts +309 -0
- package/src/sam-gov/client.ts +13 -4
- package/src/server.ts +213 -8
- package/src/socrata.ts +153 -18
package/src/nhtsa.ts
CHANGED
|
@@ -19,16 +19,20 @@
|
|
|
19
19
|
* entirely — never surfaced, logged, or stored. The B2G value is the
|
|
20
20
|
* manufacturer / component / safety signal, NOT the VIN.
|
|
21
21
|
*
|
|
22
|
-
* ★ THE HONESTY PILLARS (P1
|
|
22
|
+
* ★ THE HONESTY PILLARS (P1/P3/P4 live-verified 2026-07-15; P2 corrected +
|
|
23
|
+
* re-verified 2026-07-20):
|
|
23
24
|
* P1: totalAvailable = `Count` (recalls) / `count` (complaints) — the REAL total.
|
|
24
25
|
* NHTSA returns the COMPLETE filtered set (no pagination), so in the normal
|
|
25
26
|
* case Count === results.length ⇒ complete:true. totalAvailable is NEVER
|
|
26
27
|
* fabricated: a PRESENT numeric Count is trusted verbatim; a MISSING Count
|
|
27
28
|
* falls back to results.length WITH an honest note (never invented).
|
|
28
|
-
* P2:
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* P2: ★NHTSA returns HTTP 400 (NOT 200) + {Count/count:0, results:[]} (Message
|
|
30
|
+
* "Results returned successfully") for a VALID make/model/year that simply
|
|
31
|
+
* has ZERO records (live-verified 2026-07-20). getNhtsa reads the body and
|
|
32
|
+
* reclassifies THAT idiom as an HONEST EMPTY (returned:0, totalAvailable:0,
|
|
33
|
+
* complete:true) with a note; any OTHER 400 ⇒ invalid_input, a 404 ⇒
|
|
34
|
+
* not_found, a 5xx/timeout ⇒ THROW (never a fake empty), a 200 non-JSON
|
|
35
|
+
* body ⇒ schema_drift.
|
|
32
36
|
* P3: booleans (crash/fire/parkIt/parkOutSide/overTheAirUpdate) preserved AS
|
|
33
37
|
* booleans (a non-boolean ⇒ null, never a fabricated false); counts
|
|
34
38
|
* (numberOfInjuries/numberOfDeaths) via `num` (a genuine 0 stays 0, NEVER
|
|
@@ -42,8 +46,8 @@
|
|
|
42
46
|
* so a `../` or `%` can never reach the fixed path).
|
|
43
47
|
*/
|
|
44
48
|
|
|
45
|
-
import { ToolErrorCarrier } from "./errors.js";
|
|
46
|
-
import {
|
|
49
|
+
import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
|
|
50
|
+
import { driftError, isRedirectError } from "./datasource.js";
|
|
47
51
|
import { num, str } from "./coerce.js";
|
|
48
52
|
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
49
53
|
|
|
@@ -109,10 +113,31 @@ function validateVehicleArgs(args: NhtsaVehicleArgs, label: string): void {
|
|
|
109
113
|
|
|
110
114
|
// ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
|
|
111
115
|
/**
|
|
112
|
-
*
|
|
113
|
-
* `
|
|
114
|
-
*
|
|
115
|
-
* `
|
|
116
|
+
* ★ NHTSA's "400-for-empty" idiom. A VALID make/model/year that simply has ZERO
|
|
117
|
+
* records returns HTTP 400 with a body `{Count/count:0, ..., results:[]}` and
|
|
118
|
+
* `Message/message:"Results returned successfully"` (live-verified 2026-07-20:
|
|
119
|
+
* `make=tesla&model=model 3&modelYear=2015` → HTTP 400, Count 0). Recalls use
|
|
120
|
+
* `Count`/`Message`, complaints use lowercase `count`/`message` — accept either.
|
|
121
|
+
* This is a genuine no-match, NOT a bad request; the caller reclassifies it to an
|
|
122
|
+
* honest empty. (A 400 that is NOT this idiom stays a real invalid_input.)
|
|
123
|
+
*/
|
|
124
|
+
function isNhtsaEmptyIdiom(body: unknown): boolean {
|
|
125
|
+
if (body === null || typeof body !== "object") return false;
|
|
126
|
+
const b = body as Record<string, unknown>;
|
|
127
|
+
const rawCount = b.Count ?? b.count;
|
|
128
|
+
return num(rawCount) === 0 && Array.isArray(b.results) && b.results.length === 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* GET one NHTSA JSON resource on the FIXED host. Bespoke `fetch` (NOT the shared
|
|
133
|
+
* getJson) because we must READ a non-2xx body: NHTSA returns HTTP 400 for a valid
|
|
134
|
+
* query with zero records (the ★400-for-empty idiom above), and getJson would
|
|
135
|
+
* throw invalid_input and DISCARD the body — turning "zero recalls" (a valid,
|
|
136
|
+
* high-value answer) into a phantom "Bad request". Asserts the constructed URL's
|
|
137
|
+
* hostname === the fixed host over https (belt-and-suspenders) + `redirect:"error"`
|
|
138
|
+
* (fail closed on any off-host 3xx). Keyless — no header/token. Non-2xx that is NOT
|
|
139
|
+
* the empty idiom keeps the standard taxonomy (400→invalid_input, 404→not_found,
|
|
140
|
+
* 429→rate_limited, 5xx→upstream_unavailable); a 200 non-JSON body ⇒ schema_drift.
|
|
116
141
|
*/
|
|
117
142
|
async function getNhtsa(
|
|
118
143
|
path: string,
|
|
@@ -129,7 +154,57 @@ async function getNhtsa(
|
|
|
129
154
|
upstreamEndpoint: label,
|
|
130
155
|
});
|
|
131
156
|
}
|
|
132
|
-
|
|
157
|
+
let res: Response;
|
|
158
|
+
try {
|
|
159
|
+
res = await fetch(built.toString(), {
|
|
160
|
+
redirect: "error",
|
|
161
|
+
signal: AbortSignal.timeout(15_000),
|
|
162
|
+
});
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (isRedirectError(e)) {
|
|
165
|
+
throw driftError(
|
|
166
|
+
label,
|
|
167
|
+
`NHTSA returned an off-host redirect (redirect:"error") while fetching ${label} — refusing to follow it (SSRF safety).`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
171
|
+
throw new ToolErrorCarrier({
|
|
172
|
+
kind: "upstream_unavailable",
|
|
173
|
+
retryable: false,
|
|
174
|
+
message: `Request to ${label} timed out.`,
|
|
175
|
+
upstreamEndpoint: label,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
throw new ToolErrorCarrier({
|
|
179
|
+
kind: "upstream_unavailable",
|
|
180
|
+
retryable: true,
|
|
181
|
+
retryAfterSeconds: 30,
|
|
182
|
+
message: `Network error reaching ${label}: ${e instanceof Error ? e.message : String(e)}`,
|
|
183
|
+
upstreamEndpoint: label,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
// ★P2 CRUX: read the body — a 400 that is NHTSA's empty idiom is an HONEST
|
|
188
|
+
// no-match (returned via resolveTotal as Count 0 → total 0), NOT an error.
|
|
189
|
+
let body: unknown = null;
|
|
190
|
+
try {
|
|
191
|
+
body = await res.json();
|
|
192
|
+
} catch {
|
|
193
|
+
body = null;
|
|
194
|
+
}
|
|
195
|
+
if (res.status === 400 && isNhtsaEmptyIdiom(body)) return body;
|
|
196
|
+
// Any other non-2xx keeps the standard taxonomy (never a fabricated empty).
|
|
197
|
+
// errorFromResponse returns a plain ToolError → wrap it in the carrier to throw.
|
|
198
|
+
throw new ToolErrorCarrier(errorFromResponse(res, label));
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
return await res.json();
|
|
202
|
+
} catch {
|
|
203
|
+
throw driftError(
|
|
204
|
+
label,
|
|
205
|
+
`NHTSA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
133
208
|
}
|
|
134
209
|
|
|
135
210
|
/** Build the shared make/model/modelYear query (module-built; no raw passthrough). */
|
|
@@ -142,27 +217,17 @@ function vehicleParams(args: NhtsaVehicleArgs): URLSearchParams {
|
|
|
142
217
|
}
|
|
143
218
|
|
|
144
219
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* error
|
|
220
|
+
* Build the vehicle query and fetch through getNhtsa, which fully classifies the
|
|
221
|
+
* response: the ★400-for-empty idiom → an honest empty; every other non-2xx → the
|
|
222
|
+
* standard taxonomy; a 200 non-JSON body → schema_drift. A thin wrapper — getNhtsa
|
|
223
|
+
* owns the error contract.
|
|
149
224
|
*/
|
|
150
225
|
async function fetchNhtsa(
|
|
151
226
|
path: string,
|
|
152
227
|
label: string,
|
|
153
228
|
args: NhtsaVehicleArgs,
|
|
154
229
|
): Promise<unknown> {
|
|
155
|
-
|
|
156
|
-
return await getNhtsa(path, label, vehicleParams(args));
|
|
157
|
-
} catch (e) {
|
|
158
|
-
if (e instanceof ToolErrorCarrier) throw e;
|
|
159
|
-
if (e instanceof SyntaxError)
|
|
160
|
-
throw driftError(
|
|
161
|
-
label,
|
|
162
|
-
`NHTSA ${label} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
|
|
163
|
-
);
|
|
164
|
-
throw e;
|
|
165
|
-
}
|
|
230
|
+
return await getNhtsa(path, label, vehicleParams(args));
|
|
166
231
|
}
|
|
167
232
|
|
|
168
233
|
/**
|
package/src/nist-controls.ts
CHANGED
|
@@ -46,8 +46,10 @@ export type NistControl = {
|
|
|
46
46
|
id: string; // display form, e.g. "AC-2"
|
|
47
47
|
family: string; // e.g. "AC — Access Control"
|
|
48
48
|
title: string;
|
|
49
|
-
|
|
49
|
+
status: string | null; // OSCAL status prop, e.g. "withdrawn"; null when active
|
|
50
|
+
statement: string | null; // requirement prose; NULL when absent (a WITHDRAWN control has none) — never ""
|
|
50
51
|
guidance: string | null; // discussion prose
|
|
52
|
+
incorporatedInto: string[]; // withdrawn control ⇒ the control id(s) it was folded into (e.g. ["AC-2","AU-6"])
|
|
51
53
|
enhancements: { id: string; title: string }[]; // e.g. AC-2(1)
|
|
52
54
|
};
|
|
53
55
|
|
|
@@ -60,6 +62,8 @@ type OscalPart = {
|
|
|
60
62
|
type OscalControl = {
|
|
61
63
|
id?: string;
|
|
62
64
|
title?: string;
|
|
65
|
+
props?: { name?: string; value?: string }[];
|
|
66
|
+
links?: { href?: string; rel?: string }[];
|
|
63
67
|
parts?: OscalPart[];
|
|
64
68
|
controls?: OscalControl[];
|
|
65
69
|
};
|
|
@@ -100,12 +104,26 @@ function partProse(control: OscalControl, name: string): string | null {
|
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
function mapControl(control: OscalControl, family: string): NistControl {
|
|
107
|
+
const status =
|
|
108
|
+
(control.props ?? []).find((p) => p.name === "status")?.value?.trim() || null;
|
|
109
|
+
// A withdrawn control carries `links[] rel="incorporated-into"` → the control(s)
|
|
110
|
+
// it was folded into (e.g. AC-13 → AC-2, AU-6). Surface them so a withdrawn
|
|
111
|
+
// control is never mistaken for an active requirement.
|
|
112
|
+
const incorporatedInto = (control.links ?? [])
|
|
113
|
+
.filter((l) => l.rel === "incorporated-into" && typeof l.href === "string")
|
|
114
|
+
.map((l) => displayId(String(l.href).replace(/^#/, "")))
|
|
115
|
+
.filter((x) => x.length > 0);
|
|
103
116
|
return {
|
|
104
117
|
id: displayId(control.id ?? ""),
|
|
105
118
|
family,
|
|
106
119
|
title: (control.title ?? "").trim(),
|
|
107
|
-
|
|
120
|
+
status,
|
|
121
|
+
// [P3] absent statement ⇒ null, NEVER "" — a withdrawn control has no
|
|
122
|
+
// requirement text; "" would misread as "an active control with a blank
|
|
123
|
+
// requirement".
|
|
124
|
+
statement: partProse(control, "statement"),
|
|
108
125
|
guidance: partProse(control, "guidance"),
|
|
126
|
+
incorporatedInto,
|
|
109
127
|
enhancements: (control.controls ?? []).map((e) => ({
|
|
110
128
|
id: displayId(e.id ?? ""),
|
|
111
129
|
title: (e.title ?? "").trim(),
|
|
@@ -119,7 +137,12 @@ function mapControl(control: OscalControl, family: string): NistControl {
|
|
|
119
137
|
* families (else driftError — a truncated catalog is NEVER a fake empty). Enhancements
|
|
120
138
|
* are carried on each control (not indexed as top-level entries).
|
|
121
139
|
*/
|
|
122
|
-
|
|
140
|
+
type OscalMetadata = { version: string | null; lastModified: string | null };
|
|
141
|
+
|
|
142
|
+
async function loadControls(): Promise<{
|
|
143
|
+
controls: NistControl[];
|
|
144
|
+
metadata: OscalMetadata;
|
|
145
|
+
}> {
|
|
123
146
|
return memoize(
|
|
124
147
|
"nist:sp800-53r5",
|
|
125
148
|
async () => {
|
|
@@ -131,7 +154,12 @@ async function loadControls(): Promise<NistControl[]> {
|
|
|
131
154
|
label: OSCAL_LABEL,
|
|
132
155
|
redirect: "error",
|
|
133
156
|
timeoutMs: OSCAL_TIMEOUT_MS,
|
|
134
|
-
})) as {
|
|
157
|
+
})) as {
|
|
158
|
+
catalog?: {
|
|
159
|
+
groups?: unknown;
|
|
160
|
+
metadata?: { version?: unknown; "last-modified"?: unknown };
|
|
161
|
+
};
|
|
162
|
+
};
|
|
135
163
|
const groups = body.catalog?.groups;
|
|
136
164
|
if (!Array.isArray(groups) || groups.length < FAMILY_FLOOR) {
|
|
137
165
|
throw driftError(
|
|
@@ -139,12 +167,21 @@ async function loadControls(): Promise<NistControl[]> {
|
|
|
139
167
|
`OSCAL catalog.groups missing or implausibly small (${Array.isArray(groups) ? groups.length : "not-an-array"} < ${FAMILY_FLOOR} families) — treating as schema drift / truncation, never a fake-empty catalog.`,
|
|
140
168
|
);
|
|
141
169
|
}
|
|
170
|
+
// [P5 freshness] The OSCAL catalog is a moving static file on the `main`
|
|
171
|
+
// branch — surface its exact version + last-modified so a compliance caller
|
|
172
|
+
// knows WHICH point-release (5.1.1 / 5.2.0 …) they are reading.
|
|
173
|
+
const md = body.catalog?.metadata ?? {};
|
|
174
|
+
const metadata: OscalMetadata = {
|
|
175
|
+
version: typeof md.version === "string" ? md.version : null,
|
|
176
|
+
lastModified:
|
|
177
|
+
typeof md["last-modified"] === "string" ? md["last-modified"] : null,
|
|
178
|
+
};
|
|
142
179
|
const controls: NistControl[] = [];
|
|
143
180
|
for (const g of groups as OscalGroup[]) {
|
|
144
181
|
const family = `${(g.id ?? "").toUpperCase()} — ${(g.title ?? "").trim()}`;
|
|
145
182
|
for (const c of g.controls ?? []) controls.push(mapControl(c, family));
|
|
146
183
|
}
|
|
147
|
-
return controls;
|
|
184
|
+
return { controls, metadata };
|
|
148
185
|
},
|
|
149
186
|
OSCAL_CACHE_TTL_MS,
|
|
150
187
|
);
|
|
@@ -166,7 +203,7 @@ export async function searchControls(args: {
|
|
|
166
203
|
}): Promise<MetaBundle> {
|
|
167
204
|
const limit = args.limit ?? 25;
|
|
168
205
|
const offset = args.offset ?? 0;
|
|
169
|
-
const all = await loadControls();
|
|
206
|
+
const { controls: all, metadata } = await loadControls();
|
|
170
207
|
|
|
171
208
|
const filtersApplied: string[] = [];
|
|
172
209
|
const idQ = args.controlId !== undefined ? displayId(args.controlId) : undefined;
|
|
@@ -189,7 +226,7 @@ export async function searchControls(args: {
|
|
|
189
226
|
// Search the title + requirement statement AND each enhancement's title, so a
|
|
190
227
|
// term that lives only in an enhancement (e.g. "multi-factor" → IA-2(1)) still
|
|
191
228
|
// surfaces the parent control. filtersApplied still lists 'keyword'.
|
|
192
|
-
const hay = `${c.title}\n${c.statement}\n${c.enhancements.map((e) => e.title).join("\n")}`.toLowerCase();
|
|
229
|
+
const hay = `${c.title}\n${c.statement ?? ""}\n${c.enhancements.map((e) => e.title).join("\n")}`.toLowerCase();
|
|
193
230
|
if (!hay.includes(kwQ)) return false;
|
|
194
231
|
}
|
|
195
232
|
return true;
|
|
@@ -201,10 +238,22 @@ export async function searchControls(args: {
|
|
|
201
238
|
const hasMore = offset + returned < totalAvailable;
|
|
202
239
|
const nextOffset = hasMore ? offset + returned : null;
|
|
203
240
|
|
|
241
|
+
const versionLabel = metadata.version ? `Rev ${metadata.version}` : "Rev 5";
|
|
242
|
+
const notes: string[] = [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, REFERENCE_NOTE];
|
|
243
|
+
notes.push(
|
|
244
|
+
`OSCAL catalog ${metadata.version ? `version ${metadata.version}` : "revision 5"}${metadata.lastModified ? `, last-modified ${metadata.lastModified.slice(0, 10)}` : ""} — fetched live from the usnistgov/oscal-content \`main\` branch (a MOVING target; control text can change between point releases, e.g. 5.1.1 → 5.2.0). Cite the version above, not just "Rev 5".`,
|
|
245
|
+
);
|
|
246
|
+
const withdrawnOnPage = page.filter((c) => c.status === "withdrawn").length;
|
|
247
|
+
if (withdrawnOnPage > 0) {
|
|
248
|
+
notes.push(
|
|
249
|
+
`${withdrawnOnPage} of the returned control(s) are WITHDRAWN (status:"withdrawn") — a withdrawn control has NO requirement text (statement:null) and is NOT an active requirement; see its incorporatedInto for the control(s) that superseded it.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
204
253
|
return withMeta(
|
|
205
254
|
{ controls: page },
|
|
206
255
|
{
|
|
207
|
-
source:
|
|
256
|
+
source: `NIST SP 800-53 ${versionLabel} (OSCAL catalog, keyless)`,
|
|
208
257
|
keylessMode: true,
|
|
209
258
|
returned,
|
|
210
259
|
totalAvailable,
|
|
@@ -213,7 +262,7 @@ export async function searchControls(args: {
|
|
|
213
262
|
filtersDropped: [],
|
|
214
263
|
fieldsUnavailable: [],
|
|
215
264
|
pagination: { offset, limit, hasMore, nextOffset },
|
|
216
|
-
notes
|
|
265
|
+
notes,
|
|
217
266
|
} satisfies Partial<ResponseMeta>,
|
|
218
267
|
);
|
|
219
268
|
}
|
package/src/ofac.ts
CHANGED
|
@@ -206,7 +206,18 @@ export function classifyMatch(
|
|
|
206
206
|
const allQinE = qt.length > 0 && qt.every((t) => eSet.has(t));
|
|
207
207
|
const allEinQ = et.length > 0 && et.every((t) => qSet.has(t));
|
|
208
208
|
if (allQinE || allEinQ) return "strong";
|
|
209
|
-
|
|
209
|
+
// Substring containment is "strong" ONLY when the CONTAINED (shorter) string is
|
|
210
|
+
// a substantial fragment. Without a floor, a tiny sub-word substring graded
|
|
211
|
+
// "strong" ("TS" ⊂ "WIDGETS", "IBB" ⊂ "QUIBBLEFARB", "D" ⊂ "WIDGETS") —
|
|
212
|
+
// overstating confidence and flooding nearly every input with false "strong"
|
|
213
|
+
// matches (alert fatigue). Whole-token containment is already handled above by
|
|
214
|
+
// allQinE/allEinQ; this mid-word tier is for the space-stripped joined form and
|
|
215
|
+
// must clear the same ≥3 bar the token "weak" tier uses — here a slightly higher
|
|
216
|
+
// floor since a contiguous substring is a weaker signal than a shared whole token.
|
|
217
|
+
if (
|
|
218
|
+
Math.min(queryNorm.length, entryNorm.length) >= 5 &&
|
|
219
|
+
(entryNorm.includes(queryNorm) || queryNorm.includes(entryNorm))
|
|
220
|
+
) {
|
|
210
221
|
return "strong";
|
|
211
222
|
}
|
|
212
223
|
for (const t of qt) {
|
package/src/opengov.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opengov.ts — OpenGov Procurement (formerly ProcureNow) public solicitations,
|
|
3
|
+
* a keyless-first SLED bid source (loop cycle — SLED bid campaign, 2026-07-19).
|
|
4
|
+
*
|
|
5
|
+
* WHAT IT ADDS: OpenGov Procurement hosts the live open-solicitation portals of
|
|
6
|
+
* **525+ US state/local governments** (cities, counties, school & special
|
|
7
|
+
* districts across 42 states + DC). The public portal SPA
|
|
8
|
+
* (`procurement.opengov.com/portal/{code}`) is served by an UNAUTHENTICATED
|
|
9
|
+
* backend REST API — the official key-gated `api-key` API is NOT used; this
|
|
10
|
+
* consumes ONLY the anonymous endpoints the public portal itself calls, so it is
|
|
11
|
+
* genuinely keyless (live-verified 2026-07-19: bare anonymous request, no api-key /
|
|
12
|
+
* auth header / cookie). It is the OpenGov sibling of the Socrata/CKAN/ArcGIS
|
|
13
|
+
* discovery tools and one of the two highest-reach keyless SLED bid feeds (with
|
|
14
|
+
* Bonfire RSS).
|
|
15
|
+
*
|
|
16
|
+
* The module writes ZERO fetch/coercion/error/meta code — it REUSES `getJson`
|
|
17
|
+
* (GET + POST/JSON via ADR-0014, redirect:"error") / `driftError` / `num`·`str`
|
|
18
|
+
* (coerce.ts, null-never-0 / null-never-empty) / `withMeta`·`buildMeta`,
|
|
19
|
+
* mirroring datagov-catalog.ts. KEYLESS: no key seam.
|
|
20
|
+
*
|
|
21
|
+
* ★ SSRF: the host is a compile-time literal (`OPENGOV_HOST`). All inputs ride in
|
|
22
|
+
* a MODULE-BUILT JSON body / URLSearchParams from validated typed args — NO
|
|
23
|
+
* raw-host/raw-path passthrough (this is a FIXED-host API). A post-construction
|
|
24
|
+
* hostname/protocol assertion + `redirect:"error"` lock every call.
|
|
25
|
+
*
|
|
26
|
+
* ★ TWO endpoints (both anonymous, live-verified):
|
|
27
|
+
* - `GET /api/v1/government` → a flat JSON array of ~560 org records (the whole
|
|
28
|
+
* directory in one call; `government.code` = the portal slug). We keep the
|
|
29
|
+
* ACTIVE, non-internal ones (~525 real public portals).
|
|
30
|
+
* - `POST /api/v1/project/list` body `{governmentCode, publicView:true, page,
|
|
31
|
+
* limit}` → `{projects:[…], count}`. `publicView:true` is REQUIRED (the public
|
|
32
|
+
* gate). `count` is the TRUE total of public projects for that org.
|
|
33
|
+
*
|
|
34
|
+
* ★ HONESTY PILLARS (captured live 2026-07-19):
|
|
35
|
+
* P1: totalAvailable = the real total — the filtered directory length (govt
|
|
36
|
+
* list) or the API's `count` (solicitations) — NEVER the page length.
|
|
37
|
+
* P2: getJson→fetchWithRetry THROWS on 429 / 5xx / timeout — NEVER a fake empty.
|
|
38
|
+
* A genuine no-match (projects:[], count:0) ⇒ honest empty.
|
|
39
|
+
* P3: every scalar via `str` (null-never-empty) / `num` (null-never-0). The
|
|
40
|
+
* `status` enum is surfaced VERBATIM (open = currently accepting; the response
|
|
41
|
+
* also carries pending/evaluation/closed — the CONSUMER filters; we do not
|
|
42
|
+
* hide non-open rows or fabricate an "open count").
|
|
43
|
+
* P4: `/government` non-array or `/project/list` `.projects` non-array ⇒
|
|
44
|
+
* driftError; a 200 non-JSON body ⇒ schema_drift via the catch-ladder
|
|
45
|
+
* (ToolErrorCarrier rethrow FIRST so a 429/5xx keeps its taxonomy).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
49
|
+
import { getJson, driftError } from "./datasource.js";
|
|
50
|
+
import { num, str } from "./coerce.js";
|
|
51
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
52
|
+
|
|
53
|
+
// Re-export the shared honesty coercion (num-parity guard).
|
|
54
|
+
export { num };
|
|
55
|
+
|
|
56
|
+
// ─── Fixed endpoint (SSRF core — compile-time CONSTANTS) ──────────
|
|
57
|
+
export const OPENGOV_HOST = "api.procurement.opengov.com";
|
|
58
|
+
const OPENGOV_GOVERNMENT_PATH = "/api/v1/government";
|
|
59
|
+
const OPENGOV_PROJECT_LIST_PATH = "/api/v1/project/list";
|
|
60
|
+
const OPENGOV_GOV_LABEL = "opengov:/api/v1/government";
|
|
61
|
+
const OPENGOV_PROJ_LABEL = "opengov:/api/v1/project/list";
|
|
62
|
+
const OPENGOV_SOURCE = "procurement.opengov.com via OpenGov Procurement public API (keyless)";
|
|
63
|
+
const OPENGOV_PORTAL = "https://procurement.opengov.com/portal";
|
|
64
|
+
|
|
65
|
+
// The org slug grammar (SSRF + injection guard). Real slugs are lowercase
|
|
66
|
+
// alnum + hyphen (e.g. "santacruzca", "u-46", "twc-texas-gov"). Validated
|
|
67
|
+
// BEFORE it rides the JSON body — a "../"/space/uppercase ⇒ invalid_input, 0 fetch.
|
|
68
|
+
export const OPENGOV_CODE_RE = /^[a-z0-9-]{1,64}$/;
|
|
69
|
+
|
|
70
|
+
const OPENGOV_STATUS_NOTE =
|
|
71
|
+
"`status` is surfaced verbatim: open = currently ACCEPTING responses; pending/evaluation/closed are also returned (publicView shows all public projects). Filter status==='open' for live bids. totalAvailable is the org's TOTAL public-project count (all statuses), not an open-only count.";
|
|
72
|
+
|
|
73
|
+
// ─── The curated shapes ───────────────────────────────────────────
|
|
74
|
+
export type OpengovGovernment = {
|
|
75
|
+
code: string | null;
|
|
76
|
+
name: string | null;
|
|
77
|
+
city: string | null;
|
|
78
|
+
state: string | null;
|
|
79
|
+
website: string | null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type OpengovSolicitation = {
|
|
83
|
+
id: number | null;
|
|
84
|
+
title: string | null;
|
|
85
|
+
solicitationNumber: string | null; // financialId
|
|
86
|
+
status: string | null;
|
|
87
|
+
type: string | null;
|
|
88
|
+
department: string | null;
|
|
89
|
+
releaseDate: string | null;
|
|
90
|
+
proposalDeadline: string | null; // close/due date
|
|
91
|
+
contactName: string | null;
|
|
92
|
+
link: string | null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** null-preserving boolean (a real true/false survives; else null). */
|
|
96
|
+
function boolOrNull(x: unknown): boolean | null {
|
|
97
|
+
return typeof x === "boolean" ? x : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── SSRF-guarded fetch (fixed host + hostname assertion + redirect) ──
|
|
101
|
+
async function getOpengov(
|
|
102
|
+
path: string,
|
|
103
|
+
label: string,
|
|
104
|
+
init: { method?: "POST"; body?: string } = {},
|
|
105
|
+
): Promise<unknown> {
|
|
106
|
+
const url = `https://${OPENGOV_HOST}${path}`;
|
|
107
|
+
const built = new URL(url);
|
|
108
|
+
if (built.hostname !== OPENGOV_HOST || built.protocol !== "https:") {
|
|
109
|
+
throw new ToolErrorCarrier({
|
|
110
|
+
kind: "invalid_input",
|
|
111
|
+
message: `Constructed OpenGov URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(OPENGOV_HOST)} over https — refusing to fetch (SSRF safety).`,
|
|
112
|
+
retryable: false,
|
|
113
|
+
upstreamEndpoint: label,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const headers: Record<string, string> =
|
|
117
|
+
init.method === "POST" ? { "Content-Type": "application/json" } : {};
|
|
118
|
+
return getJson(url, { label, headers, redirect: "error", ...init });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Tool 1: opengov_list_governments (the directory) ─────────────
|
|
122
|
+
export type OpengovListGovernmentsArgs = {
|
|
123
|
+
state?: string;
|
|
124
|
+
query?: string;
|
|
125
|
+
limit?: number;
|
|
126
|
+
offset?: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* List the OpenGov Procurement government portals (the whole directory arrives in
|
|
131
|
+
* ONE keyless GET). Client-side filters: `state` (2-letter), `query` (name
|
|
132
|
+
* substring). Only ACTIVE, non-internal portals are kept. Feeds `code` to
|
|
133
|
+
* opengov_search_solicitations. totalAvailable = the filtered directory size (P1).
|
|
134
|
+
*/
|
|
135
|
+
export async function listGovernments(
|
|
136
|
+
args: OpengovListGovernmentsArgs,
|
|
137
|
+
): Promise<MetaBundle> {
|
|
138
|
+
const limit = args.limit ?? 50;
|
|
139
|
+
const offset = args.offset ?? 0;
|
|
140
|
+
|
|
141
|
+
let body: unknown;
|
|
142
|
+
try {
|
|
143
|
+
body = await getOpengov(OPENGOV_GOVERNMENT_PATH, OPENGOV_GOV_LABEL);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
146
|
+
if (e instanceof SyntaxError)
|
|
147
|
+
throw driftError(OPENGOV_GOV_LABEL, "OpenGov /government returned a non-JSON body at HTTP 200 — schema drift.");
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
// P4: the response MUST be an array (a missing/object body is drift).
|
|
151
|
+
if (!Array.isArray(body)) {
|
|
152
|
+
throw driftError(OPENGOV_GOV_LABEL, "OpenGov /government shape drift — response must be a JSON array of governments.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const stateFilter = args.state ? args.state.trim().toUpperCase() : null;
|
|
156
|
+
const queryFilter = args.query ? args.query.trim().toLowerCase() : null;
|
|
157
|
+
const filtersApplied: string[] = [];
|
|
158
|
+
if (stateFilter) filtersApplied.push("state");
|
|
159
|
+
if (queryFilter) filtersApplied.push("query");
|
|
160
|
+
|
|
161
|
+
const all: OpengovGovernment[] = [];
|
|
162
|
+
for (const row of body as unknown[]) {
|
|
163
|
+
const r = (row ?? {}) as Record<string, unknown>;
|
|
164
|
+
// Keep only active, non-internal, non-vendor real public portals.
|
|
165
|
+
if (boolOrNull(r.isActive) === false) continue;
|
|
166
|
+
if (boolOrNull(r.isInternal) === true) continue;
|
|
167
|
+
const gov = (r.government ?? {}) as Record<string, unknown>;
|
|
168
|
+
const g: OpengovGovernment = {
|
|
169
|
+
code: str(gov.code),
|
|
170
|
+
name: str(r.name),
|
|
171
|
+
city: str(r.city),
|
|
172
|
+
state: str(r.state),
|
|
173
|
+
website: str(r.website),
|
|
174
|
+
};
|
|
175
|
+
if (g.code === null) continue; // no portal slug ⇒ not queryable
|
|
176
|
+
if (stateFilter && (g.state ?? "").toUpperCase() !== stateFilter) continue;
|
|
177
|
+
if (queryFilter && !(g.name ?? "").toLowerCase().includes(queryFilter)) continue;
|
|
178
|
+
all.push(g);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const totalAvailable = all.length;
|
|
182
|
+
const page = all.slice(offset, offset + limit);
|
|
183
|
+
const returned = page.length;
|
|
184
|
+
const hasMore = offset + returned < totalAvailable;
|
|
185
|
+
const nextOffset = hasMore ? offset + returned : null;
|
|
186
|
+
|
|
187
|
+
const notes: string[] = [
|
|
188
|
+
"The full OpenGov Procurement directory is returned in one keyless call and filtered client-side; totalAvailable is the exact filtered portal count. Feed a result's `code` to opengov_search_solicitations.",
|
|
189
|
+
];
|
|
190
|
+
if (filtersApplied.length === 0) {
|
|
191
|
+
notes.push("No filters applied — this lists ALL active OpenGov portals. Add `state` and/or `query` to scope.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return withMeta(
|
|
195
|
+
{ governments: page },
|
|
196
|
+
{
|
|
197
|
+
source: OPENGOV_SOURCE,
|
|
198
|
+
keylessMode: true,
|
|
199
|
+
returned,
|
|
200
|
+
totalAvailable,
|
|
201
|
+
filtersApplied,
|
|
202
|
+
filtersDropped: [],
|
|
203
|
+
fieldsUnavailable: [],
|
|
204
|
+
pagination: { offset, limit, hasMore, nextOffset },
|
|
205
|
+
notes,
|
|
206
|
+
} satisfies Partial<ResponseMeta>,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Tool 2: opengov_search_solicitations (per org) ───────────────
|
|
211
|
+
export type OpengovSearchSolicitationsArgs = {
|
|
212
|
+
governmentCode: string;
|
|
213
|
+
limit?: number;
|
|
214
|
+
offset?: number;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* List an OpenGov government's public solicitations (POST /project/list with the
|
|
219
|
+
* REQUIRED publicView gate). Returns all public projects with `status` surfaced
|
|
220
|
+
* (filter status==='open' for live bids). totalAvailable = the API's `count` (the
|
|
221
|
+
* org's total public-project count — P1, never the page length).
|
|
222
|
+
*/
|
|
223
|
+
export async function searchSolicitations(
|
|
224
|
+
args: OpengovSearchSolicitationsArgs,
|
|
225
|
+
): Promise<MetaBundle> {
|
|
226
|
+
const limit = args.limit ?? 50;
|
|
227
|
+
const offset = args.offset ?? 0;
|
|
228
|
+
const code = args.governmentCode ?? "";
|
|
229
|
+
|
|
230
|
+
// Belt-and-suspenders slug grammar (behind the server's Zod). A bad slug would
|
|
231
|
+
// ride the JSON body and either error or mis-scope — reject pre-fetch (0 fetch).
|
|
232
|
+
if (!OPENGOV_CODE_RE.test(code)) {
|
|
233
|
+
throw new ToolErrorCarrier({
|
|
234
|
+
kind: "invalid_input",
|
|
235
|
+
message: `Invalid OpenGov governmentCode ${JSON.stringify(code)} — expected a lowercase-alnum/hyphen portal slug (≤64 chars) from opengov_list_governments (e.g. 'santacruzca', 'u-46').`,
|
|
236
|
+
retryable: false,
|
|
237
|
+
upstreamEndpoint: OPENGOV_PROJ_LABEL,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The API paginates by 0-based `page` + `limit`. Map our offset → page.
|
|
242
|
+
const page = Math.floor(offset / limit);
|
|
243
|
+
const servedOffset = page * limit;
|
|
244
|
+
const payload = JSON.stringify({ governmentCode: code, publicView: true, page, limit });
|
|
245
|
+
|
|
246
|
+
let body: unknown;
|
|
247
|
+
try {
|
|
248
|
+
body = await getOpengov(OPENGOV_PROJECT_LIST_PATH, OPENGOV_PROJ_LABEL, { method: "POST", body: payload });
|
|
249
|
+
} catch (e) {
|
|
250
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
251
|
+
if (e instanceof SyntaxError)
|
|
252
|
+
throw driftError(OPENGOV_PROJ_LABEL, "OpenGov /project/list returned a non-JSON body at HTTP 200 — schema drift.");
|
|
253
|
+
throw e;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const b = (body ?? {}) as { projects?: unknown; count?: unknown };
|
|
257
|
+
// P4: `projects` MUST be an array (a missing/object projects is drift).
|
|
258
|
+
if (!Array.isArray(b.projects)) {
|
|
259
|
+
throw driftError(OPENGOV_PROJ_LABEL, "OpenGov /project/list shape drift — response.projects must be an array.");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const solicitations: OpengovSolicitation[] = (b.projects as unknown[]).map((row) => {
|
|
263
|
+
const r = (row ?? {}) as Record<string, unknown>;
|
|
264
|
+
const id = num(r.id);
|
|
265
|
+
const codeForLink = code;
|
|
266
|
+
return {
|
|
267
|
+
id,
|
|
268
|
+
title: str(r.title),
|
|
269
|
+
solicitationNumber: str(r.financialId),
|
|
270
|
+
status: str(r.status),
|
|
271
|
+
type: str(r.type),
|
|
272
|
+
department: str(r.departmentName),
|
|
273
|
+
releaseDate: str(r.releaseProjectDate ?? r.postedAt),
|
|
274
|
+
proposalDeadline: str(r.proposalDeadline),
|
|
275
|
+
contactName: [str(r.contactFirstName), str(r.contactLastName)].filter((v) => v !== null).join(" ") || null,
|
|
276
|
+
link: id !== null ? `${OPENGOV_PORTAL}/${codeForLink}/projects/${id}` : null,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
const returned = solicitations.length;
|
|
280
|
+
|
|
281
|
+
// P1: totalAvailable = the API `count` (total public projects), NEVER page length.
|
|
282
|
+
const totalAvailable = num(b.count);
|
|
283
|
+
const hasMore =
|
|
284
|
+
totalAvailable !== null ? servedOffset + returned < totalAvailable : returned >= limit;
|
|
285
|
+
const nextOffset = hasMore ? servedOffset + returned : null;
|
|
286
|
+
|
|
287
|
+
const notes: string[] = [OPENGOV_STATUS_NOTE];
|
|
288
|
+
if (servedOffset !== offset) {
|
|
289
|
+
notes.push(`offset was snapped to the page boundary ${servedOffset} (OpenGov paginates by fixed pages of ${limit}).`);
|
|
290
|
+
}
|
|
291
|
+
if (totalAvailable === null) {
|
|
292
|
+
notes.push("totalAvailable is unknown (the API omitted `count`); completeness inferred from page fullness.");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return withMeta(
|
|
296
|
+
{ governmentCode: code, solicitations },
|
|
297
|
+
{
|
|
298
|
+
source: OPENGOV_SOURCE,
|
|
299
|
+
keylessMode: true,
|
|
300
|
+
returned,
|
|
301
|
+
totalAvailable,
|
|
302
|
+
filtersApplied: ["governmentCode", "publicView"],
|
|
303
|
+
filtersDropped: [],
|
|
304
|
+
fieldsUnavailable: [],
|
|
305
|
+
pagination: { offset: servedOffset, limit, hasMore, nextOffset },
|
|
306
|
+
notes,
|
|
307
|
+
} satisfies Partial<ResponseMeta>,
|
|
308
|
+
);
|
|
309
|
+
}
|