Skip to content

Commit 52c5d69

Browse files
committed
fix(cbinsights): reject malformed input instead of silently rescoping a billed query
CB Insights is metered, so a filter that fails to parse must fail the request — dropping it does not narrow the result, it charges for a query the caller never asked for. - reject an unrecognized boolean rather than dropping it, which had been widening a VC-backed firmographics search - reject a non-numeric limit instead of falling back to the endpoint default - reject non-text filter entries instead of stringifying them to "[object Object]" - accept only asc/desc for sort direction; a typo had returned the bottom of a metered result set as though it were the top - treat a whitespace-only numeric bound as unset, not as zero - drop `totalHits`/`totalHitsRelation` from list business relationships; that endpoint reports no total, so both were permanently null - trim `nextPageToken`, matching the id fields Also moves the token cache onto `lru-cache` per the in-process caching rule, replacing hand-rolled TTL arithmetic and a manual prune.
1 parent 2b90218 commit 52c5d69

12 files changed

Lines changed: 265 additions & 70 deletions

apps/docs/content/docs/en/integrations/cbinsights.mdx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,8 +489,6 @@ Retrieve partnerships, client/vendor relationships, and licensing activity for u
489489
| --------- | ---- | ----------- |
490490
| `orgs` | json | Organizations as \[\{orgId, businessRelationships\}\] |
491491
| `nextPageToken` | string | Token for the next page, or null when there are no more results |
492-
| `totalHits` | number | Total number of matching records |
493-
| `totalHitsRelation` | string | Whether totalHits is exact \('eq'\) or a floor \('gte', used above 10,000\) |
494492

495493
### CB Insights List Management and Board
496494

apps/sim/tools/cbinsights/cbinsights.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cbinsightsChatTool } from '@/tools/cbinsights/chat'
66
import { cbinsightsGetOrgFundingsTool } from '@/tools/cbinsights/get_org_fundings'
77
import { cbinsightsGetOrgOutlookTool } from '@/tools/cbinsights/get_org_outlook'
88
import { cbinsightsGetScoutingReportTool } from '@/tools/cbinsights/get_scouting_report'
9+
import { cbinsightsListBusinessRelationshipsTool } from '@/tools/cbinsights/list_business_relationships'
910
import { cbinsightsListFundingsTool } from '@/tools/cbinsights/list_fundings'
1011
import { cbinsightsLookupOrganizationsTool } from '@/tools/cbinsights/lookup_organizations'
1112
import { cbinsightsRagTool } from '@/tools/cbinsights/rag'
@@ -268,6 +269,110 @@ describe('cbinsights request building', () => {
268269
expect(JSON.parse(String(calls[2].init.body)).sectorIds).toEqual([1, 2])
269270
})
270271

272+
/*
273+
* The sibling of the numeric bound. `parseBooleanParam` used to return
274+
* undefined for anything it did not recognize, so a model answering "yes"
275+
* dropped the restriction entirely and widened the search — the same failure,
276+
* on a filter the caller explicitly set.
277+
*/
278+
it.each(['yes', '1', 'TRUE ish', 0])(
279+
'rejects the unrecognized boolean %j rather than dropping the filter',
280+
async (entry) => {
281+
mockFetch([AUTH_OK])
282+
await expect(
283+
cbinsightsSearchFirmographicsTool.directExecution!({
284+
...CREDS,
285+
keyword: 'fintech',
286+
vcBacked: entry,
287+
} as never)
288+
).rejects.toThrow(/"vcBacked" must be true or false/)
289+
expect(calls).toHaveLength(0)
290+
}
291+
)
292+
293+
it.each([
294+
['true', true],
295+
['FALSE', false],
296+
[true, true],
297+
[false, false],
298+
])('still accepts the boolean form %j a dropdown emits', async (entry, expected) => {
299+
mockFetch([AUTH_OK, { body: { orgs: [] } }])
300+
await cbinsightsSearchFirmographicsTool.directExecution!({
301+
...CREDS,
302+
keyword: 'fintech',
303+
vcBacked: entry,
304+
} as never)
305+
expect(JSON.parse(String(calls[1].init.body)).vcBacked).toBe(expected)
306+
})
307+
308+
it('treats the dropdown\'s "Any" option as no filter at all', async () => {
309+
mockFetch([AUTH_OK, { body: { orgs: [] } }])
310+
await cbinsightsSearchFirmographicsTool.directExecution!({
311+
...CREDS,
312+
keyword: 'fintech',
313+
vcBacked: '',
314+
} as never)
315+
expect(JSON.parse(String(calls[1].init.body))).toEqual({ keyword: 'fintech' })
316+
})
317+
318+
/*
319+
* A mistyped direction used to fold into `desc`, handing back the bottom of a
320+
* metered result set as though it were the top.
321+
*/
322+
it('rejects a sort direction that is neither asc nor desc', async () => {
323+
mockFetch([AUTH_OK])
324+
await expect(
325+
cbinsightsSearchFirmographicsTool.directExecution!({
326+
...CREDS,
327+
keyword: 'fintech',
328+
sortField: 'mosaicOverall',
329+
sortDirection: 'ascending',
330+
} as never)
331+
).rejects.toThrow(/"sortDirection" must be "asc" or "desc"/)
332+
expect(calls).toHaveLength(0)
333+
})
334+
335+
/*
336+
* `limit` is the last silent-drop path: falling back to the endpoint default
337+
* returns a different page than the caller asked for, and still bills for it.
338+
*/
339+
it('rejects a mistyped limit rather than falling back to the endpoint default', async () => {
340+
mockFetch([AUTH_OK])
341+
await expect(
342+
cbinsightsLookupOrganizationsTool.directExecution!({
343+
...CREDS,
344+
names: 'a',
345+
limit: 'twenty',
346+
} as never)
347+
).rejects.toThrow(/"limit" must be a number/)
348+
expect(calls).toHaveLength(0)
349+
})
350+
351+
/*
352+
* A block-to-block reference can hand over an object. Stringifying it searched
353+
* for the literal "[object Object]" and reported success on zero matches.
354+
*/
355+
it('rejects a non-text entry in a free-text filter rather than stringifying it', async () => {
356+
mockFetch([AUTH_OK])
357+
await expect(
358+
cbinsightsLookupOrganizationsTool.directExecution!({
359+
...CREDS,
360+
names: [{ name: 'CB Insights' }],
361+
} as never)
362+
).rejects.toThrow(/"names" must contain only text values/)
363+
expect(calls).toHaveLength(0)
364+
})
365+
366+
it('treats a whitespace-only numeric bound as unset, not as zero', async () => {
367+
mockFetch([AUTH_OK, { body: { orgs: [] } }])
368+
await cbinsightsSearchFirmographicsTool.directExecution!({
369+
...CREDS,
370+
keyword: 'fintech',
371+
minCurrentHeadcount: ' ',
372+
} as never)
373+
expect(JSON.parse(String(calls[1].init.body))).toEqual({ keyword: 'fintech' })
374+
})
375+
271376
it('rejects a mistyped numeric bound rather than dropping it', async () => {
272377
mockFetch([AUTH_OK])
273378
await expect(
@@ -469,6 +574,24 @@ describe('cbinsights response mapping', () => {
469574
})
470575
})
471576

577+
/*
578+
* Business relationships is the one paged endpoint whose documented response
579+
* carries no total, so the tool must not manufacture a permanently-null
580+
* `totalHits` alongside the real token.
581+
*/
582+
it('reports only the fields the business-relationships endpoint documents', async () => {
583+
mockFetch([AUTH_OK, { body: { orgs: [{ orgId: 1 }], nextPageToken: 'tok' } }])
584+
585+
const result = await cbinsightsListBusinessRelationshipsTool.directExecution!({
586+
...CREDS,
587+
orgIds: '1',
588+
} as never)
589+
590+
expect(result.output).toEqual({ orgs: [{ orgId: 1 }], nextPageToken: 'tok' })
591+
expect(cbinsightsListBusinessRelationshipsTool.outputs).not.toHaveProperty('totalHits')
592+
expect(cbinsightsListBusinessRelationshipsTool.outputs).not.toHaveProperty('totalHitsRelation')
593+
})
594+
472595
it('renames the API chatID to the block-facing chatId', async () => {
473596
mockFetch([
474597
AUTH_OK,

apps/sim/tools/cbinsights/get_org_fundings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const cbinsightsGetOrgFundingsTool: ToolConfig<CbInsightsOrgFundingsParam
7171
path: `/v2/organizations/${orgId}/financialtransactions/fundings`,
7272
body: compactBody({
7373
limit: clampLimit(params.limit),
74-
nextPageToken: params.nextPageToken,
74+
nextPageToken: params.nextPageToken?.trim(),
7575
}),
7676
},
7777
(data) => ({

apps/sim/tools/cbinsights/get_org_investments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const cbinsightsGetOrgInvestmentsTool: ToolConfig<
7373
path: `/v2/organizations/${orgId}/financialtransactions/investments`,
7474
body: compactBody({
7575
limit: clampLimit(params.limit),
76-
nextPageToken: params.nextPageToken,
76+
nextPageToken: params.nextPageToken?.trim(),
7777
}),
7878
},
7979
(data) => ({ investments: asArray(data.investments), ...pageInfo(data) }),

apps/sim/tools/cbinsights/list_business_relationships.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import type { CbInsightsListResponse, CbInsightsOrgListParams } from '@/tools/cbinsights/types'
1+
import type {
2+
CbInsightsOrgListParams,
3+
CbInsightsPagedOrgListResponse,
4+
} from '@/tools/cbinsights/types'
25
import {
36
asArray,
7+
asString,
48
cbInsightsRequest,
59
compactBody,
6-
pageInfo,
710
requireOrgIds,
811
} from '@/tools/cbinsights/utils'
912
import type { ToolConfig } from '@/tools/types'
@@ -14,7 +17,7 @@ interface CbInsightsListBusinessRelationshipsParams extends CbInsightsOrgListPar
1417

1518
export const cbinsightsListBusinessRelationshipsTool: ToolConfig<
1619
CbInsightsListBusinessRelationshipsParams,
17-
CbInsightsListResponse
20+
CbInsightsPagedOrgListResponse
1821
> = {
1922
id: 'cbinsights_list_business_relationships',
2023
name: 'CB Insights List Business Relationships',
@@ -58,10 +61,10 @@ export const cbinsightsListBusinessRelationshipsTool: ToolConfig<
5861
path: '/v2/businessrelationships',
5962
body: compactBody({
6063
orgIds: requireOrgIds(params.orgIds),
61-
nextPageToken: params.nextPageToken,
64+
nextPageToken: params.nextPageToken?.trim(),
6265
}),
6366
},
64-
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),
67+
(data) => ({ orgs: asArray(data.orgs), nextPageToken: asString(data.nextPageToken) }),
6568
signal
6669
),
6770

@@ -70,20 +73,15 @@ export const cbinsightsListBusinessRelationshipsTool: ToolConfig<
7073
type: 'json',
7174
description: 'Organizations as [{orgId, businessRelationships}]',
7275
},
76+
/*
77+
* This is the one paged endpoint that reports no total: the documented
78+
* response carries `orgs` and `nextPageToken` only, so declaring `totalHits`
79+
* here would promise a field that is always null.
80+
*/
7381
nextPageToken: {
7482
type: 'string',
7583
nullable: true,
7684
description: 'Token for the next page, or null when there are no more results',
7785
},
78-
totalHits: {
79-
type: 'number',
80-
nullable: true,
81-
description: 'Total number of matching records',
82-
},
83-
totalHitsRelation: {
84-
type: 'string',
85-
nullable: true,
86-
description: "Whether totalHits is exact ('eq') or a floor ('gte', used above 10,000)",
87-
},
8886
},
8987
}

apps/sim/tools/cbinsights/list_fundings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const cbinsightsListFundingsTool: ToolConfig<
7272
body: compactBody({
7373
orgIds: requireOrgIds(params.orgIds),
7474
limit: clampLimit(params.limit),
75-
nextPageToken: params.nextPageToken,
75+
nextPageToken: params.nextPageToken?.trim(),
7676
}),
7777
},
7878
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),

apps/sim/tools/cbinsights/list_investments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const cbinsightsListInvestmentsTool: ToolConfig<
7272
body: compactBody({
7373
orgIds: requireOrgIds(params.orgIds),
7474
limit: clampLimit(params.limit),
75-
nextPageToken: params.nextPageToken,
75+
nextPageToken: params.nextPageToken?.trim(),
7676
}),
7777
},
7878
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),

apps/sim/tools/cbinsights/list_portfolio_exits.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const cbinsightsListPortfolioExitsTool: ToolConfig<
7272
body: compactBody({
7373
orgIds: requireOrgIds(params.orgIds),
7474
limit: clampLimit(params.limit),
75-
nextPageToken: params.nextPageToken,
75+
nextPageToken: params.nextPageToken?.trim(),
7676
}),
7777
},
7878
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),

apps/sim/tools/cbinsights/lookup_organizations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export const cbinsightsLookupOrganizationsTool: ToolConfig<
105105
urls,
106106
profileUrl,
107107
limit: clampLimit(params.limit),
108-
nextPageToken: params.nextPageToken,
108+
nextPageToken: params.nextPageToken?.trim(),
109109
}),
110110
},
111111
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),

apps/sim/tools/cbinsights/search_firmographics.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ interface CbInsightsFirmographicsParams extends CbInsightsAuthParams {
5353
nextPageToken?: string
5454
}
5555

56+
/**
57+
* Reads the sort direction, defaulting to the API's own `desc` when unset.
58+
*
59+
* A value that is neither is rejected rather than folded into the default: a
60+
* mistyped `"ascending"` would silently reverse the page and hand back the
61+
* bottom of the result set as though it were the top, on a metered search.
62+
*/
63+
function sortDirection(value: unknown): 'asc' | 'desc' {
64+
if (value === undefined || value === null) return 'desc'
65+
const normalized = String(value).trim().toLowerCase()
66+
if (normalized === '') return 'desc'
67+
if (normalized === 'asc' || normalized === 'desc') return normalized
68+
throw new Error(
69+
`CB Insights "sortDirection" must be "asc" or "desc" (received "${String(value)}")`
70+
)
71+
}
72+
5673
export const cbinsightsSearchFirmographicsTool: ToolConfig<
5774
CbInsightsFirmographicsParams,
5875
CbInsightsListResponse
@@ -358,7 +375,7 @@ export const cbinsightsSearchFirmographicsTool: ToolConfig<
358375
),
359376
minLastFundingDate: params.minLastFundingDate?.trim(),
360377
maxLastFundingDate: params.maxLastFundingDate?.trim(),
361-
vcBacked: parseBooleanParam(params.vcBacked),
378+
vcBacked: parseBooleanParam(params.vcBacked, 'vcBacked'),
362379
})
363380

364381
/*
@@ -375,18 +392,15 @@ export const cbinsightsSearchFirmographicsTool: ToolConfig<
375392
...filters,
376393
...compactBody({
377394
limit: clampLimit(params.limit),
378-
nextPageToken: params.nextPageToken,
395+
nextPageToken: params.nextPageToken?.trim(),
379396
}),
380397
}
381398

382399
/* The API takes one sort object; the block exposes it as two plain fields
383400
so neither has to be typed as JSON. */
384401
const sortField = params.sortField?.trim()
385402
if (sortField) {
386-
body.sort = {
387-
field: sortField,
388-
direction: params.sortDirection === 'asc' ? 'asc' : 'desc',
389-
}
403+
body.sort = { field: sortField, direction: sortDirection(params.sortDirection) }
390404
}
391405

392406
return cbInsightsRequest<{

0 commit comments

Comments
 (0)