Skip to content

Commit 21a5b55

Browse files
committed
fix(harmonic): correct the destructive-clear copy and stop double-billing enrichment
Follow-up to #6902, from a final validation pass against Harmonic's OpenAPI and API reference. No endpoint, method, or response mapping changed. - The `personUrns` field told users and the LLM that Clear Net-New Results "clears everything when omitted". That is the raw provider behavior the clearScope guard was added to block; omitting it now throws. The field is shared across three operations, so the wrong sentence was being served as guidance on all of them. - Bulk email enrichment deduplicated LinkedIn URLs before canonicalising them, so `.../in/foo?utm_source=x` and `.../in/foo` were submitted as two people. Harmonic bills per submitted entry, so this spent quota twice and double-counted against the 5,000 cap. Deduplicate after canonicalising. - The two documented bulk-enrichment failures carry a code in `error` and no message anywhere, so quota exhaustion surfaced as "Request failed with status 429". Render the code with its counters instead. Gated on those counters being present: `extractErrorMessage` without an explicit id walks every extractor in order, and claiming a bare `error` key swallowed OAuth's `error_description`. - An enrichment 404 whose detail carries only the URN no longer discards it. - Report the identifier conflict before complaining about an individual URL. - Validate `companyContextUrns` as company URNs, like every other URN param. Forward-compat: `user_saved_search_type` is passed through rather than checked against a fixed set. It is display metadata nothing branches on, and Harmonic owns the enum — an allow-list turned any value they add into a hard failure of the whole list while the selector reading the same rows kept working. Also drops `USER_CONNECTION`, which Harmonic documents as unsupported via the API, removes three superseded types and one dead helper, and extends the "credential never reaches a URL or body" assertion from 4 tools to all 13.
1 parent ea70f8d commit 21a5b55

5 files changed

Lines changed: 168 additions & 55 deletions

File tree

apps/sim/blocks/blocks/harmonic.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export const HarmonicBlock: BlockConfig = {
358358
language: 'json',
359359
placeholder: '["urn:harmonic:person:22", "urn:harmonic:person:1690"]',
360360
description:
361-
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results clears everything when omitted',
361+
'Batch Get requires at least one Person URN or Person ID. Clear Net-New Results requires at least one URN unless Clear Scope is set to every net-new result',
362362
condition: { field: 'operation', value: [...PERSON_URN_OPERATIONS] },
363363
paramVisibility: 'user-or-llm',
364364
wandConfig: {
@@ -838,7 +838,7 @@ export const HarmonicBlockMeta = {
838838
description:
839839
'Turn LinkedIn URLs or email addresses a workflow already holds into Harmonic contacts.',
840840
content:
841-
'# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. When Harmonic reports the person is not on file, capture the enrichment it scheduled and poll Get Enrichment Status until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.',
841+
'# Enrich Known Identifiers\n\nUse Enrich Person when the workflow already has an identifier rather than a description of who to find.\n\n## Steps\n1. Prefer the LinkedIn profile URL; supply the email only as a fallback identifier.\n2. Run Enrich Person once per identifier and keep personUrn from every match.\n3. A person Harmonic does not have yet fails the block rather than returning a row: the error names the enrichment that was scheduled and carries its URN. Handle that error instead of treating it as a match, and poll Get Enrichment Status with the URN until it is COMPLETE or FAILED.\n4. Read the resulting person with Get Person or Batch Get People once enrichment completes.\n\n## Output\nReturn the hydrated contacts, the identifiers still pending enrichment, and the identifiers Harmonic could not resolve. Do not invent contact fields for unresolved rows.',
842842
},
843843
{
844844
name: 'source-company-employees',

apps/sim/tools/error-extractors.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
213213
{
214214
id: 'harmonic-errors',
215215
description:
216-
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, and validation detail arrays without echoed request input',
216+
'Harmonic API message errors, string and object FastAPI detail aborts including the enrichment URN, bulk email-enrichment error codes with their quota counters, and validation detail arrays without echoed request input',
217217
examples: ['Harmonic'],
218218
extract: (errorInfo) => {
219219
const data = errorInfo?.data
@@ -241,12 +241,30 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
241241
if (data.detail && typeof data.detail === 'object' && !Array.isArray(data.detail)) {
242242
const detail = data.detail as { message?: unknown; enrichment_urn?: unknown }
243243
const detailMessage = typeof detail.message === 'string' ? detail.message.trim() : ''
244-
if (!detailMessage) return undefined
245244
const enrichmentUrn =
246245
typeof detail.enrichment_urn === 'string' ? detail.enrichment_urn.trim() : ''
246+
if (!detailMessage) return enrichmentUrn || undefined
247247
return enrichmentUrn ? `${detailMessage} (${enrichmentUrn})` : detailMessage
248248
}
249249

250+
/**
251+
* The bulk email-enrichment endpoint answers 422/429 with a code in `error`
252+
* and no message anywhere — `{error: 'MONTHLY_QUOTA_INSUFFICIENT', needed,
253+
* available, submitted}`. These are the most actionable failures on that path.
254+
*
255+
* Gated on one of the documented numeric counters being present. `error` alone
256+
* is far too common a key to claim: `extractErrorMessage` without an explicit
257+
* id walks every extractor in order, so a bare `error` check here would swallow
258+
* OAuth's `{error, error_description}` and return the code instead of the text.
259+
*/
260+
const emailJobCounters = (['needed', 'available', 'submitted'] as const).filter(
261+
(key) => typeof data[key] === 'number'
262+
)
263+
if (typeof data.error === 'string' && data.error.trim() && emailJobCounters.length > 0) {
264+
const code = data.error.trim()
265+
return `${code} (${emailJobCounters.map((key) => `${key} ${data[key]}`).join(', ')})`
266+
}
267+
250268
if (!Array.isArray(data.detail)) return undefined
251269
const details = data.detail
252270
.map((entry: unknown) => {

apps/sim/tools/harmonic/harmonic.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ describe('Harmonic authentication and registry-facing contracts', () => {
230230
expect(headers.Authorization).toBeUndefined()
231231
}
232232

233+
/** One sample per registered tool: every URL builder interpolates user input. */
233234
const requestSamples: Array<[ToolConfig, Record<string, unknown>]> = [
234235
[harmonicSearchPeopleScoutTool, { accessToken: 'team-secret', query: 'find FDEs' }],
235236
[harmonicListPeopleSavedSearchesTool, { accessToken: 'team-secret' }],
@@ -238,7 +239,35 @@ describe('Harmonic authentication and registry-facing contracts', () => {
238239
{ accessToken: 'team-secret', savedSearchId: 'urn:harmonic:saved_search:1' },
239240
],
240241
[harmonicBatchGetPeopleTool, { accessToken: 'team-secret', personIds: [1] }],
242+
[
243+
harmonicEnrichPersonTool,
244+
{ accessToken: 'team-secret', linkedinUrl: 'https://www.linkedin.com/in/ada' },
245+
],
246+
[harmonicGetPersonTool, { accessToken: 'team-secret', personId: '123' }],
247+
[harmonicGetCompanyEmployeesTool, { accessToken: 'team-secret', companyId: '1' }],
248+
[
249+
harmonicGetPeopleSavedSearchNetNewResultsTool,
250+
{ accessToken: 'team-secret', savedSearchId: '5' },
251+
],
252+
[
253+
harmonicClearPeopleSavedSearchNetNewResultsTool,
254+
{ accessToken: 'team-secret', savedSearchId: '5', clearScope: 'all' },
255+
],
256+
[
257+
harmonicSubmitEmailEnrichmentJobTool,
258+
{ accessToken: 'team-secret', personUrns: ['urn:harmonic:person:1'] },
259+
],
260+
[harmonicGetEmailEnrichmentJobTool, { accessToken: 'team-secret', jobId: 'job-1' }],
261+
[harmonicGetEmailEnrichmentUsageTool, { accessToken: 'team-secret' }],
262+
[
263+
harmonicGetEnrichmentStatusTool,
264+
{ accessToken: 'team-secret', enrichmentUrns: ['urn:harmonic:enrichment:1'] },
265+
],
241266
]
267+
expect(requestSamples).toHaveLength(allTools.length)
268+
expect(new Set(requestSamples.map(([tool]) => tool.id))).toEqual(
269+
new Set(allTools.map((tool) => tool.id))
270+
)
242271
for (const [tool, params] of requestSamples) {
243272
expect(buildUrl(tool, params)).not.toContain('team-secret')
244273
if (tool.request.body)
@@ -336,6 +365,13 @@ describe('Harmonic authentication and registry-facing contracts', () => {
336365
{ status: 404, data: { detail: { enrichment_urn: 'urn:harmonic:enrichment:abc' } } },
337366
harmonicEnrichPersonTool.errorExtractor
338367
)
368+
).toBe('urn:harmonic:enrichment:abc')
369+
370+
expect(
371+
extractErrorMessage(
372+
{ status: 404, data: { detail: {} } },
373+
harmonicEnrichPersonTool.errorExtractor
374+
)
339375
).toBe('Request failed with status 404')
340376
})
341377

@@ -564,7 +600,6 @@ describe('Harmonic people retrieval', () => {
564600
['entity_urn', 'urn:harmonic:company:1'],
565601
['name', ' '],
566602
['creator', 'urn:harmonic:company:1'],
567-
['user_saved_search_type', 'UNKNOWN'],
568603
['created_at', 'yesterday'],
569604
['created_at', '2026-02-31T12:34:56Z'],
570605
['created_at', '2026-01-01T00:00:60Z'],
@@ -579,6 +614,14 @@ describe('Harmonic people retrieval', () => {
579614
).rejects.toThrow(/saved search/)
580615
})
581616

617+
it('passes an unrecognized user_saved_search_type through instead of failing the list', async () => {
618+
const result = await harmonicListPeopleSavedSearchesTool.transformResponse!(
619+
jsonResponse([{ ...validPeopleSavedSearch, user_saved_search_type: 'SOMETHING_NEW' }])
620+
)
621+
expect(result.output.savedSearches).toHaveLength(1)
622+
expect(result.output.savedSearches[0].userSavedSearchType).toBe('SOMETHING_NEW')
623+
})
624+
582625
it.each([
583626
'id',
584627
'entity_urn',
@@ -929,6 +972,15 @@ describe('Harmonic person enrichment', () => {
929972
}
930973
})
931974

975+
it('rejects company context URNs from another entity family', () => {
976+
expect(() =>
977+
buildUrl(harmonicGetPersonTool, {
978+
personId: '123',
979+
companyContextUrns: ['urn:harmonic:person:1'],
980+
})
981+
).toThrow('"companyContextUrns" must contain only company URNs')
982+
})
983+
932984
it('repeats company context URNs as query parameters', () => {
933985
expect(
934986
buildUrl(harmonicGetPersonTool, {
@@ -1098,6 +1150,62 @@ describe('Harmonic email enrichment', () => {
10981150
).toThrow('must contain absolute http(s) URLs')
10991151
})
11001152

1153+
it('deduplicates LinkedIn URLs after canonicalisation so quota is not spent twice', () => {
1154+
expect(
1155+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1156+
personLinkedinUrls: [
1157+
'https://www.linkedin.com/in/ada?utm_source=x',
1158+
'https://www.linkedin.com/in/ada',
1159+
'https://www.linkedin.com/in/ada#about',
1160+
],
1161+
})
1162+
).toEqual({ person_linkedin_urls: ['https://www.linkedin.com/in/ada'] })
1163+
})
1164+
1165+
it('reports the identifier conflict before complaining about any single URL', () => {
1166+
expect(() =>
1167+
buildBody(harmonicSubmitEmailEnrichmentJobTool, {
1168+
personUrns: ['urn:harmonic:person:1'],
1169+
personLinkedinUrls: ['not-a-url'],
1170+
})
1171+
).toThrow('accepts person URNs or LinkedIn URLs, not both')
1172+
})
1173+
1174+
it('surfaces the bulk email error codes with their quota counters', () => {
1175+
expect(
1176+
extractErrorMessage(
1177+
{
1178+
status: 429,
1179+
data: {
1180+
error: 'MONTHLY_QUOTA_INSUFFICIENT',
1181+
needed: 500,
1182+
available: 20,
1183+
submitted: 500,
1184+
},
1185+
},
1186+
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
1187+
)
1188+
).toBe('MONTHLY_QUOTA_INSUFFICIENT (needed 500, available 20, submitted 500)')
1189+
1190+
expect(
1191+
extractErrorMessage(
1192+
{ status: 422, data: { error: 'NO_ELIGIBLE_PEOPLE', submitted: 3, dropped: [] } },
1193+
harmonicSubmitEmailEnrichmentJobTool.errorExtractor
1194+
)
1195+
).toBe('NO_ELIGIBLE_PEOPLE (submitted 3)')
1196+
1197+
/**
1198+
* `extractErrorMessage` with no id walks every extractor in order, so a bare
1199+
* `error` key here would hijack other providers' envelopes.
1200+
*/
1201+
expect(
1202+
extractErrorMessage({
1203+
status: 400,
1204+
data: { error: 'invalid_grant', error_description: 'The grant is invalid' },
1205+
})
1206+
).toBe('The grant is invalid')
1207+
})
1208+
11011209
it('forwards unrecognised profile URLs so Harmonic can drop them per item', () => {
11021210
expect(
11031211
buildBody(harmonicSubmitEmailEnrichmentJobTool, {

apps/sim/tools/harmonic/types.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -120,24 +120,6 @@ export interface HarmonicEnrichmentOutput {
120120
enriched_entity_urn?: unknown
121121
}
122122

123-
export interface HarmonicDroppedPerson {
124-
submitted_identifier?: unknown
125-
reason?: unknown
126-
}
127-
128-
export interface HarmonicPersonJobResultOutput {
129-
person_urn?: unknown
130-
status?: unknown
131-
}
132-
133-
export interface HarmonicPersonJobCountsOutput {
134-
total_processed?: unknown
135-
total_succeeded?: unknown
136-
total_failed?: unknown
137-
total_skipped?: unknown
138-
total_not_found?: unknown
139-
}
140-
141123
export interface HarmonicEnrichmentStatus {
142124
enrichmentUrn: string | null
143125
status: string | null

apps/sim/tools/harmonic/utils.ts

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,8 @@ export const HARMONIC_EMPLOYEE_GROUP_TYPES = [
3434
'NON_PARTNERS',
3535
] as const
3636
export const HARMONIC_EMPLOYEE_STATUSES = ['ACTIVE', 'NOT_ACTIVE', 'ACTIVE_AND_NOT_ACTIVE'] as const
37-
export const HARMONIC_USER_CONNECTION_STATUSES = [
38-
'USER_CONNECTION',
39-
'TEAM_CONNECTION',
40-
'NO_CONNECTION',
41-
] as const
37+
/** Harmonic documents per-user connection filtering as unsupported via the API. */
38+
export const HARMONIC_USER_CONNECTION_STATUSES = ['TEAM_CONNECTION', 'NO_CONNECTION'] as const
4239
/** Terminal states for a bulk email-enrichment job; `results` stays null until one is reached. */
4340
export const HARMONIC_EMAIL_JOB_TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED'])
4441
export const HARMONIC_PERSON_INCLUDE_FIELDS = [
@@ -61,6 +58,7 @@ const PERSON_URN_PATTERN = /^urn:harmonic:person:[^\s]+$/
6158
const SAVED_SEARCH_URN_PATTERN = /^urn:harmonic:saved_search:[^\s]+$/
6259
const USER_URN_PATTERN = /^urn:harmonic:user:[^\s]+$/
6360
const ENRICHMENT_URN_PATTERN = /^urn:harmonic:enrichment:[^\s]+$/
61+
const COMPANY_URN_PATTERN = /^urn:harmonic:company:[^\s]+$/
6462
const COMPANY_OR_PERSON_URN_PATTERN = /^urn:harmonic:(company|person):[^\s]+$/
6563
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
6664
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -230,14 +228,14 @@ function requireUserUrn(value: unknown): string {
230228
return normalized
231229
}
232230

231+
/**
232+
* Passed through rather than checked against a fixed set. This value is display
233+
* metadata that nothing downstream branches on, and Harmonic owns the enum — an
234+
* allow-list would turn any value they add into a hard failure of the entire list,
235+
* while the selector reading the same rows kept working.
236+
*/
233237
function requireUserSavedSearchType(value: unknown): string {
234-
const normalized = requireSavedSearchString(value, 'user_saved_search_type')
235-
if (!HARMONIC_USER_SAVED_SEARCH_TYPES.has(normalized)) {
236-
throw new Error(
237-
'Harmonic returned a people saved search with an invalid user_saved_search_type'
238-
)
239-
}
240-
return normalized
238+
return requireSavedSearchString(value, 'user_saved_search_type')
241239
}
242240

243241
function requireSavedSearchTimestamp(value: unknown, field: string): string {
@@ -357,10 +355,6 @@ export function parsePersonUrns(value: unknown, paramName = 'personUrns'): strin
357355
return normalizePersonUrns(parseArrayParam(value, paramName), paramName)
358356
}
359357

360-
export function parsePersonIds(value: unknown): number[] {
361-
return normalizePersonIds(parseArrayParam(value, 'personIds'))
362-
}
363-
364358
export function clampPageSize(value: unknown): number {
365359
if (value === undefined || value === null || value === '') return HARMONIC_PAGE_SIZE_DEFAULT
366360
const parsed = parseSafeDecimalInteger(value, 'size')
@@ -700,6 +694,9 @@ export function buildGetPersonUrl(personId: unknown, companyContextUrns: unknown
700694
`${HARMONIC_API_BASE}/persons/${encodeURIComponent(requireIdentifier(personId, 'personId'))}`
701695
)
702696
for (const urn of uniqueStrings(parseArrayParam(companyContextUrns, 'companyContextUrns'))) {
697+
if (!COMPANY_URN_PATTERN.test(urn)) {
698+
throw new Error('Harmonic "companyContextUrns" must contain only company URNs')
699+
}
703700
url.searchParams.append('company_context_urns', urn)
704701
}
705702
return url.toString()
@@ -843,34 +840,42 @@ export function buildEmailEnrichmentJobBody(
843840
* for Harmonic to adjudicate. Only values that are not absolute http(s) URLs at
844841
* all are rejected here, because those are a local mistake, not a provider call.
845842
*/
846-
const linkedinUrls = uniqueStrings(parseArrayParam(personLinkedinUrls, 'personLinkedinUrls')).map(
847-
(value) => {
843+
const rawLinkedinUrls = parseArrayParam(personLinkedinUrls, 'personLinkedinUrls')
844+
845+
/**
846+
* Harmonic documents these as mutually exclusive — "Provide exactly one of the
847+
* two arrays" — so sending both is rejected locally rather than letting the
848+
* provider silently pick one and bill for it. This runs before any per-URL work
849+
* so the clearer of the two errors wins when both problems are present.
850+
*/
851+
if (urns.length > 0 && rawLinkedinUrls.length > 0) {
852+
throw new Error(
853+
'Harmonic Submit Email Enrichment Job accepts person URNs or LinkedIn URLs, not both'
854+
)
855+
}
856+
857+
/**
858+
* Canonicalise first, then deduplicate: `.../in/foo?utm_source=x` and `.../in/foo`
859+
* are the same person, and Harmonic bills each submitted entry, so deduplicating
860+
* the raw strings would spend quota twice and double-count against the cap.
861+
*/
862+
const linkedinUrls = uniqueStrings(
863+
rawLinkedinUrls.map((value) => {
848864
const normalized = normalizeLinkedinProfileUrl(value)
849865
if (normalized) return normalized
850866
try {
851-
const parsed = new URL(value)
867+
const parsed = new URL(String(value))
852868
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
853869
throw new Error('unsupported scheme')
854870
}
855-
return value
871+
return String(value)
856872
} catch {
857873
throw new Error(
858874
'Harmonic "personLinkedinUrls" must contain absolute http(s) URLs; Harmonic reports unmatched profiles in dropped'
859875
)
860876
}
861-
}
877+
})
862878
)
863-
864-
/**
865-
* Harmonic documents these as mutually exclusive — "Provide exactly one of the
866-
* two arrays" — so sending both is rejected locally rather than letting the
867-
* provider silently pick one and bill for it.
868-
*/
869-
if (urns.length > 0 && linkedinUrls.length > 0) {
870-
throw new Error(
871-
'Harmonic Submit Email Enrichment Job accepts person URNs or LinkedIn URLs, not both'
872-
)
873-
}
874879
const identifiers = urns.length > 0 ? urns : linkedinUrls
875880
if (identifiers.length === 0) {
876881
throw new Error(

0 commit comments

Comments
 (0)