@dench.com/cli 0.4.3 → 0.4.5

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.
@@ -0,0 +1,431 @@
1
+ // Shared CRM enrichment catalog and helpers.
2
+ //
3
+ // Keep this in the CLI package because CRM enrichment is a CLI-first surface.
4
+ // App/workflow code can import this same catalog so the agent, CLI, and future
5
+ // column UI agree on provider contracts and extraction behavior.
6
+
7
+ export type EnrichmentCategory = "people" | "company";
8
+
9
+ export const ENRICHMENT_CATEGORIES: readonly EnrichmentCategory[] = [
10
+ "people",
11
+ "company",
12
+ ] as const;
13
+
14
+ export type EnrichmentFieldType =
15
+ | "text"
16
+ | "email"
17
+ | "phone"
18
+ | "url"
19
+ | "number"
20
+ | "boolean"
21
+ | "date"
22
+ | "richtext"
23
+ | "file"
24
+ | "user"
25
+ | "enum"
26
+ | "relation"
27
+ | "tags"
28
+ | "action";
29
+
30
+ export type EnrichmentColumnDef = {
31
+ label: string;
32
+ key: string;
33
+ fieldType: EnrichmentFieldType;
34
+ /** Dot-path into the gateway response payload to extract the value. */
35
+ apolloPath: string;
36
+ /**
37
+ * Canonical Dench gateway field names sent in the requiredFields contract.
38
+ * Empty means the gateway should use its default backfill behavior.
39
+ */
40
+ requiredFields: string[];
41
+ /** Additional dot-paths to try when apolloPath does not resolve. */
42
+ extractionFallbacks?: string[];
43
+ };
44
+
45
+ export type EnrichmentInputKind = "email" | "linkedin" | "domain";
46
+
47
+ export type EnrichmentInputDef = {
48
+ kind: EnrichmentInputKind;
49
+ label: string;
50
+ };
51
+
52
+ export type FieldCandidate = {
53
+ id?: string;
54
+ name: string;
55
+ type: string;
56
+ };
57
+
58
+ const PEOPLE_PATTERNS = /people|person|contact|lead|prospect/i;
59
+ const COMPANY_PATTERNS = /company|companies|organization|account|business/i;
60
+
61
+ export function detectEnrichmentCategory(
62
+ objectName: string,
63
+ ): EnrichmentCategory | null {
64
+ if (PEOPLE_PATTERNS.test(objectName)) return "people";
65
+ if (COMPANY_PATTERNS.test(objectName)) return "company";
66
+ return null;
67
+ }
68
+
69
+ export const PEOPLE_ENRICHMENT_COLUMNS: readonly EnrichmentColumnDef[] = [
70
+ {
71
+ label: "Full Name",
72
+ key: "person.name",
73
+ fieldType: "text",
74
+ apolloPath: "__computed.fullName",
75
+ requiredFields: ["fullName"],
76
+ extractionFallbacks: ["person.name", "fullName", "person.fullName"],
77
+ },
78
+ {
79
+ label: "Email",
80
+ key: "person.email",
81
+ fieldType: "email",
82
+ apolloPath: "person.email",
83
+ requiredFields: ["email"],
84
+ extractionFallbacks: ["email"],
85
+ },
86
+ {
87
+ label: "Headline",
88
+ key: "person.headline",
89
+ fieldType: "text",
90
+ apolloPath: "person.headline",
91
+ requiredFields: ["headline"],
92
+ extractionFallbacks: ["headline"],
93
+ },
94
+ {
95
+ label: "LinkedIn URL",
96
+ key: "person.linkedin_url",
97
+ fieldType: "url",
98
+ apolloPath: "person.linkedin_url",
99
+ requiredFields: ["linkedinID"],
100
+ extractionFallbacks: ["linkedin_url", "URLs.linkedin", "person.URLs.linkedin"],
101
+ },
102
+ {
103
+ label: "Twitter URL",
104
+ key: "person.twitter_url",
105
+ fieldType: "url",
106
+ apolloPath: "person.twitter_url",
107
+ requiredFields: ["URLs"],
108
+ extractionFallbacks: ["twitter_url", "URLs.twitter", "person.URLs.twitter"],
109
+ },
110
+ {
111
+ label: "Phone",
112
+ key: "person.phone",
113
+ fieldType: "phone",
114
+ apolloPath: "person.contact.phone_numbers.0.sanitized_number",
115
+ requiredFields: ["phone"],
116
+ extractionFallbacks: ["phone", "person.phone"],
117
+ },
118
+ {
119
+ label: "Title",
120
+ key: "person.title",
121
+ fieldType: "text",
122
+ apolloPath: "person.title",
123
+ requiredFields: [],
124
+ extractionFallbacks: ["title", "person.headline", "headline"],
125
+ },
126
+ {
127
+ label: "Location",
128
+ key: "person.location",
129
+ fieldType: "text",
130
+ apolloPath: "__computed.location",
131
+ requiredFields: ["location"],
132
+ extractionFallbacks: ["location", "person.location"],
133
+ },
134
+ ];
135
+
136
+ export const COMPANY_ENRICHMENT_COLUMNS: readonly EnrichmentColumnDef[] = [
137
+ {
138
+ label: "Company Name",
139
+ key: "organization.name",
140
+ fieldType: "text",
141
+ apolloPath: "organization.name",
142
+ requiredFields: ["name"],
143
+ extractionFallbacks: ["name"],
144
+ },
145
+ {
146
+ label: "Website URL",
147
+ key: "organization.website_url",
148
+ fieldType: "url",
149
+ apolloPath: "organization.website_url",
150
+ requiredFields: ["website"],
151
+ extractionFallbacks: ["website", "organization.website"],
152
+ },
153
+ {
154
+ label: "Industry",
155
+ key: "organization.industry",
156
+ fieldType: "text",
157
+ apolloPath: "organization.industry",
158
+ requiredFields: ["industryList"],
159
+ extractionFallbacks: ["industryList.0", "organization.industryList.0"],
160
+ },
161
+ {
162
+ label: "LinkedIn URL",
163
+ key: "organization.linkedin_url",
164
+ fieldType: "url",
165
+ apolloPath: "organization.linkedin_url",
166
+ requiredFields: ["linkedinID"],
167
+ extractionFallbacks: ["URLs.linkedin", "organization.URLs.linkedin"],
168
+ },
169
+ {
170
+ label: "Total Funding",
171
+ key: "organization.total_funding_printed",
172
+ fieldType: "text",
173
+ apolloPath: "organization.total_funding_printed",
174
+ requiredFields: ["totalFunding"],
175
+ extractionFallbacks: ["totalFunding", "organization.totalFunding"],
176
+ },
177
+ {
178
+ label: "Founded Year",
179
+ key: "organization.founded_year",
180
+ fieldType: "number",
181
+ apolloPath: "organization.founded_year",
182
+ requiredFields: ["founded"],
183
+ extractionFallbacks: ["founded", "organization.founded"],
184
+ },
185
+ ];
186
+
187
+ export const PEOPLE_INPUTS: readonly EnrichmentInputDef[] = [
188
+ { kind: "email", label: "Email" },
189
+ { kind: "linkedin", label: "LinkedIn URL" },
190
+ ];
191
+
192
+ export const COMPANY_INPUTS: readonly EnrichmentInputDef[] = [
193
+ { kind: "domain", label: "Website / Domain" },
194
+ { kind: "linkedin", label: "LinkedIn URL" },
195
+ ];
196
+
197
+ export type EnrichmentFieldMeta = {
198
+ enrichment: {
199
+ category: EnrichmentCategory;
200
+ key: string;
201
+ apolloPath: string;
202
+ inputFieldName: string;
203
+ };
204
+ };
205
+
206
+ export function getEnrichmentColumns(
207
+ category: EnrichmentCategory,
208
+ ): readonly EnrichmentColumnDef[] {
209
+ return category === "people"
210
+ ? PEOPLE_ENRICHMENT_COLUMNS
211
+ : COMPANY_ENRICHMENT_COLUMNS;
212
+ }
213
+
214
+ export function getInputDefs(
215
+ category: EnrichmentCategory,
216
+ ): readonly EnrichmentInputDef[] {
217
+ return category === "people" ? PEOPLE_INPUTS : COMPANY_INPUTS;
218
+ }
219
+
220
+ export function findEnrichmentColumn(args: {
221
+ category: EnrichmentCategory;
222
+ key?: string;
223
+ apolloPath?: string;
224
+ label?: string;
225
+ }): EnrichmentColumnDef | null {
226
+ const columns = getEnrichmentColumns(args.category);
227
+ const normalizedLabel = args.label?.trim().toLowerCase();
228
+ return (
229
+ columns.find((column) => {
230
+ return (
231
+ (args.key !== undefined && column.key === args.key) ||
232
+ (args.apolloPath !== undefined && column.apolloPath === args.apolloPath) ||
233
+ (normalizedLabel !== undefined &&
234
+ column.label.toLowerCase() === normalizedLabel)
235
+ );
236
+ }) ?? null
237
+ );
238
+ }
239
+
240
+ export function isEligibleInputField(
241
+ category: EnrichmentCategory,
242
+ field: FieldCandidate,
243
+ ): boolean {
244
+ if (category === "people") {
245
+ return (
246
+ field.type === "email" ||
247
+ /^e[-_]?mail/i.test(field.name) ||
248
+ /linkedin/i.test(field.name)
249
+ );
250
+ }
251
+
252
+ return (
253
+ /domain|website/i.test(field.name) ||
254
+ /linkedin/i.test(field.name) ||
255
+ (/^url$/i.test(field.name) && field.type === "url")
256
+ );
257
+ }
258
+
259
+ export function getEligibleInputFields(
260
+ category: EnrichmentCategory,
261
+ fields: readonly FieldCandidate[],
262
+ ): FieldCandidate[] {
263
+ return fields.filter((field) => isEligibleInputField(category, field));
264
+ }
265
+
266
+ export function getAvailableEnrichmentCategories(
267
+ objectName: string,
268
+ fields: readonly FieldCandidate[],
269
+ ): EnrichmentCategory[] {
270
+ const detected = detectEnrichmentCategory(objectName);
271
+ if (detected) return [detected];
272
+
273
+ const categoriesWithInputs = ENRICHMENT_CATEGORIES.filter(
274
+ (category) => getEligibleInputFields(category, fields).length > 0,
275
+ );
276
+ return categoriesWithInputs.length > 0
277
+ ? [...categoriesWithInputs]
278
+ : [...ENRICHMENT_CATEGORIES];
279
+ }
280
+
281
+ export function autoDetectInputField(
282
+ category: EnrichmentCategory,
283
+ fields: readonly FieldCandidate[],
284
+ ): FieldCandidate | null {
285
+ const eligibleFields = getEligibleInputFields(category, fields);
286
+ if (category === "people") {
287
+ const emailField = eligibleFields.find(
288
+ (field) => field.type === "email" || /^e[-_]?mail/i.test(field.name),
289
+ );
290
+ if (emailField) return emailField;
291
+ return eligibleFields.find((field) => /linkedin/i.test(field.name)) ?? null;
292
+ }
293
+
294
+ const domainField = eligibleFields.find((field) =>
295
+ /website|domain|^url$/i.test(field.name),
296
+ );
297
+ if (domainField) return domainField;
298
+ return eligibleFields.find((field) => /linkedin/i.test(field.name)) ?? null;
299
+ }
300
+
301
+ export function inferInputKind(field: FieldCandidate): EnrichmentInputKind {
302
+ if (field.type === "email" || /^e[-_]?mail/i.test(field.name)) {
303
+ return "email";
304
+ }
305
+ if (/linkedin/i.test(field.name)) return "linkedin";
306
+ return "domain";
307
+ }
308
+
309
+ export function extractEnrichmentValue(
310
+ payload: Record<string, unknown>,
311
+ column: Pick<EnrichmentColumnDef, "apolloPath" | "extractionFallbacks">,
312
+ ): string | null {
313
+ const primary = extractApolloValue(payload, column.apolloPath);
314
+ if (primary !== null) return primary;
315
+
316
+ for (const fallback of column.extractionFallbacks ?? []) {
317
+ const value = extractApolloValue(payload, fallback);
318
+ if (value !== null) return value;
319
+ }
320
+ return null;
321
+ }
322
+
323
+ export function getRequiredFieldsForApolloPath(
324
+ category: EnrichmentCategory,
325
+ apolloPath: string,
326
+ ): string[] {
327
+ const column = getEnrichmentColumns(category).find(
328
+ (candidate) => candidate.apolloPath === apolloPath,
329
+ );
330
+ return column?.requiredFields ?? [];
331
+ }
332
+
333
+ export function buildEnrichmentMeta(
334
+ category: EnrichmentCategory,
335
+ column: EnrichmentColumnDef,
336
+ inputFieldName: string,
337
+ ): EnrichmentFieldMeta {
338
+ return {
339
+ enrichment: {
340
+ category,
341
+ key: column.key,
342
+ apolloPath: column.apolloPath,
343
+ inputFieldName,
344
+ },
345
+ };
346
+ }
347
+
348
+ export function parseEnrichmentMeta(
349
+ defaultValue: string | null | undefined,
350
+ ): EnrichmentFieldMeta | null {
351
+ if (!defaultValue) return null;
352
+ try {
353
+ const parsed = JSON.parse(defaultValue) as unknown;
354
+ if (!parsed || typeof parsed !== "object") return null;
355
+ const enrichment = (parsed as { enrichment?: unknown }).enrichment;
356
+ if (!enrichment || typeof enrichment !== "object") return null;
357
+ const meta = enrichment as Record<string, unknown>;
358
+ if (
359
+ (meta.category === "people" || meta.category === "company") &&
360
+ typeof meta.key === "string" &&
361
+ typeof meta.apolloPath === "string" &&
362
+ typeof meta.inputFieldName === "string"
363
+ ) {
364
+ return parsed as EnrichmentFieldMeta;
365
+ }
366
+ } catch {
367
+ // Not enrichment metadata.
368
+ }
369
+ return null;
370
+ }
371
+
372
+ export function extractDomain(input: string): string {
373
+ const trimmed = input.trim();
374
+ if (!trimmed) return "";
375
+ try {
376
+ const url = new URL(
377
+ trimmed.startsWith("http") ? trimmed : `https://${trimmed}`,
378
+ );
379
+ return url.hostname.replace(/^www\./, "");
380
+ } catch {
381
+ return trimmed.replace(/^www\./, "");
382
+ }
383
+ }
384
+
385
+ function extractApolloValue(
386
+ payload: Record<string, unknown>,
387
+ apolloPath: string,
388
+ ): string | null {
389
+ if (apolloPath === "__computed.fullName") {
390
+ return computeFullName(payload);
391
+ }
392
+ if (apolloPath === "__computed.location") {
393
+ return computeLocation(payload);
394
+ }
395
+
396
+ const parts = apolloPath.split(".");
397
+ let current: unknown = payload;
398
+ for (const part of parts) {
399
+ if (current == null || typeof current !== "object") return null;
400
+ const index = Number(part);
401
+ if (Array.isArray(current) && !Number.isNaN(index)) {
402
+ current = current[index];
403
+ } else {
404
+ current = (current as Record<string, unknown>)[part];
405
+ }
406
+ }
407
+ if (current == null) return null;
408
+ if (typeof current === "object") return JSON.stringify(current);
409
+ const value = String(current).trim();
410
+ return value.length > 0 ? value : null;
411
+ }
412
+
413
+ function computeFullName(payload: Record<string, unknown>): string | null {
414
+ const person = payload.person as Record<string, unknown> | undefined;
415
+ if (!person) return null;
416
+ const explicit = person.name ?? person.fullName;
417
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
418
+ return explicit.trim();
419
+ }
420
+ const parts = [person.first_name, person.last_name].filter(
421
+ (part): part is string => typeof part === "string" && part.trim().length > 0,
422
+ );
423
+ return parts.length > 0 ? parts.join(" ") : null;
424
+ }
425
+
426
+ function computeLocation(payload: Record<string, unknown>): string | null {
427
+ const person = payload.person as Record<string, unknown> | undefined;
428
+ if (!person) return null;
429
+ const parts = [person.city, person.state, person.country].filter(Boolean);
430
+ return parts.length > 0 ? parts.join(", ") : null;
431
+ }
@@ -0,0 +1,279 @@
1
+ import { extractDomain } from "./crm-enrichment";
2
+
3
+ export type FetchLike = (
4
+ input: string | URL,
5
+ init?: RequestInit,
6
+ ) => Promise<Response>;
7
+
8
+ export type EnrichmentGatewayEnv = Pick<
9
+ NodeJS.ProcessEnv,
10
+ "DENCH_API_KEY" | "DENCH_GATEWAY_URL" | "GATEWAY_URL" | "DENCH_HOST"
11
+ >;
12
+
13
+ export class EnrichmentGatewayError extends Error {
14
+ readonly status?: number;
15
+ readonly code?: string;
16
+
17
+ constructor(message: string, options?: { status?: number; code?: string }) {
18
+ super(message);
19
+ this.name = "EnrichmentGatewayError";
20
+ this.status = options?.status;
21
+ this.code = options?.code;
22
+ }
23
+ }
24
+
25
+ export type EnrichPersonBody = {
26
+ email?: string;
27
+ linkedinUrl?: string;
28
+ firstName?: string;
29
+ lastName?: string;
30
+ domain?: string;
31
+ organizationName?: string;
32
+ requiredFields?: string[];
33
+ };
34
+
35
+ export type EnrichCompanyBody = {
36
+ domain: string;
37
+ requiredFields?: string[];
38
+ };
39
+
40
+ export type SearchPeopleBody = {
41
+ personTitles?: string[];
42
+ personLocations?: string[];
43
+ organizationDomains?: string[];
44
+ organizationLocations?: string[];
45
+ organizationNumEmployeesRanges?: string[];
46
+ qKeywords?: string;
47
+ page?: number;
48
+ perPage?: number;
49
+ };
50
+
51
+ export type EnrichmentGatewayOptions = {
52
+ apiKey?: string;
53
+ baseUrl?: string;
54
+ env?: EnrichmentGatewayEnv;
55
+ fetchImpl?: FetchLike;
56
+ };
57
+
58
+ export function resolveEnrichmentGatewayBaseUrl(
59
+ env: EnrichmentGatewayEnv = process.env,
60
+ ): string {
61
+ const explicit = env.DENCH_GATEWAY_URL?.trim() || env.GATEWAY_URL?.trim();
62
+ if (explicit) return explicit.replace(/\/+$/, "");
63
+
64
+ const host = env.DENCH_HOST?.trim();
65
+ if (host && /localhost|127\.0\.0\.1/.test(host)) {
66
+ return "http://localhost:8787";
67
+ }
68
+ return "https://gateway.merseoriginals.com";
69
+ }
70
+
71
+ export function requireEnrichmentApiKey(
72
+ env: EnrichmentGatewayEnv = process.env,
73
+ ): string {
74
+ const apiKey = env.DENCH_API_KEY?.trim();
75
+ if (!apiKey) {
76
+ throw new EnrichmentGatewayError(
77
+ "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " +
78
+ "the env automatically; outside, export it manually or run " +
79
+ "`dench login` first.",
80
+ { code: "missing_api_key" },
81
+ );
82
+ }
83
+ return apiKey;
84
+ }
85
+
86
+ export function buildPersonBodyFromIdentifier(
87
+ identifier: string,
88
+ requiredFields?: string[],
89
+ ): EnrichPersonBody {
90
+ const trimmed = identifier.trim();
91
+ if (!trimmed) {
92
+ throw new EnrichmentGatewayError("People enrichment input is empty.", {
93
+ code: "empty_identifier",
94
+ });
95
+ }
96
+ if (/linkedin\.com/i.test(trimmed)) {
97
+ return { linkedinUrl: trimmed, requiredFields };
98
+ }
99
+ if (trimmed.includes("@")) {
100
+ return { email: trimmed, requiredFields };
101
+ }
102
+ throw new EnrichmentGatewayError(
103
+ "Unsupported people identifier. Use an email address or LinkedIn URL.",
104
+ { code: "unsupported_people_identifier" },
105
+ );
106
+ }
107
+
108
+ export async function enrichPerson(
109
+ body: EnrichPersonBody,
110
+ options: EnrichmentGatewayOptions = {},
111
+ ): Promise<Record<string, unknown>> {
112
+ const payload: Record<string, unknown> = {};
113
+ if (body.email) payload.email = body.email;
114
+ if (body.linkedinUrl) payload.linkedin_url = body.linkedinUrl;
115
+ if (body.firstName) payload.first_name = body.firstName;
116
+ if (body.lastName) payload.last_name = body.lastName;
117
+ if (body.domain) payload.domain = body.domain;
118
+ if (body.organizationName) payload.organization_name = body.organizationName;
119
+ if (body.requiredFields && body.requiredFields.length > 0) {
120
+ payload.requiredFields = body.requiredFields;
121
+ }
122
+
123
+ if (Object.keys(payload).length === 0) {
124
+ throw new EnrichmentGatewayError(
125
+ "People enrichment requires at least one identifier.",
126
+ { code: "missing_people_identifier" },
127
+ );
128
+ }
129
+
130
+ return callGatewayJson("/v1/enrichment/people", {
131
+ method: "POST",
132
+ body: payload,
133
+ options,
134
+ });
135
+ }
136
+
137
+ export async function enrichPersonByIdentifier(
138
+ identifier: string,
139
+ requiredFields?: string[],
140
+ options: EnrichmentGatewayOptions = {},
141
+ ): Promise<Record<string, unknown>> {
142
+ return enrichPerson(buildPersonBodyFromIdentifier(identifier, requiredFields), options);
143
+ }
144
+
145
+ export async function enrichCompany(
146
+ body: EnrichCompanyBody,
147
+ options: EnrichmentGatewayOptions = {},
148
+ ): Promise<Record<string, unknown>> {
149
+ const domain = extractDomain(body.domain);
150
+ if (!domain) {
151
+ throw new EnrichmentGatewayError("Company enrichment input is empty.", {
152
+ code: "empty_domain",
153
+ });
154
+ }
155
+
156
+ const url = new URL(
157
+ `${resolveBaseUrl(options)}/v1/enrichment/company`,
158
+ );
159
+ url.searchParams.set("domain", domain);
160
+ if (body.requiredFields && body.requiredFields.length > 0) {
161
+ url.searchParams.set("requiredFields", body.requiredFields.join(","));
162
+ }
163
+
164
+ return callGatewayJson(url, { method: "GET", options });
165
+ }
166
+
167
+ export async function searchPeople(
168
+ body: SearchPeopleBody,
169
+ options: EnrichmentGatewayOptions = {},
170
+ ): Promise<Record<string, unknown>> {
171
+ const payload: Record<string, unknown> = {};
172
+ if (body.personTitles) payload.person_titles = body.personTitles;
173
+ if (body.personLocations) payload.person_locations = body.personLocations;
174
+ if (body.organizationDomains) {
175
+ payload.organization_domains = body.organizationDomains;
176
+ }
177
+ if (body.organizationLocations) {
178
+ payload.organization_locations = body.organizationLocations;
179
+ }
180
+ if (body.organizationNumEmployeesRanges) {
181
+ payload.organization_num_employees_ranges =
182
+ body.organizationNumEmployeesRanges;
183
+ }
184
+ if (body.qKeywords) payload.q_keywords = body.qKeywords;
185
+ if (body.page !== undefined) payload.page = body.page;
186
+ if (body.perPage !== undefined) payload.per_page = body.perPage;
187
+
188
+ return callGatewayJson("/v1/enrichment/people/search", {
189
+ method: "POST",
190
+ body: payload,
191
+ options,
192
+ });
193
+ }
194
+
195
+ async function callGatewayJson(
196
+ pathOrUrl: string | URL,
197
+ args: {
198
+ method: "GET" | "POST";
199
+ body?: Record<string, unknown>;
200
+ options: EnrichmentGatewayOptions;
201
+ },
202
+ ): Promise<Record<string, unknown>> {
203
+ const fetchImpl = args.options.fetchImpl ?? fetch;
204
+ const apiKey = args.options.apiKey ?? requireEnrichmentApiKey(args.options.env);
205
+ const url =
206
+ typeof pathOrUrl === "string"
207
+ ? `${resolveBaseUrl(args.options)}${pathOrUrl}`
208
+ : pathOrUrl;
209
+
210
+ const response = await fetchImpl(url, {
211
+ method: args.method,
212
+ headers: {
213
+ ...(args.method === "POST" ? { "content-type": "application/json" } : {}),
214
+ Authorization: `Bearer ${apiKey}`,
215
+ },
216
+ ...(args.body ? { body: JSON.stringify(args.body) } : {}),
217
+ });
218
+
219
+ if (!response.ok) {
220
+ throw await buildGatewayError(response, pathOrUrl.toString());
221
+ }
222
+
223
+ return (await response.json()) as Record<string, unknown>;
224
+ }
225
+
226
+ function resolveBaseUrl(options: EnrichmentGatewayOptions): string {
227
+ return (
228
+ options.baseUrl?.trim().replace(/\/+$/, "") ??
229
+ resolveEnrichmentGatewayBaseUrl(options.env)
230
+ );
231
+ }
232
+
233
+ async function buildGatewayError(
234
+ response: Response,
235
+ context: string,
236
+ ): Promise<EnrichmentGatewayError> {
237
+ let body: unknown = null;
238
+ let rawText = "";
239
+ try {
240
+ rawText = await response.text();
241
+ body = rawText ? JSON.parse(rawText) : null;
242
+ } catch {
243
+ // Non-JSON bodies are surfaced below.
244
+ }
245
+
246
+ const error =
247
+ body && typeof body === "object"
248
+ ? (body as { error?: { code?: unknown; message?: unknown } }).error
249
+ : undefined;
250
+ const code = typeof error?.code === "string" ? error.code : undefined;
251
+ const upstreamMessage =
252
+ typeof error?.message === "string" ? error.message : undefined;
253
+
254
+ if (response.status === 404 || code === "not_found") {
255
+ return new EnrichmentGatewayError("No data returned", {
256
+ status: response.status,
257
+ code: code ?? "not_found",
258
+ });
259
+ }
260
+ if (response.status === 503 || code === "provider_unavailable") {
261
+ return new EnrichmentGatewayError("Gateway providers unavailable", {
262
+ status: response.status,
263
+ code: code ?? "provider_unavailable",
264
+ });
265
+ }
266
+ if (code === "invalid_required_field") {
267
+ return new EnrichmentGatewayError(
268
+ upstreamMessage ?? "Invalid required field",
269
+ { status: response.status, code },
270
+ );
271
+ }
272
+
273
+ const detail = upstreamMessage || rawText || response.statusText;
274
+ const truncated = detail.length > 500 ? `${detail.slice(0, 500)}...` : detail;
275
+ return new EnrichmentGatewayError(
276
+ `Gateway ${context} returned ${response.status}: ${truncated}`,
277
+ { status: response.status, code },
278
+ );
279
+ }