Skip to content

Commit ce5c01e

Browse files
authored
feat(connectors): Outreach/Salesloft/Gong/RingCentral/DialPad/Demandbase OAuth2 + release 0.43.0 (#111)
6 declarative-REST OAuth2 GTM connectors (Outreach, Salesloft, Gong, RingCentral, DialPad authorization_code; Demandbase client_credentials) for Toolverse #2330 R2+R3, plus release bump 0.43.0. Refs tangle-network/agent-dev-container#2330.
1 parent d0006dd commit ce5c01e

14 files changed

Lines changed: 1700 additions & 1 deletion

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tangle-network/agent-integrations",
3-
"version": "0.42.0",
3+
"version": "0.43.0",
44
"description": "Vendor-neutral integration contracts and runtime helpers for sandbox and agent apps.",
55
"homepage": "https://github.com/tangle-network/agent-integrations#readme",
66
"repository": {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { demandbaseConnector } from '../demandbase.js'
3+
import { validateConnectorManifest, type ResolvedDataSource } from '../../types.js'
4+
5+
const ACCESS_TOKEN = 'demandbase_jwt_test'
6+
7+
const source: ResolvedDataSource = {
8+
id: 'src_demandbase',
9+
projectId: 'project_1',
10+
publishedAgentId: null,
11+
kind: 'demandbase',
12+
label: 'Demandbase',
13+
consistencyModel: 'authoritative',
14+
scopes: [],
15+
metadata: {},
16+
// client_credentials grant: the hub exchanges the API Key Set and stores
17+
// the resulting JWT as an oauth2 access token.
18+
credentials: { kind: 'oauth2', accessToken: ACCESS_TOKEN },
19+
status: 'active',
20+
}
21+
22+
afterEach(() => {
23+
vi.restoreAllMocks()
24+
})
25+
26+
function mockFetch(body: unknown, init: { status?: number; headers?: Record<string, string> } = {}) {
27+
const fetchMock = vi.fn(async (_input: URL | string, _init?: RequestInit) => new Response(JSON.stringify(body), {
28+
status: init.status ?? 200,
29+
headers: { 'content-type': 'application/json', ...init.headers },
30+
}))
31+
vi.stubGlobal('fetch', fetchMock)
32+
return fetchMock
33+
}
34+
35+
describe('demandbase adapter', () => {
36+
it('ships a valid connector manifest', () => {
37+
expect(validateConnectorManifest(demandbaseConnector.manifest)).toEqual({ ok: true, issues: [] })
38+
})
39+
40+
it('declares a client_credentials oauth2 grant with no authorize URL and sales-intelligence classification', () => {
41+
const auth = demandbaseConnector.manifest.auth
42+
expect(auth.kind).toBe('oauth2')
43+
if (auth.kind !== 'oauth2') throw new Error('auth narrowing failed')
44+
expect(auth.grantType).toBe('client_credentials')
45+
expect(auth.authorizationUrl).toBeUndefined()
46+
expect(auth.tokenUrl).toBe('https://uapi.demandbase.com/auth/v1/token')
47+
expect(auth.clientIdEnv).toBe('DEMANDBASE_OAUTH_CLIENT_ID')
48+
expect(auth.clientSecretEnv).toBe('DEMANDBASE_OAUTH_CLIENT_SECRET')
49+
expect(demandbaseConnector.manifest.category).toBe('sales-intelligence')
50+
})
51+
52+
it('exposes the expected capability surface and read/mutation split', () => {
53+
const names = demandbaseConnector.manifest.capabilities.map((c) => c.name).sort()
54+
expect(names).toEqual(['users.create', 'users.get', 'users.list'])
55+
const reads = demandbaseConnector.manifest.capabilities.filter((c) => c.class === 'read').map((c) => c.name).sort()
56+
const mutations = demandbaseConnector.manifest.capabilities.filter((c) => c.class === 'mutation').map((c) => c.name).sort()
57+
expect(reads).toEqual(['users.get', 'users.list'])
58+
expect(mutations).toEqual(['users.create'])
59+
})
60+
61+
it('exposes both executeRead and executeMutation handlers', () => {
62+
expect(typeof demandbaseConnector.executeRead).toBe('function')
63+
expect(typeof demandbaseConnector.executeMutation).toBe('function')
64+
})
65+
66+
it('declares a CAS strategy for every mutation', () => {
67+
for (const cap of demandbaseConnector.manifest.capabilities) {
68+
if (cap.class !== 'mutation') continue
69+
expect(cap.cas).toBeDefined()
70+
}
71+
})
72+
73+
it('lists users via GET /admin/v1/users (plural) with bearer auth', async () => {
74+
const fetchMock = mockFetch({ users: [] })
75+
await demandbaseConnector.executeRead!({ source, capabilityName: 'users.list', args: { limit: 10 }, idempotencyKey: 'op_0' })
76+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
77+
expect(url.origin).toBe('https://uapi.demandbase.com')
78+
expect(url.pathname).toBe('/admin/v1/users')
79+
expect(init.method).toBe('GET')
80+
expect((init.headers as Record<string, string>).authorization).toBe(`Bearer ${ACCESS_TOKEN}`)
81+
expect(url.searchParams.get('limit')).toBe('10')
82+
})
83+
84+
it('reads a single user via GET /admin/v1/user/{userId} (singular)', async () => {
85+
const fetchMock = mockFetch({ id: 'u1' })
86+
await demandbaseConnector.executeRead!({ source, capabilityName: 'users.get', args: { userId: 'u1' }, idempotencyKey: 'op_1' })
87+
const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]
88+
expect(url.pathname).toBe('/admin/v1/user/u1')
89+
})
90+
91+
it('creates a user via POST /admin/v1/user (singular) forwarding the args body', async () => {
92+
const fetchMock = mockFetch({ id: 'u2' })
93+
const result = await demandbaseConnector.executeMutation!({
94+
source,
95+
capabilityName: 'users.create',
96+
args: { email: 'a@b.com', first_name: 'Ada', last_name: 'L', role: 'analyst' },
97+
idempotencyKey: 'op_2',
98+
})
99+
expect(result.status).toBe('committed')
100+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
101+
expect(url.pathname).toBe('/admin/v1/user')
102+
expect(init.method).toBe('POST')
103+
expect(JSON.parse(String(init.body))).toEqual({ email: 'a@b.com', first_name: 'Ada', last_name: 'L', role: 'analyst' })
104+
})
105+
106+
it('throws CredentialsExpired when Demandbase rejects the token', async () => {
107+
vi.stubGlobal('fetch', vi.fn(async () => new Response('unauthorized', { status: 401 })))
108+
await expect(
109+
demandbaseConnector.executeRead!({ source, capabilityName: 'users.list', args: {}, idempotencyKey: 'unauth_1' }),
110+
).rejects.toMatchObject({ name: 'CredentialsExpired' })
111+
})
112+
113+
it('rejects unknown capabilities', async () => {
114+
await expect(
115+
demandbaseConnector.executeRead!({ source, capabilityName: 'does.not.exist', args: {}, idempotencyKey: 'unknown_1' }),
116+
).rejects.toThrow(/unknown read capability/)
117+
})
118+
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { dialpadConnector } from '../dialpad.js'
3+
import { validateConnectorManifest, type ResolvedDataSource } from '../../types.js'
4+
5+
const ACCESS_TOKEN = 'dialpad_at_test'
6+
7+
const source: ResolvedDataSource = {
8+
id: 'src_dialpad',
9+
projectId: 'project_1',
10+
publishedAgentId: null,
11+
kind: 'dialpad',
12+
label: 'Dialpad',
13+
consistencyModel: 'authoritative',
14+
scopes: [],
15+
metadata: {},
16+
credentials: { kind: 'oauth2', accessToken: ACCESS_TOKEN },
17+
status: 'active',
18+
}
19+
20+
afterEach(() => {
21+
vi.restoreAllMocks()
22+
})
23+
24+
function mockFetch(body: unknown, init: { status?: number; headers?: Record<string, string> } = {}) {
25+
const fetchMock = vi.fn(async (_input: URL | string, _init?: RequestInit) => new Response(JSON.stringify(body), {
26+
status: init.status ?? 200,
27+
headers: { 'content-type': 'application/json', ...init.headers },
28+
}))
29+
vi.stubGlobal('fetch', fetchMock)
30+
return fetchMock
31+
}
32+
33+
describe('dialpad adapter', () => {
34+
it('ships a valid connector manifest', () => {
35+
expect(validateConnectorManifest(dialpadConnector.manifest)).toEqual({ ok: true, issues: [] })
36+
})
37+
38+
it('declares authorization_code oauth2 against dialpad.com with comms classification', () => {
39+
const auth = dialpadConnector.manifest.auth
40+
expect(auth.kind).toBe('oauth2')
41+
if (auth.kind !== 'oauth2') throw new Error('auth narrowing failed')
42+
expect(auth.authorizationUrl).toBe('https://dialpad.com/oauth2/authorize')
43+
expect(auth.tokenUrl).toBe('https://dialpad.com/oauth2/token')
44+
expect(auth.clientIdEnv).toBe('DIALPAD_OAUTH_CLIENT_ID')
45+
expect(auth.clientSecretEnv).toBe('DIALPAD_OAUTH_CLIENT_SECRET')
46+
// Least-privilege: only Call--List/Get need a documented scope (calls:list);
47+
// contacts/users/sms use base bearer access. Webhook-export scopes are not
48+
// requested because this connector exposes no webhook subscriptions.
49+
expect(auth.scopes).toEqual(['calls:list', 'offline_access'])
50+
expect(dialpadConnector.manifest.category).toBe('comms')
51+
})
52+
53+
it('exposes the expected capability surface and read/mutation split', () => {
54+
const names = dialpadConnector.manifest.capabilities.map((c) => c.name).sort()
55+
expect(names).toEqual(['calls.get', 'calls.list', 'contacts.create', 'contacts.list', 'sms.send', 'users.list'])
56+
const reads = dialpadConnector.manifest.capabilities.filter((c) => c.class === 'read').map((c) => c.name).sort()
57+
const mutations = dialpadConnector.manifest.capabilities.filter((c) => c.class === 'mutation').map((c) => c.name).sort()
58+
expect(reads).toEqual(['calls.get', 'calls.list', 'contacts.list', 'users.list'])
59+
expect(mutations).toEqual(['contacts.create', 'sms.send'])
60+
})
61+
62+
it('exposes both executeRead and executeMutation handlers', () => {
63+
expect(typeof dialpadConnector.executeRead).toBe('function')
64+
expect(typeof dialpadConnector.executeMutation).toBe('function')
65+
})
66+
67+
it('declares a CAS strategy for every mutation', () => {
68+
for (const cap of dialpadConnector.manifest.capabilities) {
69+
if (cap.class !== 'mutation') continue
70+
expect(cap.cas).toBeDefined()
71+
}
72+
})
73+
74+
it('routes calls.list as GET /api/v2/call (singular) with bearer auth', async () => {
75+
const fetchMock = mockFetch({ items: [] })
76+
await dialpadConnector.executeRead!({ source, capabilityName: 'calls.list', args: { started_after: 1700000000000 }, idempotencyKey: 'op_0' })
77+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
78+
expect(url.origin).toBe('https://dialpad.com')
79+
expect(url.pathname).toBe('/api/v2/call')
80+
expect(init.method).toBe('GET')
81+
expect((init.headers as Record<string, string>).authorization).toBe(`Bearer ${ACCESS_TOKEN}`)
82+
expect(url.searchParams.get('started_after')).toBe('1700000000000')
83+
})
84+
85+
it('routes calls.get to /api/v2/call/{id}', async () => {
86+
const fetchMock = mockFetch({ id: '5' })
87+
await dialpadConnector.executeRead!({ source, capabilityName: 'calls.get', args: { id: 5 }, idempotencyKey: 'op_1' })
88+
const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]
89+
expect(url.pathname).toBe('/api/v2/call/5')
90+
})
91+
92+
it('sends an SMS via POST /api/v2/sms forwarding the args body', async () => {
93+
const fetchMock = mockFetch({ id: 'sms_1' })
94+
const result = await dialpadConnector.executeMutation!({
95+
source,
96+
capabilityName: 'sms.send',
97+
args: { to_numbers: ['+14155550111'], text: 'Hello', user_id: 99 },
98+
idempotencyKey: 'op_2',
99+
})
100+
expect(result.status).toBe('committed')
101+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
102+
expect(url.pathname).toBe('/api/v2/sms')
103+
expect(init.method).toBe('POST')
104+
expect((init.headers as Record<string, string>).authorization).toBe(`Bearer ${ACCESS_TOKEN}`)
105+
expect(JSON.parse(String(init.body))).toEqual({ to_numbers: ['+14155550111'], text: 'Hello', user_id: 99 })
106+
})
107+
108+
it('throws CredentialsExpired when Dialpad rejects the token', async () => {
109+
vi.stubGlobal('fetch', vi.fn(async () => new Response('unauthorized', { status: 401 })))
110+
await expect(
111+
dialpadConnector.executeRead!({ source, capabilityName: 'calls.list', args: {}, idempotencyKey: 'unauth_1' }),
112+
).rejects.toMatchObject({ name: 'CredentialsExpired' })
113+
})
114+
115+
it('rejects unknown capabilities', async () => {
116+
await expect(
117+
dialpadConnector.executeRead!({ source, capabilityName: 'does.not.exist', args: {}, idempotencyKey: 'unknown_1' }),
118+
).rejects.toThrow(/unknown read capability/)
119+
})
120+
})
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { gongConnector } from '../gong.js'
3+
import { validateConnectorManifest, type ResolvedDataSource } from '../../types.js'
4+
5+
const ACCESS_TOKEN = 'gong_at_test'
6+
// A normally-connected Gong source carries the per-customer host the hub
7+
// persisted from the token exchange; tests that exercise real calls default
8+
// to it. The fail-loud test below passes `source({})` to drop it.
9+
const PER_CUSTOMER_BASE = 'https://company-17.api.gong.io'
10+
11+
function source(metadata: Record<string, unknown> = { apiBaseUrlForCustomer: PER_CUSTOMER_BASE }): ResolvedDataSource {
12+
return {
13+
id: 'src_gong',
14+
projectId: 'project_1',
15+
publishedAgentId: null,
16+
kind: 'gong',
17+
label: 'Gong',
18+
consistencyModel: 'authoritative',
19+
scopes: [],
20+
metadata,
21+
credentials: { kind: 'oauth2', accessToken: ACCESS_TOKEN },
22+
status: 'active',
23+
}
24+
}
25+
26+
afterEach(() => {
27+
vi.restoreAllMocks()
28+
})
29+
30+
function mockFetch(body: unknown, init: { status?: number; headers?: Record<string, string> } = {}) {
31+
const fetchMock = vi.fn(async (_input: URL | string, _init?: RequestInit) => new Response(JSON.stringify(body), {
32+
status: init.status ?? 200,
33+
headers: { 'content-type': 'application/json', ...init.headers },
34+
}))
35+
vi.stubGlobal('fetch', fetchMock)
36+
return fetchMock
37+
}
38+
39+
describe('gong adapter', () => {
40+
it('ships a valid connector manifest', () => {
41+
expect(validateConnectorManifest(gongConnector.manifest)).toEqual({ ok: true, issues: [] })
42+
})
43+
44+
it('declares authorization_code oauth2 against app.gong.io with comms classification', () => {
45+
const auth = gongConnector.manifest.auth
46+
expect(auth.kind).toBe('oauth2')
47+
if (auth.kind !== 'oauth2') throw new Error('auth narrowing failed')
48+
expect(auth.authorizationUrl).toBe('https://app.gong.io/oauth2/authorize')
49+
expect(auth.tokenUrl).toBe('https://app.gong.io/oauth2/generate-customer-token')
50+
expect(auth.clientIdEnv).toBe('GONG_OAUTH_CLIENT_ID')
51+
expect(auth.clientSecretEnv).toBe('GONG_OAUTH_CLIENT_SECRET')
52+
expect(gongConnector.manifest.category).toBe('comms')
53+
})
54+
55+
it('exposes the expected capability surface and read/mutation split', () => {
56+
const names = gongConnector.manifest.capabilities.map((c) => c.name).sort()
57+
expect(names).toEqual([
58+
'calls.create',
59+
'calls.getExtensive',
60+
'calls.getTranscripts',
61+
'calls.list',
62+
'flows.assignProspects',
63+
'users.list',
64+
])
65+
const mutations = gongConnector.manifest.capabilities.filter((c) => c.class === 'mutation').map((c) => c.name).sort()
66+
expect(mutations).toEqual(['calls.create', 'flows.assignProspects'])
67+
})
68+
69+
it('exposes both executeRead and executeMutation handlers', () => {
70+
expect(typeof gongConnector.executeRead).toBe('function')
71+
expect(typeof gongConnector.executeMutation).toBe('function')
72+
})
73+
74+
it('declares a CAS strategy for every mutation', () => {
75+
for (const cap of gongConnector.manifest.capabilities) {
76+
if (cap.class !== 'mutation') continue
77+
expect(cap.cas).toBeDefined()
78+
}
79+
})
80+
81+
it('fails loud (no silent fallback host) when the per-customer base URL is absent', async () => {
82+
// The generic api.gong.io host is invalid for OAuth apps, so a missing
83+
// metadata.apiBaseUrlForCustomer must throw rather than route to a host
84+
// where every call would fail while the connection looks active.
85+
const fetchMock = mockFetch({ users: [] })
86+
await expect(
87+
gongConnector.executeRead!({ source: source({}), capabilityName: 'users.list', args: {}, idempotencyKey: 'op_0' }),
88+
).rejects.toThrow(/missing metadata\.apiBaseUrlForCustomer/)
89+
expect(fetchMock).not.toHaveBeenCalled()
90+
})
91+
92+
it('targets the per-customer base URL from metadata.apiBaseUrlForCustomer when present', async () => {
93+
const fetchMock = mockFetch({ calls: [] })
94+
await gongConnector.executeRead!({
95+
source: source({ apiBaseUrlForCustomer: 'https://company-17.api.gong.io' }),
96+
capabilityName: 'calls.list',
97+
args: { fromDateTime: '2026-01-01T00:00:00Z' },
98+
idempotencyKey: 'op_1',
99+
})
100+
const [url] = fetchMock.mock.calls[0] as [URL, RequestInit]
101+
expect(url.origin).toBe('https://company-17.api.gong.io')
102+
expect(url.pathname).toBe('/v2/calls')
103+
expect(url.searchParams.get('fromDateTime')).toBe('2026-01-01T00:00:00Z')
104+
})
105+
106+
it('retrieves transcripts via POST /v2/calls/transcript forwarding the filter object', async () => {
107+
const fetchMock = mockFetch({ callTranscripts: [] })
108+
await gongConnector.executeRead!({
109+
source: source(),
110+
capabilityName: 'calls.getTranscripts',
111+
args: { filter: { callIds: ['c1', 'c2'] } },
112+
idempotencyKey: 'op_2',
113+
})
114+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
115+
expect(url.pathname).toBe('/v2/calls/transcript')
116+
expect(init.method).toBe('POST')
117+
expect(JSON.parse(String(init.body))).toEqual({ filter: { callIds: ['c1', 'c2'] } })
118+
})
119+
120+
it('assigns prospects to an Engage flow via POST /v2/flows/prospects/assign', async () => {
121+
const fetchMock = mockFetch({ ok: true })
122+
const result = await gongConnector.executeMutation!({
123+
source: source(),
124+
capabilityName: 'flows.assignProspects',
125+
args: { flowId: 'flow_1', prospects: [{ crmProspectId: 'p1' }] },
126+
idempotencyKey: 'op_3',
127+
})
128+
expect(result.status).toBe('committed')
129+
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]
130+
expect(url.pathname).toBe('/v2/flows/prospects/assign')
131+
expect(JSON.parse(String(init.body))).toEqual({ flowId: 'flow_1', prospects: [{ crmProspectId: 'p1' }] })
132+
})
133+
134+
it('throws CredentialsExpired when Gong rejects the token', async () => {
135+
vi.stubGlobal('fetch', vi.fn(async () => new Response('unauthorized', { status: 401 })))
136+
await expect(
137+
gongConnector.executeRead!({ source: source(), capabilityName: 'users.list', args: {}, idempotencyKey: 'unauth_1' }),
138+
).rejects.toMatchObject({ name: 'CredentialsExpired' })
139+
})
140+
141+
it('rejects unknown capabilities', async () => {
142+
await expect(
143+
gongConnector.executeRead!({ source: source(), capabilityName: 'does.not.exist', args: {}, idempotencyKey: 'unknown_1' }),
144+
).rejects.toThrow(/unknown read capability/)
145+
})
146+
})

0 commit comments

Comments
 (0)