Skip to content

Commit f17938c

Browse files
authored
feat(cbinsights): add CB Insights API v2 integration (#6879)
* feat(cbinsights): add CB Insights API v2 integration Covers every non-streaming v2 endpoint across 25 tools: free organization lookup, firmographics search, funding rounds and cap tables, investments, portfolio exits, business relationships, management and board, the Mosaic / Commercial Maturity / Exit Probability outlooks and their histories, funding windows, revenue, strategy maps, Scouting Reports, ChatCBI, and RAG context. CB Insights authorizes by client-credential exchange rather than a static key, so the tools run through directExecution: the shared executor trades the credentials for a bearer token, caches it briefly, and re-authorizes once on a 401 — the token lifetime is undocumented, so expiry is discovered rather than predicted. ChatCBI and RAG declare request.modelInput so an activated Sim secret in the message is projected to its canonical label before reaching a third party's model. directExecution still runs projectToolModelInputParams, so the two are compatible. The two streaming endpoints are deliberately excluded; they deliver incremental JSON chunks and their non-streaming counterparts return the same content in one piece. * fix(cbinsights): reject malformed ID lists and bound the token cache - Reject an organization ID list containing an invalid entry instead of dropping it. Silently filtering meant a typo ran the request against a narrower set — spending credits on the wrong organizations, or quietly widening a filtered search — and still reported success. - Apply the same rule to the optional firmographics ID filters, where a dropped filter broadens the search rather than narrowing it. - Bound the process-wide token cache so a long-lived worker serving many CB Insights accounts does not grow with the cumulative number of accounts seen. Expired entries are swept on write, then the oldest evicted. * fix(cbinsights): stop paging and blank input bypassing the search guards - Measure the firmographics empty-search guard against the filters alone. limit, nextPageToken, and sort were in the same object, so a request carrying only paging slipped past it and issued an unfiltered search over the whole database — which still spends credits. - Reject a mistyped numeric bound instead of dropping it. A bad headcount, funding, or valuation filter silently widened the search, the same failure mode already fixed for ID lists. - Treat an empty comma segment identically on the required and optional paths. A trailing or doubled comma is a separator artifact that cannot change which records are requested, so both paths now discard it; every other malformed entry is still rejected. * fix(cbinsights): accept only plain decimal organization IDs Number reads "0x10" as 16 and "1e2" as 100, so either notation resolved to a real but unintended organization and the request spent credits on it. Both the path-scoped and the bulk validators now require a plain run of digits, and use Number.isSafeInteger so an ID past the precision limit cannot round to a neighbouring one. * fix(cbinsights): bound a numeric organization ID to the safe-integer range The string path already required a safe integer; the numeric path still used Number.isInteger, which accepts a value past the precision limit. JSON parsing has already rounded such a value, so the request would target a different organization than the caller supplied.
1 parent 8aab6d5 commit f17938c

43 files changed

Lines changed: 5854 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/components/icons.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5695,6 +5695,22 @@ export function Neo4jIcon(props: SVGProps<SVGSVGElement>) {
56955695
)
56965696
}
56975697

5698+
export function CbInsightsIcon(props: SVGProps<SVGSVGElement>) {
5699+
return (
5700+
<svg {...props} viewBox='0 0 64 64' fill='none' xmlns='http://www.w3.org/2000/svg'>
5701+
<path d='M63.858 28.878H44V9.58h11.78A8.24 8.24 0 0 1 64 17.809v11.07z' fill='#006699' />
5702+
<path
5703+
d='M55.628 54.28H43.85V33.703h19.867V46.05c.142 4.54-3.548 8.23-8.1 8.23z'
5704+
fill='#ff6633'
5705+
/>
5706+
<path
5707+
d='M20.718 33.703v-4.967h19.44V9.58H8.1c-4.54 0-8.1 3.7-8.1 8.23V46.2a8.24 8.24 0 0 0 8.231 8.231H40.3V33.703z'
5708+
fill='#003366'
5709+
/>
5710+
</svg>
5711+
)
5712+
}
5713+
56985714
export function CalendlyIcon(props: SVGProps<SVGSVGElement>) {
56995715
return (
57005716
<svg {...props} viewBox='169.28 46.16 502.57 502.57' xmlns='http://www.w3.org/2000/svg'>

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
BufferIcon,
3434
CalComIcon,
3535
CalendlyIcon,
36+
CbInsightsIcon,
3637
CirclebackIcon,
3738
ClaudeIcon,
3839
ClayIcon,
@@ -295,6 +296,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
295296
buffer: BufferIcon,
296297
calcom: CalComIcon,
297298
calendly: CalendlyIcon,
299+
cbinsights: CbInsightsIcon,
298300
circleback: CirclebackIcon,
299301
clay: ClayIcon,
300302
clerk: ClerkIcon,

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

Lines changed: 611 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"calcom",
3535
"calcom-service-account",
3636
"calendly",
37+
"cbinsights",
3738
"circleback",
3839
"clay",
3940
"clerk",
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { CbInsightsBlock } from '@/blocks/blocks/cbinsights'
6+
7+
/**
8+
* Every assertion here runs against `{ ...inputs, ...buildParams(inputs) }`, the
9+
* shape the generic tool handler actually forwards. A key the mapper omits is
10+
* *not* dropped by that merge — the raw subBlock value survives — so asserting
11+
* on the mapper's return alone would prove nothing about what the tool receives.
12+
*/
13+
describe('CbInsightsBlock', () => {
14+
const buildParams = CbInsightsBlock.tools.config!.params!
15+
const selectTool = CbInsightsBlock.tools.config!.tool!
16+
17+
const resolve = (inputs: Record<string, unknown>) => ({ ...inputs, ...buildParams(inputs) })
18+
19+
const operationIds =
20+
CbInsightsBlock.subBlocks
21+
.find((subBlock) => subBlock.id === 'operation')
22+
?.options?.map((option) => (option as { id: string }).id) ?? []
23+
24+
it('maps every dropdown operation onto a registered tool', () => {
25+
expect(operationIds).toHaveLength(25)
26+
expect(new Set(operationIds.map((id) => selectTool({ operation: id })))).toEqual(
27+
new Set(CbInsightsBlock.tools.access)
28+
)
29+
})
30+
31+
it('rejects an operation the dropdown does not offer', () => {
32+
expect(() => selectTool({ operation: 'get_org_secrets' })).toThrow(
33+
/Invalid CB Insights operation/
34+
)
35+
})
36+
37+
it('gives every subblock a unique id', () => {
38+
const ids = CbInsightsBlock.subBlocks.map((subBlock) => subBlock.id)
39+
expect(ids).toHaveLength(new Set(ids).size)
40+
})
41+
42+
it('never hides a required field behind advanced mode', () => {
43+
const advancedRequired = CbInsightsBlock.subBlocks
44+
.filter((subBlock) => subBlock.mode === 'advanced' && subBlock.required)
45+
.map((subBlock) => subBlock.id)
46+
47+
expect(advancedRequired).toEqual([])
48+
})
49+
50+
it('always forwards the credentials', () => {
51+
const params = resolve({ operation: 'chat', clientId: 'id', clientSecret: 'secret' })
52+
expect(params.clientId).toBe('id')
53+
expect(params.clientSecret).toBe('secret')
54+
})
55+
56+
/*
57+
* The mapper's whole job is dropping keys that belong to another operation.
58+
* `shouldSerializeSubBlock` short-circuits on `mode: 'advanced'` before it
59+
* evaluates a condition, so a hidden advanced field still reaches `inputs` —
60+
* only an explicit `undefined` removes it from what goes out on the wire.
61+
*/
62+
it('drops a previous operation’s leftovers when the operation changes', () => {
63+
const params = resolve({
64+
operation: 'chat',
65+
clientId: 'id',
66+
clientSecret: 'secret',
67+
message: 'What is growing?',
68+
orgId: '129410',
69+
orgIds: '1,2,3',
70+
keyword: 'fintech',
71+
titleIds: '50',
72+
startDate: '2025-01-01',
73+
endDate: '2025-06-01',
74+
limit: '50',
75+
nextPageToken: 'stale-token',
76+
names: 'CB Insights',
77+
sortField: 'mosaicOverall',
78+
minCurrentHeadcount: '10',
79+
})
80+
81+
expect(params.message).toBe('What is growing?')
82+
expect(params.orgId).toBeUndefined()
83+
expect(params.orgIds).toBeUndefined()
84+
expect(params.keyword).toBeUndefined()
85+
expect(params.titleIds).toBeUndefined()
86+
expect(params.startDate).toBeUndefined()
87+
expect(params.endDate).toBeUndefined()
88+
expect(params.limit).toBeUndefined()
89+
expect(params.nextPageToken).toBeUndefined()
90+
expect(params.names).toBeUndefined()
91+
expect(params.sortField).toBeUndefined()
92+
expect(params.minCurrentHeadcount).toBeUndefined()
93+
})
94+
95+
/*
96+
* Lookup and firmographics both filter on organization IDs and websites, but
97+
* the endpoints take different shapes, so the block gives each its own
98+
* subblock and the mapper folds them onto the single tool param.
99+
*/
100+
it('folds the firmographics-only aliases onto the shared tool params', () => {
101+
const firmographics = resolve({
102+
operation: 'search_firmographics',
103+
clientId: 'id',
104+
clientSecret: 'secret',
105+
orgIdFilter: '129410, 129411',
106+
firmographicsUrls: 'cbinsights.com',
107+
urls: 'ignored-lookup-value.com',
108+
orgIds: 'ignored-list-value',
109+
})
110+
111+
expect(firmographics.orgIds).toBe('129410, 129411')
112+
expect(firmographics.urls).toBe('cbinsights.com')
113+
expect(firmographics.orgIdFilter).toBeUndefined()
114+
expect(firmographics.firmographicsUrls).toBeUndefined()
115+
116+
const lookup = resolve({
117+
operation: 'lookup_organizations',
118+
clientId: 'id',
119+
clientSecret: 'secret',
120+
urls: 'cbinsights.com',
121+
orgIdFilter: '129410',
122+
firmographicsUrls: 'wrong.com',
123+
})
124+
125+
expect(lookup.urls).toBe('cbinsights.com')
126+
expect(lookup.orgIds).toBeUndefined()
127+
})
128+
129+
it('sends the organization ID only to path-scoped operations', () => {
130+
const scoped = resolve({
131+
operation: 'get_org_outlook',
132+
clientId: 'id',
133+
clientSecret: 'secret',
134+
orgId: '129410',
135+
orgIds: '1,2',
136+
})
137+
expect(scoped.orgId).toBe('129410')
138+
expect(scoped.orgIds).toBeUndefined()
139+
140+
const listed = resolve({
141+
operation: 'list_outlook',
142+
clientId: 'id',
143+
clientSecret: 'secret',
144+
orgId: '129410',
145+
orgIds: '1,2',
146+
})
147+
expect(listed.orgId).toBeUndefined()
148+
expect(listed.orgIds).toBe('1,2')
149+
})
150+
151+
/*
152+
* Mosaic history takes a start date but no end date; the other two history
153+
* endpoints take both. Sending endDate to Mosaic history would be an
154+
* undocumented field on the wire.
155+
*/
156+
it('sends only the date fields each history endpoint documents', () => {
157+
const mosaic = resolve({
158+
operation: 'get_mosaic_history',
159+
clientId: 'id',
160+
clientSecret: 'secret',
161+
orgId: '1',
162+
startDate: '2025-01-01',
163+
endDate: '2025-06-01',
164+
})
165+
expect(mosaic.startDate).toBe('2025-01-01')
166+
expect(mosaic.endDate).toBeUndefined()
167+
168+
const maturity = resolve({
169+
operation: 'get_commercial_maturity_history',
170+
clientId: 'id',
171+
clientSecret: 'secret',
172+
orgId: '1',
173+
startDate: '2025-01-01',
174+
endDate: '2025-06-01',
175+
})
176+
expect(maturity.startDate).toBe('2025-01-01')
177+
expect(maturity.endDate).toBe('2025-06-01')
178+
})
179+
180+
/*
181+
* Business relationships pages with a token but documents no `limit`, so
182+
* carrying one over from another operation would put an undocumented field on
183+
* the wire.
184+
*/
185+
it('sends limit only to the operations whose endpoint documents it', () => {
186+
const relationships = resolve({
187+
operation: 'list_business_relationships',
188+
clientId: 'id',
189+
clientSecret: 'secret',
190+
orgIds: '1,2',
191+
limit: '50',
192+
nextPageToken: 'tok',
193+
})
194+
expect(relationships.limit).toBeUndefined()
195+
expect(relationships.nextPageToken).toBe('tok')
196+
197+
const fundings = resolve({
198+
operation: 'list_fundings',
199+
clientId: 'id',
200+
clientSecret: 'secret',
201+
orgIds: '1,2',
202+
limit: '50',
203+
})
204+
expect(fundings.limit).toBe('50')
205+
})
206+
})

0 commit comments

Comments
 (0)