@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,237 +1,237 @@
1
- /**
2
- * get.gov — the authoritative US .gov domain registry (CISA). KEYLESS.
3
- *
4
- * The .gov program (run by CISA) publishes the COMPLETE registry as CSVs in its
5
- * official repo github.com/cisagov/dotgov-data — the canonical published location
6
- * (get.gov links there; there is no query API for the full set). It is NOT a .gov
7
- * API host, so provenance is disclosed on every response (the ProPublica /
8
- * CourtListener republisher pattern — except here CISA is the first-party registrar).
9
- *
10
- * B2G value: resolve WHICH organization owns a .gov domain, enumerate federal
11
- * agencies, and MAP SLED entities (state / county / city / school-district /
12
- * special-district / tribal) for market targeting — a distinct authoritative
13
- * gov-org registry no other tool here exposes.
14
- *
15
- * SSRF: fixed host `raw.githubusercontent.com` + fixed path prefix
16
- * `/cisagov/dotgov-data/main/` + a scope-selected FIXED filename (federal | full) —
17
- * no free host, path, or filename. `redirect:"error"` on the fetch.
18
- *
19
- * PII: the CSV carries a "Security contact email" column (an ORG security mailbox,
20
- * e.g. security@agency.gov). We DROP it — this tool resolves ORGANIZATIONS, not
21
- * contacts, and excluding it keeps the output free of contact info.
22
- *
23
- * Filtering is CLIENT-SIDE over the full published CSV (the registry has no query
24
- * API) — disclosed in `_meta.notes`. `totalAvailable` is the EXACT match count.
25
- */
26
-
27
- import { getText } from "./datasource.js";
28
- import { driftError } from "./datasource.js";
29
- import { memoize } from "./cache.js";
30
- import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
31
-
32
- export const DOTGOV_HOST = "raw.githubusercontent.com";
33
- const DOTGOV_PATH_PREFIX = "https://raw.githubusercontent.com/cisagov/dotgov-data/main/";
34
-
35
- // scope → the pinned filename (no free path). "all" = federal + SLED (~16k rows),
36
- // "federal" = federal only (~1.3k rows).
37
- const DOTGOV_FILES = {
38
- all: "current-full.csv",
39
- federal: "current-federal.csv",
40
- } as const;
41
- export type GovDomainScope = keyof typeof DOTGOV_FILES;
42
-
43
- const SOURCE_LABEL = "getgov:cisagov/dotgov-data";
44
- const PROVENANCE_NOTE =
45
- "Source: the CISA get.gov OFFICIAL .gov domain registry, published as CSV at github.com/cisagov/dotgov-data (the canonical location; get.gov links there). CISA is the first-party .gov registrar; this is authoritative public data served from GitHub, not a .gov API host.";
46
- const CLIENT_FILTER_NOTE =
47
- "The registry has no query API — the full published CSV is fetched and filtered CLIENT-SIDE (organization/domain/city are case-insensitive SUBSTRING matches; state/domainType are case-insensitive). totalAvailable is the EXACT count of matching rows.";
48
- const FRESHNESS_NOTE =
49
- "The CISA dotgov-data CSV is refreshed ~daily; this response reflects the currently-published snapshot (served from a 6-hour cache).";
50
- const EMAIL_DROP_NOTE =
51
- "The registry's 'Security contact email' column (an organization security mailbox) is intentionally EXCLUDED — this tool resolves organizations, not contacts.";
52
-
53
- export type GovDomainRow = {
54
- domain: string;
55
- domainType: string;
56
- organization: string;
57
- suborganization: string | null;
58
- city: string | null;
59
- state: string | null;
60
- };
61
-
62
- // ─── Minimal RFC-4180 CSV parser (quoted fields + embedded commas/newlines) ──
63
- /**
64
- * Parse a full CSV document into rows of string fields. Handles double-quoted
65
- * fields, escaped `""` quotes, and commas/newlines INSIDE quotes. Self-contained
66
- * (no external dep) — the get.gov CSV is small (~1.4 MB) so a whole-string parse is
67
- * fine. Returns every record's raw field array (including the header row).
68
- */
69
- export function parseCsv(text: string): string[][] {
70
- const rows: string[][] = [];
71
- let field = "";
72
- let row: string[] = [];
73
- let inQuotes = false;
74
- for (let i = 0; i < text.length; i++) {
75
- const c = text[i];
76
- if (inQuotes) {
77
- if (c === '"') {
78
- if (text[i + 1] === '"') {
79
- field += '"';
80
- i++; // consume the escaped quote
81
- } else {
82
- inQuotes = false;
83
- }
84
- } else {
85
- field += c;
86
- }
87
- } else if (c === '"') {
88
- inQuotes = true;
89
- } else if (c === ",") {
90
- row.push(field);
91
- field = "";
92
- } else if (c === "\n") {
93
- row.push(field);
94
- rows.push(row);
95
- row = [];
96
- field = "";
97
- } else if (c === "\r") {
98
- // CRLF: the \n case pushes the record; ignore the stray CR.
99
- } else {
100
- field += c;
101
- }
102
- }
103
- // Flush a trailing field/row if the file did not end with a newline.
104
- if (field.length > 0 || row.length > 0) {
105
- row.push(field);
106
- rows.push(row);
107
- }
108
- return rows;
109
- }
110
-
111
- /** Coerce a CSV field to a trimmed non-empty string, else null (never ""). */
112
- function s(v: string | undefined): string | null {
113
- if (v === undefined) return null;
114
- const t = v.trim();
115
- return t.length > 0 ? t : null;
116
- }
117
-
118
- /**
119
- * Fetch + parse ONE scope's registry CSV into rows, memoized ~6h. Header-mapped by
120
- * COLUMN NAME (not fixed index) so an upstream column reorder does not silently
121
- * mis-map; a missing required header ⇒ schema_drift (never a fake-empty). The
122
- * "Security contact email" column is dropped at the map step.
123
- */
124
- async function loadRegistry(scope: GovDomainScope): Promise<GovDomainRow[]> {
125
- return memoize(`getgov:${scope}`, async () => {
126
- const url = `${DOTGOV_PATH_PREFIX}${DOTGOV_FILES[scope]}`;
127
- // Belt-and-suspenders SSRF: the URL is built only from the pinned prefix +
128
- // pinned filename, but assert it before the fetch.
129
- const built = new URL(url);
130
- if (built.hostname !== DOTGOV_HOST || built.protocol !== "https:") {
131
- throw driftError(
132
- SOURCE_LABEL,
133
- `Constructed get.gov URL host ${JSON.stringify(built.hostname)} is not ${DOTGOV_HOST} over https — refusing to fetch (SSRF safety).`,
134
- );
135
- }
136
- const text = await getText(url, { label: SOURCE_LABEL, redirect: "error", timeoutMs: 20_000 });
137
- const rows = parseCsv(text);
138
- const headerRow = rows[0];
139
- if (!headerRow) {
140
- throw driftError(SOURCE_LABEL, "get.gov registry CSV was empty — treating as schema drift, never a fake-empty result.");
141
- }
142
- const header = headerRow.map((h) => h.trim());
143
- const idx = (name: string) => header.indexOf(name);
144
- const iDomain = idx("Domain name");
145
- const iType = idx("Domain type");
146
- const iOrg = idx("Organization name");
147
- const iSub = idx("Suborganization name");
148
- const iCity = idx("City");
149
- const iState = idx("State");
150
- // The four load-bearing columns MUST be present (a rename ⇒ schema drift, not a
151
- // silently mis-mapped/empty result).
152
- if (iDomain < 0 || iType < 0 || iOrg < 0) {
153
- throw driftError(
154
- SOURCE_LABEL,
155
- `get.gov registry CSV header is missing a required column (Domain name / Domain type / Organization name) — schema drift. Got: ${header.join(", ")}.`,
156
- );
157
- }
158
- return rows.slice(1).map((r) => ({
159
- domain: (r[iDomain] ?? "").trim(),
160
- domainType: (r[iType] ?? "").trim(),
161
- organization: (r[iOrg] ?? "").trim(),
162
- suborganization: iSub >= 0 ? s(r[iSub]) : null,
163
- city: iCity >= 0 ? s(r[iCity]) : null,
164
- state: iState >= 0 ? s(r[iState]) : null,
165
- // NOTE: "Security contact email" is deliberately NOT read/emitted.
166
- }));
167
- });
168
- }
169
-
170
- // ─── Tool: search_gov_domains ─────────────────────────────────────
171
- /**
172
- * Search the CISA get.gov .gov domain registry. Client-side filters over the
173
- * published CSV: organization/domain/city are case-insensitive SUBSTRING matches;
174
- * state (2-letter) and domainType are case-insensitive. scope 'all' (federal + SLED,
175
- * default) | 'federal'. Honest `_meta` (exact match total; provenance + client-side
176
- * disclosure).
177
- */
178
- export async function searchGovDomains(args: {
179
- scope?: GovDomainScope;
180
- organization?: string;
181
- domain?: string;
182
- domainType?: string;
183
- state?: string;
184
- city?: string;
185
- limit?: number;
186
- offset?: number;
187
- }): Promise<MetaBundle> {
188
- const scope: GovDomainScope = args.scope ?? "all";
189
- const limit = args.limit ?? 50;
190
- const offset = args.offset ?? 0;
191
-
192
- const all = await loadRegistry(scope);
193
-
194
- const filtersApplied: string[] = [];
195
- const ci = (v: string | undefined) => (v ?? "").toLowerCase();
196
- const orgQ = ci(args.organization);
197
- const domQ = ci(args.domain);
198
- const typeQ = ci(args.domainType);
199
- const stateQ = ci(args.state);
200
- const cityQ = ci(args.city);
201
- if (args.organization !== undefined) filtersApplied.push("organization");
202
- if (args.domain !== undefined) filtersApplied.push("domain");
203
- if (args.domainType !== undefined) filtersApplied.push("domainType");
204
- if (args.state !== undefined) filtersApplied.push("state");
205
- if (args.city !== undefined) filtersApplied.push("city");
206
-
207
- const matched = all.filter((row) => {
208
- if (orgQ && !row.organization.toLowerCase().includes(orgQ)) return false;
209
- if (domQ && !row.domain.toLowerCase().includes(domQ)) return false;
210
- if (typeQ && !row.domainType.toLowerCase().includes(typeQ)) return false;
211
- if (stateQ && (row.state ?? "").toLowerCase() !== stateQ) return false;
212
- if (cityQ && !(row.city ?? "").toLowerCase().includes(cityQ)) return false;
213
- return true;
214
- });
215
-
216
- const totalAvailable = matched.length;
217
- const page = matched.slice(offset, offset + limit);
218
- const returned = page.length;
219
- const hasMore = offset + returned < totalAvailable;
220
- const nextOffset = hasMore ? offset + returned : null;
221
-
222
- return withMeta(
223
- { scope, domains: page },
224
- {
225
- source: `getgov ${DOTGOV_FILES[scope]} (CISA .gov registry, keyless)`,
226
- keylessMode: true,
227
- returned,
228
- totalAvailable,
229
- truncated: hasMore,
230
- filtersApplied,
231
- filtersDropped: [],
232
- fieldsUnavailable: ["securityContactEmail (org mailbox — intentionally excluded)"],
233
- pagination: { offset, limit, hasMore, nextOffset },
234
- notes: [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, FRESHNESS_NOTE, EMAIL_DROP_NOTE],
235
- } satisfies Partial<ResponseMeta>,
236
- );
237
- }
1
+ /**
2
+ * get.gov — the authoritative US .gov domain registry (CISA). KEYLESS.
3
+ *
4
+ * The .gov program (run by CISA) publishes the COMPLETE registry as CSVs in its
5
+ * official repo github.com/cisagov/dotgov-data — the canonical published location
6
+ * (get.gov links there; there is no query API for the full set). It is NOT a .gov
7
+ * API host, so provenance is disclosed on every response (the ProPublica /
8
+ * CourtListener republisher pattern — except here CISA is the first-party registrar).
9
+ *
10
+ * B2G value: resolve WHICH organization owns a .gov domain, enumerate federal
11
+ * agencies, and MAP SLED entities (state / county / city / school-district /
12
+ * special-district / tribal) for market targeting — a distinct authoritative
13
+ * gov-org registry no other tool here exposes.
14
+ *
15
+ * SSRF: fixed host `raw.githubusercontent.com` + fixed path prefix
16
+ * `/cisagov/dotgov-data/main/` + a scope-selected FIXED filename (federal | full) —
17
+ * no free host, path, or filename. `redirect:"error"` on the fetch.
18
+ *
19
+ * PII: the CSV carries a "Security contact email" column (an ORG security mailbox,
20
+ * e.g. security@agency.gov). We DROP it — this tool resolves ORGANIZATIONS, not
21
+ * contacts, and excluding it keeps the output free of contact info.
22
+ *
23
+ * Filtering is CLIENT-SIDE over the full published CSV (the registry has no query
24
+ * API) — disclosed in `_meta.notes`. `totalAvailable` is the EXACT match count.
25
+ */
26
+
27
+ import { getText } from "./datasource.js";
28
+ import { driftError } from "./datasource.js";
29
+ import { memoize } from "./cache.js";
30
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
31
+
32
+ export const DOTGOV_HOST = "raw.githubusercontent.com";
33
+ const DOTGOV_PATH_PREFIX = "https://raw.githubusercontent.com/cisagov/dotgov-data/main/";
34
+
35
+ // scope → the pinned filename (no free path). "all" = federal + SLED (~16k rows),
36
+ // "federal" = federal only (~1.3k rows).
37
+ const DOTGOV_FILES = {
38
+ all: "current-full.csv",
39
+ federal: "current-federal.csv",
40
+ } as const;
41
+ export type GovDomainScope = keyof typeof DOTGOV_FILES;
42
+
43
+ const SOURCE_LABEL = "getgov:cisagov/dotgov-data";
44
+ const PROVENANCE_NOTE =
45
+ "Source: the CISA get.gov OFFICIAL .gov domain registry, published as CSV at github.com/cisagov/dotgov-data (the canonical location; get.gov links there). CISA is the first-party .gov registrar; this is authoritative public data served from GitHub, not a .gov API host.";
46
+ const CLIENT_FILTER_NOTE =
47
+ "The registry has no query API — the full published CSV is fetched and filtered CLIENT-SIDE (organization/domain/city are case-insensitive SUBSTRING matches; state/domainType are case-insensitive). totalAvailable is the EXACT count of matching rows.";
48
+ const FRESHNESS_NOTE =
49
+ "The CISA dotgov-data CSV is refreshed ~daily; this response reflects the currently-published snapshot (served from a 6-hour cache).";
50
+ const EMAIL_DROP_NOTE =
51
+ "The registry's 'Security contact email' column (an organization security mailbox) is intentionally EXCLUDED — this tool resolves organizations, not contacts.";
52
+
53
+ export type GovDomainRow = {
54
+ domain: string;
55
+ domainType: string;
56
+ organization: string;
57
+ suborganization: string | null;
58
+ city: string | null;
59
+ state: string | null;
60
+ };
61
+
62
+ // ─── Minimal RFC-4180 CSV parser (quoted fields + embedded commas/newlines) ──
63
+ /**
64
+ * Parse a full CSV document into rows of string fields. Handles double-quoted
65
+ * fields, escaped `""` quotes, and commas/newlines INSIDE quotes. Self-contained
66
+ * (no external dep) — the get.gov CSV is small (~1.4 MB) so a whole-string parse is
67
+ * fine. Returns every record's raw field array (including the header row).
68
+ */
69
+ export function parseCsv(text: string): string[][] {
70
+ const rows: string[][] = [];
71
+ let field = "";
72
+ let row: string[] = [];
73
+ let inQuotes = false;
74
+ for (let i = 0; i < text.length; i++) {
75
+ const c = text[i];
76
+ if (inQuotes) {
77
+ if (c === '"') {
78
+ if (text[i + 1] === '"') {
79
+ field += '"';
80
+ i++; // consume the escaped quote
81
+ } else {
82
+ inQuotes = false;
83
+ }
84
+ } else {
85
+ field += c;
86
+ }
87
+ } else if (c === '"') {
88
+ inQuotes = true;
89
+ } else if (c === ",") {
90
+ row.push(field);
91
+ field = "";
92
+ } else if (c === "\n") {
93
+ row.push(field);
94
+ rows.push(row);
95
+ row = [];
96
+ field = "";
97
+ } else if (c === "\r") {
98
+ // CRLF: the \n case pushes the record; ignore the stray CR.
99
+ } else {
100
+ field += c;
101
+ }
102
+ }
103
+ // Flush a trailing field/row if the file did not end with a newline.
104
+ if (field.length > 0 || row.length > 0) {
105
+ row.push(field);
106
+ rows.push(row);
107
+ }
108
+ return rows;
109
+ }
110
+
111
+ /** Coerce a CSV field to a trimmed non-empty string, else null (never ""). */
112
+ function s(v: string | undefined): string | null {
113
+ if (v === undefined) return null;
114
+ const t = v.trim();
115
+ return t.length > 0 ? t : null;
116
+ }
117
+
118
+ /**
119
+ * Fetch + parse ONE scope's registry CSV into rows, memoized ~6h. Header-mapped by
120
+ * COLUMN NAME (not fixed index) so an upstream column reorder does not silently
121
+ * mis-map; a missing required header ⇒ schema_drift (never a fake-empty). The
122
+ * "Security contact email" column is dropped at the map step.
123
+ */
124
+ async function loadRegistry(scope: GovDomainScope): Promise<GovDomainRow[]> {
125
+ return memoize(`getgov:${scope}`, async () => {
126
+ const url = `${DOTGOV_PATH_PREFIX}${DOTGOV_FILES[scope]}`;
127
+ // Belt-and-suspenders SSRF: the URL is built only from the pinned prefix +
128
+ // pinned filename, but assert it before the fetch.
129
+ const built = new URL(url);
130
+ if (built.hostname !== DOTGOV_HOST || built.protocol !== "https:") {
131
+ throw driftError(
132
+ SOURCE_LABEL,
133
+ `Constructed get.gov URL host ${JSON.stringify(built.hostname)} is not ${DOTGOV_HOST} over https — refusing to fetch (SSRF safety).`,
134
+ );
135
+ }
136
+ const text = await getText(url, { label: SOURCE_LABEL, redirect: "error", timeoutMs: 20_000 });
137
+ const rows = parseCsv(text);
138
+ const headerRow = rows[0];
139
+ if (!headerRow) {
140
+ throw driftError(SOURCE_LABEL, "get.gov registry CSV was empty — treating as schema drift, never a fake-empty result.");
141
+ }
142
+ const header = headerRow.map((h) => h.trim());
143
+ const idx = (name: string) => header.indexOf(name);
144
+ const iDomain = idx("Domain name");
145
+ const iType = idx("Domain type");
146
+ const iOrg = idx("Organization name");
147
+ const iSub = idx("Suborganization name");
148
+ const iCity = idx("City");
149
+ const iState = idx("State");
150
+ // The four load-bearing columns MUST be present (a rename ⇒ schema drift, not a
151
+ // silently mis-mapped/empty result).
152
+ if (iDomain < 0 || iType < 0 || iOrg < 0) {
153
+ throw driftError(
154
+ SOURCE_LABEL,
155
+ `get.gov registry CSV header is missing a required column (Domain name / Domain type / Organization name) — schema drift. Got: ${header.join(", ")}.`,
156
+ );
157
+ }
158
+ return rows.slice(1).map((r) => ({
159
+ domain: (r[iDomain] ?? "").trim(),
160
+ domainType: (r[iType] ?? "").trim(),
161
+ organization: (r[iOrg] ?? "").trim(),
162
+ suborganization: iSub >= 0 ? s(r[iSub]) : null,
163
+ city: iCity >= 0 ? s(r[iCity]) : null,
164
+ state: iState >= 0 ? s(r[iState]) : null,
165
+ // NOTE: "Security contact email" is deliberately NOT read/emitted.
166
+ }));
167
+ });
168
+ }
169
+
170
+ // ─── Tool: search_gov_domains ─────────────────────────────────────
171
+ /**
172
+ * Search the CISA get.gov .gov domain registry. Client-side filters over the
173
+ * published CSV: organization/domain/city are case-insensitive SUBSTRING matches;
174
+ * state (2-letter) and domainType are case-insensitive. scope 'all' (federal + SLED,
175
+ * default) | 'federal'. Honest `_meta` (exact match total; provenance + client-side
176
+ * disclosure).
177
+ */
178
+ export async function searchGovDomains(args: {
179
+ scope?: GovDomainScope;
180
+ organization?: string;
181
+ domain?: string;
182
+ domainType?: string;
183
+ state?: string;
184
+ city?: string;
185
+ limit?: number;
186
+ offset?: number;
187
+ }): Promise<MetaBundle> {
188
+ const scope: GovDomainScope = args.scope ?? "all";
189
+ const limit = args.limit ?? 50;
190
+ const offset = args.offset ?? 0;
191
+
192
+ const all = await loadRegistry(scope);
193
+
194
+ const filtersApplied: string[] = [];
195
+ const ci = (v: string | undefined) => (v ?? "").toLowerCase();
196
+ const orgQ = ci(args.organization);
197
+ const domQ = ci(args.domain);
198
+ const typeQ = ci(args.domainType);
199
+ const stateQ = ci(args.state);
200
+ const cityQ = ci(args.city);
201
+ if (args.organization !== undefined) filtersApplied.push("organization");
202
+ if (args.domain !== undefined) filtersApplied.push("domain");
203
+ if (args.domainType !== undefined) filtersApplied.push("domainType");
204
+ if (args.state !== undefined) filtersApplied.push("state");
205
+ if (args.city !== undefined) filtersApplied.push("city");
206
+
207
+ const matched = all.filter((row) => {
208
+ if (orgQ && !row.organization.toLowerCase().includes(orgQ)) return false;
209
+ if (domQ && !row.domain.toLowerCase().includes(domQ)) return false;
210
+ if (typeQ && !row.domainType.toLowerCase().includes(typeQ)) return false;
211
+ if (stateQ && (row.state ?? "").toLowerCase() !== stateQ) return false;
212
+ if (cityQ && !(row.city ?? "").toLowerCase().includes(cityQ)) return false;
213
+ return true;
214
+ });
215
+
216
+ const totalAvailable = matched.length;
217
+ const page = matched.slice(offset, offset + limit);
218
+ const returned = page.length;
219
+ const hasMore = offset + returned < totalAvailable;
220
+ const nextOffset = hasMore ? offset + returned : null;
221
+
222
+ return withMeta(
223
+ { scope, domains: page },
224
+ {
225
+ source: `getgov ${DOTGOV_FILES[scope]} (CISA .gov registry, keyless)`,
226
+ keylessMode: true,
227
+ returned,
228
+ totalAvailable,
229
+ truncated: hasMore,
230
+ filtersApplied,
231
+ filtersDropped: [],
232
+ fieldsUnavailable: ["securityContactEmail (org mailbox — intentionally excluded)"],
233
+ pagination: { offset, limit, hasMore, nextOffset },
234
+ notes: [PROVENANCE_NOTE, CLIENT_FILTER_NOTE, FRESHNESS_NOTE, EMAIL_DROP_NOTE],
235
+ } satisfies Partial<ResponseMeta>,
236
+ );
237
+ }