@hasna/connectors 1.3.45 → 1.4.0
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.
- package/README.md +25 -10
- package/bin/index.js +1362 -11796
- package/bin/mcp.js +751 -10328
- package/bin/serve.js +2252 -11829
- package/connectors/cloudflare-workers/.env.example +5 -0
- package/connectors/cloudflare-workers/AGENTS.md +11 -0
- package/connectors/cloudflare-workers/CLAUDE.md +11 -0
- package/connectors/cloudflare-workers/README.md +45 -0
- package/connectors/cloudflare-workers/package.json +47 -0
- package/connectors/cloudflare-workers/src/api/client.test.ts +144 -0
- package/connectors/cloudflare-workers/src/api/client.ts +386 -0
- package/connectors/cloudflare-workers/src/api/index.ts +1 -0
- package/connectors/cloudflare-workers/src/cli/index.ts +206 -0
- package/connectors/cloudflare-workers/src/index.ts +16 -0
- package/connectors/cloudflare-workers/src/types/index.ts +53 -0
- package/connectors/cloudflare-workers/src/utils/config.ts +61 -0
- package/connectors/cloudflare-workers/src/utils/output.ts +26 -0
- package/connectors/cloudflare-workers/tsconfig.build.json +4 -0
- package/connectors/cloudflare-workers/tsconfig.json +14 -0
- package/connectors/tidio/.env.example +3 -2
- package/connectors/tidio/CLAUDE.md +21 -96
- package/connectors/tidio/README.md +22 -18
- package/connectors/tidio/package.json +4 -2
- package/connectors/tidio/src/api/client.ts +26 -15
- package/connectors/tidio/src/api/index.ts +185 -128
- package/connectors/tidio/src/api/tidio.test.ts +175 -44
- package/connectors/tidio/src/cli/index.ts +154 -388
- package/connectors/tidio/src/index.ts +5 -3
- package/connectors/tidio/src/types/index.ts +135 -82
- package/connectors/tidio/src/utils/config.ts +90 -44
- package/connectors/wandb/.env.example +7 -0
- package/connectors/wandb/AGENTS.md +43 -0
- package/connectors/wandb/CLAUDE.md +67 -0
- package/connectors/wandb/README.md +65 -0
- package/connectors/wandb/package.json +52 -0
- package/connectors/wandb/src/api/client.test.ts +116 -0
- package/connectors/wandb/src/api/client.ts +83 -0
- package/connectors/wandb/src/api/graphql.ts +9 -0
- package/connectors/wandb/src/api/index.ts +36 -0
- package/connectors/wandb/src/api/projects.ts +36 -0
- package/connectors/wandb/src/api/viewer.ts +20 -0
- package/connectors/wandb/src/cli/index.ts +265 -0
- package/connectors/wandb/src/index.ts +22 -0
- package/connectors/wandb/src/types/index.ts +75 -0
- package/connectors/wandb/src/utils/config.ts +164 -0
- package/connectors/wandb/src/utils/output.ts +119 -0
- package/connectors/wandb/tsconfig.json +16 -0
- package/connectors/weights-biases/.env.example +7 -0
- package/connectors/weights-biases/CLAUDE.md +65 -0
- package/connectors/weights-biases/README.md +62 -0
- package/connectors/weights-biases/package.json +53 -0
- package/connectors/weights-biases/src/api/client.test.ts +46 -0
- package/connectors/weights-biases/src/api/client.ts +119 -0
- package/connectors/weights-biases/src/api/events.ts +18 -0
- package/connectors/weights-biases/src/api/index.ts +56 -0
- package/connectors/weights-biases/src/api/runs.ts +28 -0
- package/connectors/weights-biases/src/api/search.ts +10 -0
- package/connectors/weights-biases/src/cli/index.ts +289 -0
- package/connectors/weights-biases/src/index.ts +19 -0
- package/connectors/weights-biases/src/types/index.ts +92 -0
- package/connectors/weights-biases/src/utils/config.ts +168 -0
- package/connectors/weights-biases/src/utils/output.ts +116 -0
- package/connectors/weights-biases/tsconfig.json +16 -0
- package/connectors/x/src/api/oauth.test.ts +205 -0
- package/connectors/zoominfo/.env.example +4 -0
- package/connectors/zoominfo/AGENTS.md +26 -0
- package/connectors/zoominfo/CLAUDE.md +20 -0
- package/connectors/zoominfo/README.md +69 -0
- package/connectors/zoominfo/package.json +52 -0
- package/connectors/zoominfo/src/api/client.test.ts +140 -0
- package/connectors/zoominfo/src/api/client.ts +228 -0
- package/connectors/zoominfo/src/api/index.ts +1 -0
- package/connectors/zoominfo/src/cli/index.ts +179 -0
- package/connectors/zoominfo/src/index.ts +3 -0
- package/connectors/zoominfo/src/types/index.ts +39 -0
- package/connectors/zoominfo/src/utils/config.ts +61 -0
- package/connectors/zoominfo/src/utils/output.ts +27 -0
- package/connectors/zoominfo/tsconfig.json +20 -0
- package/connectors/zotero/.env.example +7 -0
- package/connectors/zotero/.npmrc.example +2 -0
- package/connectors/zotero/AGENTS.md +40 -0
- package/connectors/zotero/CLAUDE.md +69 -0
- package/connectors/zotero/README.md +91 -0
- package/connectors/zotero/package.json +43 -0
- package/connectors/zotero/src/api/attachments.ts +130 -0
- package/connectors/zotero/src/api/client.test.ts +166 -0
- package/connectors/zotero/src/api/client.ts +169 -0
- package/connectors/zotero/src/api/collections.ts +26 -0
- package/connectors/zotero/src/api/index.ts +45 -0
- package/connectors/zotero/src/api/items.ts +64 -0
- package/connectors/zotero/src/cli/index.ts +386 -0
- package/connectors/zotero/src/index.ts +28 -0
- package/connectors/zotero/src/types/index.ts +129 -0
- package/connectors/zotero/src/utils/config.ts +245 -0
- package/connectors/zotero/src/utils/output.ts +119 -0
- package/connectors/zotero/tsconfig.json +16 -0
- package/connectors/zymbly/.env.example +2 -0
- package/connectors/zymbly/AGENTS.md +11 -0
- package/connectors/zymbly/CLAUDE.md +5 -0
- package/connectors/zymbly/README.md +35 -0
- package/connectors/zymbly/package.json +52 -0
- package/connectors/zymbly/src/api/client.test.ts +110 -0
- package/connectors/zymbly/src/api/client.ts +166 -0
- package/connectors/zymbly/src/api/index.ts +1 -0
- package/connectors/zymbly/src/cli/index.ts +156 -0
- package/connectors/zymbly/src/index.ts +12 -0
- package/connectors/zymbly/src/types/index.ts +34 -0
- package/connectors/zymbly/src/utils/config.ts +52 -0
- package/connectors/zymbly/src/utils/output.ts +17 -0
- package/connectors/zymbly/tsconfig.json +13 -0
- package/dist/db/database.d.ts +2 -1
- package/dist/db/sqlite-adapter.d.ts +43 -0
- package/dist/index.js +172 -9653
- package/dist/lib/compact-output.d.ts +40 -0
- package/dist/mcp/tools/jobs.d.ts +32 -0
- package/dist/mcp/tools/jobs.test.d.ts +1 -0
- package/dist/no-cloud-boundary.test.d.ts +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
exchangeCodeForTokens,
|
|
4
|
+
refreshAccessToken,
|
|
5
|
+
revokeToken,
|
|
6
|
+
type OAuth2Config,
|
|
7
|
+
} from './oauth';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression tests for https://github.com/hasna/connectors/issues/1
|
|
11
|
+
*
|
|
12
|
+
* X rejects `Authorization: Basic <client_id:client_secret>` on
|
|
13
|
+
* POST /2/oauth2/token when the app is registered as a *public* client:
|
|
14
|
+
* {"error":"unauthorized_client",
|
|
15
|
+
* "error_description":"Missing valid authorization header"}
|
|
16
|
+
*
|
|
17
|
+
* The connector therefore must always authenticate the client with POST body
|
|
18
|
+
* parameters (`client_id`, plus `client_secret` when one is configured) and
|
|
19
|
+
* must never fall back to an Authorization header on the token/revoke
|
|
20
|
+
* endpoints.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface CapturedRequest {
|
|
24
|
+
url: string;
|
|
25
|
+
method?: string;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
body: URLSearchParams;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const realFetch = globalThis.fetch;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalise every shape `RequestInit.headers` can take (plain object, entry
|
|
34
|
+
* array, `Headers` instance) into a lower-cased record. Going through
|
|
35
|
+
* `new Headers(...)` matters: if the implementation ever switched to a
|
|
36
|
+
* `Headers` object, a naive `Object.entries()` would yield `[]` and the
|
|
37
|
+
* "no Authorization header" assertions below would pass vacuously.
|
|
38
|
+
*/
|
|
39
|
+
function normaliseHeaders(init?: RequestInit): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
if (!init?.headers) return out;
|
|
42
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
43
|
+
out[key.toLowerCase()] = value;
|
|
44
|
+
});
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function captureTokenRequest(): { calls: CapturedRequest[] } {
|
|
49
|
+
const calls: CapturedRequest[] = [];
|
|
50
|
+
|
|
51
|
+
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
|
52
|
+
const headers = normaliseHeaders(init);
|
|
53
|
+
|
|
54
|
+
calls.push({
|
|
55
|
+
url: String(input),
|
|
56
|
+
method: init?.method,
|
|
57
|
+
headers,
|
|
58
|
+
body: new URLSearchParams(String(init?.body ?? '')),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
access_token: 'ACCESS_TOKEN',
|
|
64
|
+
refresh_token: 'NEW_REFRESH_TOKEN',
|
|
65
|
+
expires_in: 7200,
|
|
66
|
+
scope: 'tweet.read tweet.write offline.access',
|
|
67
|
+
token_type: 'bearer',
|
|
68
|
+
}),
|
|
69
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
70
|
+
);
|
|
71
|
+
}) as typeof fetch;
|
|
72
|
+
|
|
73
|
+
return { calls };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const PUBLIC_CLIENT: OAuth2Config = {
|
|
77
|
+
clientId: 'PUBLIC_CLIENT_ID',
|
|
78
|
+
redirectUri: 'http://localhost:8888/callback',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const CONFIDENTIAL_CLIENT: OAuth2Config = {
|
|
82
|
+
clientId: 'CONFIDENTIAL_CLIENT_ID',
|
|
83
|
+
clientSecret: 'CONFIDENTIAL_CLIENT_SECRET',
|
|
84
|
+
redirectUri: 'http://localhost:8888/callback',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
globalThis.fetch = realFetch;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('OAuth 2.0 token requests - public client (issue #1)', () => {
|
|
92
|
+
test('exchangeCodeForTokens sends client_id in the body and no auth header', async () => {
|
|
93
|
+
const { calls } = captureTokenRequest();
|
|
94
|
+
|
|
95
|
+
const tokens = await exchangeCodeForTokens(
|
|
96
|
+
PUBLIC_CLIENT,
|
|
97
|
+
'AUTH_CODE',
|
|
98
|
+
'CODE_VERIFIER'
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(calls).toHaveLength(1);
|
|
102
|
+
const req = calls[0]!;
|
|
103
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
|
|
104
|
+
expect(req.method).toBe('POST');
|
|
105
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
106
|
+
expect(req.headers['content-type']).toBe(
|
|
107
|
+
'application/x-www-form-urlencoded'
|
|
108
|
+
);
|
|
109
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
110
|
+
expect(req.body.get('client_secret')).toBeNull();
|
|
111
|
+
expect(req.body.get('grant_type')).toBe('authorization_code');
|
|
112
|
+
expect(req.body.get('code')).toBe('AUTH_CODE');
|
|
113
|
+
expect(req.body.get('code_verifier')).toBe('CODE_VERIFIER');
|
|
114
|
+
expect(req.body.get('redirect_uri')).toBe(
|
|
115
|
+
'http://localhost:8888/callback'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(tokens.accessToken).toBe('ACCESS_TOKEN');
|
|
119
|
+
expect(tokens.refreshToken).toBe('NEW_REFRESH_TOKEN');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('refreshAccessToken sends client_id in the body and no auth header', async () => {
|
|
123
|
+
const { calls } = captureTokenRequest();
|
|
124
|
+
|
|
125
|
+
await refreshAccessToken(PUBLIC_CLIENT, 'OLD_REFRESH_TOKEN');
|
|
126
|
+
|
|
127
|
+
expect(calls).toHaveLength(1);
|
|
128
|
+
const req = calls[0]!;
|
|
129
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/token');
|
|
130
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
131
|
+
expect(req.body.get('grant_type')).toBe('refresh_token');
|
|
132
|
+
expect(req.body.get('refresh_token')).toBe('OLD_REFRESH_TOKEN');
|
|
133
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
134
|
+
expect(req.body.get('client_secret')).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('revokeToken sends client_id in the body and no auth header', async () => {
|
|
138
|
+
const { calls } = captureTokenRequest();
|
|
139
|
+
|
|
140
|
+
await revokeToken(PUBLIC_CLIENT, 'SOME_ACCESS_TOKEN', 'access_token');
|
|
141
|
+
|
|
142
|
+
expect(calls).toHaveLength(1);
|
|
143
|
+
const req = calls[0]!;
|
|
144
|
+
expect(req.url).toBe('https://api.twitter.com/2/oauth2/revoke');
|
|
145
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
146
|
+
expect(req.body.get('token')).toBe('SOME_ACCESS_TOKEN');
|
|
147
|
+
expect(req.body.get('token_type_hint')).toBe('access_token');
|
|
148
|
+
expect(req.body.get('client_id')).toBe('PUBLIC_CLIENT_ID');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('OAuth 2.0 token requests - configured client secret (issue #1)', () => {
|
|
153
|
+
// This is the exact trigger of issue #1: a client_secret is present in the
|
|
154
|
+
// connector config (env var or ~/.hasna/connectors/connect-x/credentials.json)
|
|
155
|
+
// while the X app itself is registered as a public client. Sending Basic auth
|
|
156
|
+
// in that situation is what produced "Missing valid authorization header".
|
|
157
|
+
test('never falls back to Basic auth when a client secret is configured', async () => {
|
|
158
|
+
const { calls } = captureTokenRequest();
|
|
159
|
+
|
|
160
|
+
await exchangeCodeForTokens(
|
|
161
|
+
CONFIDENTIAL_CLIENT,
|
|
162
|
+
'AUTH_CODE',
|
|
163
|
+
'CODE_VERIFIER'
|
|
164
|
+
);
|
|
165
|
+
await refreshAccessToken(CONFIDENTIAL_CLIENT, 'OLD_REFRESH_TOKEN');
|
|
166
|
+
await revokeToken(CONFIDENTIAL_CLIENT, 'SOME_ACCESS_TOKEN');
|
|
167
|
+
|
|
168
|
+
expect(calls).toHaveLength(3);
|
|
169
|
+
for (const req of calls) {
|
|
170
|
+
expect(req.headers['authorization']).toBeUndefined();
|
|
171
|
+
expect(req.body.get('client_id')).toBe('CONFIDENTIAL_CLIENT_ID');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('still authenticates the client via client_secret_post', async () => {
|
|
176
|
+
const { calls } = captureTokenRequest();
|
|
177
|
+
|
|
178
|
+
await exchangeCodeForTokens(
|
|
179
|
+
CONFIDENTIAL_CLIENT,
|
|
180
|
+
'AUTH_CODE',
|
|
181
|
+
'CODE_VERIFIER'
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(calls[0]!.body.get('client_secret')).toBe(
|
|
185
|
+
'CONFIDENTIAL_CLIENT_SECRET'
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('OAuth 2.0 token requests - error surfacing', () => {
|
|
191
|
+
test('exchangeCodeForTokens surfaces the X error payload', async () => {
|
|
192
|
+
globalThis.fetch = (async () =>
|
|
193
|
+
new Response(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
error: 'unauthorized_client',
|
|
196
|
+
error_description: 'Missing valid authorization header',
|
|
197
|
+
}),
|
|
198
|
+
{ status: 400 }
|
|
199
|
+
)) as typeof fetch;
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
exchangeCodeForTokens(PUBLIC_CLIENT, 'AUTH_CODE', 'CODE_VERIFIER')
|
|
203
|
+
).rejects.toThrow(/Token exchange failed:.*unauthorized_client/s);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guidance for AI agents working on this connector.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
This package implements `@hasna/connect-zoominfo`, a real ZoomInfo B2B sales intelligence REST API connector. Keep it on documented ZoomInfo HTTP endpoints and do not add browser automation dependencies.
|
|
8
|
+
|
|
9
|
+
## API Model
|
|
10
|
+
|
|
11
|
+
- Default base URL: `https://api.zoominfo.com`
|
|
12
|
+
- Authentication: `POST /authenticate` with username/password returns a JWT, or use a preconfigured JWT via `ZOOMINFO_JWT`
|
|
13
|
+
- Subsequent requests use `Authorization: Bearer <jwt>`
|
|
14
|
+
- Raw requests must use relative paths without `..` traversal
|
|
15
|
+
- Keep `.env.example` values as placeholders only
|
|
16
|
+
|
|
17
|
+
## Checks
|
|
18
|
+
|
|
19
|
+
Before finishing changes:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bun run typecheck
|
|
23
|
+
bun run build
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Also scan for committed secrets, internal references, and browser automation dependencies.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
See `AGENTS.md` for connector-specific instructions.
|
|
4
|
+
|
|
5
|
+
## Authentication
|
|
6
|
+
|
|
7
|
+
ZoomInfo uses username/password authentication via `POST /authenticate` to obtain a Bearer JWT token. Preconfigured JWT tokens are also supported via `ZOOMINFO_JWT` or `connect-zoominfo config set-jwt`.
|
|
8
|
+
|
|
9
|
+
Subsequent API calls use `Authorization: Bearer <jwt>`.
|
|
10
|
+
|
|
11
|
+
Environment variables:
|
|
12
|
+
|
|
13
|
+
| Variable | Description |
|
|
14
|
+
|----------|-------------|
|
|
15
|
+
| `ZOOMINFO_USERNAME` | ZoomInfo API username |
|
|
16
|
+
| `ZOOMINFO_PASSWORD` | ZoomInfo API password |
|
|
17
|
+
| `ZOOMINFO_JWT` | Preconfigured Bearer JWT (skips /authenticate) |
|
|
18
|
+
| `ZOOMINFO_BASE_URL` | Override API base URL |
|
|
19
|
+
|
|
20
|
+
JWT tokens expire after approximately 60 minutes. Long-running processes should re-authenticate or refresh the configured JWT.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# @hasna/connect-zoominfo
|
|
2
|
+
|
|
3
|
+
TypeScript and CLI connector for the ZoomInfo B2B sales intelligence API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @hasna/connect-zoominfo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configure
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
export ZOOMINFO_USERNAME=your_zoominfo_username
|
|
15
|
+
export ZOOMINFO_PASSWORD=your_zoominfo_password
|
|
16
|
+
export ZOOMINFO_JWT=your_zoominfo_jwt
|
|
17
|
+
export ZOOMINFO_BASE_URL=https://api.zoominfo.com
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The CLI can also store local configuration:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
connect-zoominfo config set-username your_zoominfo_username
|
|
24
|
+
connect-zoominfo config set-password your_zoominfo_password
|
|
25
|
+
connect-zoominfo config set-jwt your_zoominfo_jwt
|
|
26
|
+
connect-zoominfo config show
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## CLI Examples
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
connect-zoominfo authenticate
|
|
33
|
+
connect-zoominfo search-contacts --body '{"jobTitle":["VP of Sales"],"rpp":25,"page":1}'
|
|
34
|
+
connect-zoominfo search-companies --body '{"companyName":"Example Corp"}'
|
|
35
|
+
connect-zoominfo enrich-contact --body '{"matchPersonInput":[{"emailAddress":"person@example.com"}],"outputFields":["email","jobTitle"]}'
|
|
36
|
+
connect-zoominfo enrich-company --body '{"matchCompanyInput":[{"companyName":"Example Corp"}]}'
|
|
37
|
+
connect-zoominfo lookup-contact 123456789
|
|
38
|
+
connect-zoominfo contact-search-output-fields
|
|
39
|
+
connect-zoominfo raw-request POST /search/contact --body '{"jobTitle":["CTO"]}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## TypeScript
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { ZoomInfo } from "@hasna/connect-zoominfo";
|
|
46
|
+
|
|
47
|
+
const zoominfo = new ZoomInfo({
|
|
48
|
+
username: process.env.ZOOMINFO_USERNAME,
|
|
49
|
+
password: process.env.ZOOMINFO_PASSWORD,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await zoominfo.authenticate();
|
|
53
|
+
const contacts = await zoominfo.searchContacts({ jobTitle: ["VP of Sales"], rpp: 25, page: 1 });
|
|
54
|
+
const enriched = await zoominfo.enrichContact({
|
|
55
|
+
matchPersonInput: [{ emailAddress: "person@example.com" }],
|
|
56
|
+
outputFields: ["email", "jobTitle"],
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API Coverage
|
|
61
|
+
|
|
62
|
+
- Username/password authentication (`POST /authenticate`) with JWT caching per client instance
|
|
63
|
+
- Contact and company search
|
|
64
|
+
- Contact and company enrichment
|
|
65
|
+
- Contact lookup by ID
|
|
66
|
+
- Search output field discovery
|
|
67
|
+
- Relative raw requests
|
|
68
|
+
|
|
69
|
+
JWT tokens are valid for approximately 60 minutes. Long-running processes may need to re-authenticate or provide a fresh `ZOOMINFO_JWT`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasna/connect-zoominfo",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "ZoomInfo B2B sales intelligence API connector for contact and company search, enrichment, and lookup",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"connect-zoominfo": "./bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun && bun build ./src/cli/index.ts --outdir ./bin --target bun",
|
|
19
|
+
"dev": "bun run ./src/cli/index.ts",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"prepublishOnly": "bun run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"zoominfo",
|
|
25
|
+
"sales-intelligence",
|
|
26
|
+
"enrichment",
|
|
27
|
+
"contacts",
|
|
28
|
+
"companies",
|
|
29
|
+
"b2b",
|
|
30
|
+
"api",
|
|
31
|
+
"connector",
|
|
32
|
+
"cli",
|
|
33
|
+
"typescript",
|
|
34
|
+
"bun"
|
|
35
|
+
],
|
|
36
|
+
"author": "Hasna",
|
|
37
|
+
"license": "Apache-2.0",
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/bun": "latest",
|
|
40
|
+
"typescript": "^5"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"commander": "^12.1.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"bun": ">=1.0.0"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/hasna/connect-zoominfo.git"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { ZoomInfo, ZoomInfoApiError } from "../index";
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
let fetchMock: ReturnType<typeof mock>;
|
|
6
|
+
|
|
7
|
+
function mockJsonResponse(body: unknown, init: ResponseInit = {}) {
|
|
8
|
+
return new Response(JSON.stringify(body), {
|
|
9
|
+
status: init.status ?? 200,
|
|
10
|
+
headers: { "content-type": "application/json", ...init.headers },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function clientWithCredentials() {
|
|
15
|
+
return new ZoomInfo({
|
|
16
|
+
username: "api-user",
|
|
17
|
+
password: "api-pass",
|
|
18
|
+
baseUrl: "https://api.zoominfo.com",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clientWithJwt() {
|
|
23
|
+
return new ZoomInfo({
|
|
24
|
+
jwt: "configured-jwt-token",
|
|
25
|
+
baseUrl: "https://custom.zoominfo.test",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
fetchMock = mock(async () => mockJsonResponse({ success: true }));
|
|
31
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
globalThis.fetch = originalFetch;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("ZoomInfoClient", () => {
|
|
39
|
+
test("authenticates with username/password and caches JWT for subsequent calls", async () => {
|
|
40
|
+
fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
41
|
+
const url = String(input);
|
|
42
|
+
if (url.endsWith("/authenticate")) {
|
|
43
|
+
return mockJsonResponse({ jwt: "fresh-jwt-token" });
|
|
44
|
+
}
|
|
45
|
+
if (url.endsWith("/search/contact")) {
|
|
46
|
+
const headers = init?.headers as Record<string, string>;
|
|
47
|
+
expect(headers.Authorization).toBe("Bearer fresh-jwt-token");
|
|
48
|
+
return mockJsonResponse({ data: [{ id: "1" }] });
|
|
49
|
+
}
|
|
50
|
+
return mockJsonResponse({});
|
|
51
|
+
});
|
|
52
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
53
|
+
|
|
54
|
+
const api = clientWithCredentials();
|
|
55
|
+
const auth = await api.authenticate();
|
|
56
|
+
expect(auth.jwt).toBe("fresh-jwt-token");
|
|
57
|
+
|
|
58
|
+
await api.searchContacts({ jobTitle: ["VP of Sales"] });
|
|
59
|
+
|
|
60
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
61
|
+
const [authUrl, authInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
62
|
+
expect(authUrl).toBe("https://api.zoominfo.com/authenticate");
|
|
63
|
+
expect(authInit.method).toBe("POST");
|
|
64
|
+
expect(JSON.parse(String(authInit.body))).toEqual({ username: "api-user", password: "api-pass" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("configured JWT skips /authenticate on API calls", async () => {
|
|
68
|
+
const api = clientWithJwt();
|
|
69
|
+
|
|
70
|
+
await api.searchCompanies({ companyName: "Example Corp" });
|
|
71
|
+
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
73
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
74
|
+
expect(url).toBe("https://custom.zoominfo.test/search/company");
|
|
75
|
+
expect(init.method).toBe("POST");
|
|
76
|
+
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer configured-jwt-token");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("lookupContact uses bearer auth and encodes contact id", async () => {
|
|
80
|
+
const api = clientWithJwt();
|
|
81
|
+
|
|
82
|
+
await api.lookupContact("contact/id");
|
|
83
|
+
|
|
84
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
85
|
+
expect(url).toBe("https://custom.zoominfo.test/lookup/contact/contact%2Fid");
|
|
86
|
+
expect(init.method).toBe("GET");
|
|
87
|
+
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer configured-jwt-token");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("enrichContact requires matchPersonInput", async () => {
|
|
91
|
+
const api = clientWithJwt();
|
|
92
|
+
|
|
93
|
+
await expect(api.enrichContact({ outputFields: ["email"] })).rejects.toThrow("matchPersonInput");
|
|
94
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("enrichContact sends matchPersonInput payload", async () => {
|
|
98
|
+
const api = clientWithJwt();
|
|
99
|
+
const body = {
|
|
100
|
+
matchPersonInput: [{ emailAddress: "person@example.com" }],
|
|
101
|
+
outputFields: ["email", "jobTitle"],
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await api.enrichContact(body);
|
|
105
|
+
|
|
106
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
107
|
+
expect(init.method).toBe("POST");
|
|
108
|
+
expect(JSON.parse(String(init.body))).toEqual(body);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("lists contact and company search output fields", async () => {
|
|
112
|
+
const api = clientWithJwt();
|
|
113
|
+
|
|
114
|
+
await api.listContactSearchOutputFields();
|
|
115
|
+
await api.listCompanySearchOutputFields();
|
|
116
|
+
|
|
117
|
+
const urls = (fetchMock.mock.calls as [string, RequestInit][]).map(([url]) => url);
|
|
118
|
+
expect(urls).toEqual([
|
|
119
|
+
"https://custom.zoominfo.test/lookup/outputfields/contact/search",
|
|
120
|
+
"https://custom.zoominfo.test/lookup/outputfields/company/search",
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("rawRequest rejects absolute URLs and path traversal", async () => {
|
|
125
|
+
const api = clientWithJwt();
|
|
126
|
+
|
|
127
|
+
await expect(api.rawRequest("GET", "https://evil.example/search/contact")).rejects.toThrow("absolute URLs");
|
|
128
|
+
await expect(api.rawRequest("GET", "/search/../admin")).rejects.toThrow("path traversal");
|
|
129
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("throws ZoomInfoApiError on HTTP failures", async () => {
|
|
133
|
+
fetchMock = mock(async () => mockJsonResponse({ message: "Unauthorized" }, { status: 401 }));
|
|
134
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
135
|
+
|
|
136
|
+
const api = clientWithJwt();
|
|
137
|
+
|
|
138
|
+
await expect(api.searchContacts()).rejects.toBeInstanceOf(ZoomInfoApiError);
|
|
139
|
+
});
|
|
140
|
+
});
|