Skip to content

Commit 68e4166

Browse files
authored
feat(connectors): add SimilarWeb, Semrush, Cognism, Clay, ZoomInfo, Paychex, ADP (#103) (#107)
7 new library connectors, each w/ adapter + barrel export (auto-registers) + isolation tests: - market-intel: similarweb, semrush (api-key; JSON-only v4 surfaces) - sales-intel: cognism (api-key bearer), zoominfo (oauth2 GTM Data API) - crm: clay (outbound push_row + inbound clayWebhookProvider; clay has no general REST API) - hr: paychex (oauth2 client_credentials), adp (oauth2; manifest+tests only — mandatory mTLS not yet presentable by shared runtime) types: category union +sales-intelligence/market-intelligence/hr (mapCategory translates to hub taxonomy); OAuth2AuthSpec gains optional grantType + optional authorizationUrl for client_credentials; validator + startAuth guard updated.
1 parent 3c1450c commit 68e4166

21 files changed

Lines changed: 3027 additions & 3 deletions

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.40.1",
3+
"version": "0.41.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": {

src/adapter-provider.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ export function createConnectorAdapterProvider(options: ConnectorAdapterProvider
9696
'config_missing',
9797
)
9898
}
99+
if (!auth.authorizationUrl) {
100+
throw new IntegrationError(
101+
`Connector ${request.connectorId} uses the ${auth.grantType ?? 'authorization_code'} grant and has no authorization URL to redirect to.`,
102+
'auth_not_supported',
103+
)
104+
}
99105
const scopes =
100106
request.requestedScopes && request.requestedScopes.length > 0
101107
? request.requestedScopes
@@ -407,6 +413,11 @@ function mapCategory(category: ConnectorAdapter['manifest']['category']): Integr
407413
if (category === 'spreadsheet') return 'database'
408414
if (category === 'doc') return 'docs'
409415
if (category === 'commerce') return 'workflow'
416+
// The manifest taxonomy is richer than the hub-facade taxonomy. Sales/market
417+
// intelligence and HR/payroll connectors collapse to the nearest hub bucket:
418+
// contact/company intelligence reads like CRM; the rest have no closer fit.
419+
if (category === 'sales-intelligence') return 'crm'
420+
if (category === 'market-intelligence' || category === 'hr') return 'other'
410421
return category === 'other' ? 'other' : category
411422
}
412423

src/connectors/adapters/__tests__/contentful.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe('contentful startAuth URL construction', () => {
8585
it('builds an OAuth2 authorize URL via startOAuthFlow with the manifest endpoint and scopes', () => {
8686
const m = contentfulConnector.manifest
8787
if (m.auth.kind !== 'oauth2') throw new Error('contentful auth must be oauth2')
88+
if (!m.auth.authorizationUrl) throw new Error('contentful authorization_code auth must expose authorizationUrl')
8889

8990
const { authorizationUrl, state } = startOAuthFlow({
9091
projectId: 'project_1',

src/connectors/adapters/__tests__/whatsapp-business.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ describe('whatsappBusiness adapter', () => {
8787
const adapter = whatsappBusiness(opts)
8888
const auth = adapter.manifest.auth
8989
if (auth.kind !== 'oauth2') throw new Error('expected oauth2')
90+
if (!auth.authorizationUrl) throw new Error('expected authorizationUrl')
9091
// The hub constructs the authorize URL by appending standard OAuth2 params; assert the
9192
// adapter exposes everything the constructor needs to build a correct URL.
9293
const url = new URL(auth.authorizationUrl)

src/connectors/adapters/adp.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { declarativeRestConnector } from './declarative-rest.js'
2+
3+
/**
4+
* ADP (Workforce Now / ADP Marketplace) — HR/payroll reads over workers, worker
5+
* demographics, pay statements, and pay distributions.
6+
*
7+
* ┌─────────────────────────────────────────────────────────────────────────┐
8+
* │ RUNTIME GAP — MANDATORY mTLS. NOT EXECUTABLE THROUGH THE SHARED RUNTIME. │
9+
* │ │
10+
* │ ADP requires MUTUAL TLS (a client X.509 certificate, CSR-signed by ADP) │
11+
* │ presented at the TLS handshake on EVERY call — both the token endpoint │
12+
* │ (accounts.adp.com) and the data gateway (api.adp.com). The shared │
13+
* │ declarative-REST fetch path cannot attach a client certificate to its │
14+
* │ outbound TLS connection, so every request here will fail the handshake │
15+
* │ until per-connection client-cert plumbing is added to the runtime. │
16+
* │ │
17+
* │ This adapter therefore ships the MANIFEST + ISOLATION TESTS only. The │
18+
* │ capability set, paths, and auth shape are correct and validated against │
19+
* │ stubbed fetch; live execution is gated on a follow-up that teaches the │
20+
* │ runtime to present client certs (tracked separately — see the PR). │
21+
* └─────────────────────────────────────────────────────────────────────────┘
22+
*
23+
* Auth is OAuth2; ADP supports both client_credentials (server-to-server Data
24+
* Connector apps — the natural fit for bulk reads) and authorization_code
25+
* (delegated End-User apps). We declare the authorization_code shape here so
26+
* the manifest carries both the authorize and token URLs; the access token is
27+
* still injected by the platform and sent as `Authorization: Bearer`.
28+
*
29+
* Most HR/payroll endpoints also require a `roleCode` header
30+
* (practitioner|employee|manager|administrator|supervisor); we default it to
31+
* `practitioner`. SSN/birth-date are masked unless the caller adds
32+
* `Accept: application/json;masked=false` with a practitioner role + scopes —
33+
* we leave masking ON by default. The `associateOID` (AOID) is the primary key
34+
* resolved from worker.list and threaded into every per-worker endpoint.
35+
*
36+
* The pay-statement IMAGE endpoint is intentionally excluded — it returns a
37+
* binary PDF, which the JSON runtime cannot parse.
38+
*/
39+
export const adpConnector = declarativeRestConnector({
40+
kind: 'adp',
41+
displayName: 'ADP',
42+
description:
43+
'Read HR and payroll data from ADP Workforce Now: workers, worker demographics, pay statements, and pay distributions. Requires mutual-TLS client certificates (see adapter notes) in addition to OAuth2.',
44+
auth: {
45+
kind: 'oauth2',
46+
authorizationUrl: 'https://accounts.adp.com/auth/oauth/v2/authorize',
47+
tokenUrl: 'https://accounts.adp.com/auth/oauth/v2/token',
48+
scopes: [
49+
'hr/workerInformationManagement/read',
50+
'payroll/payStatementManagement/read',
51+
],
52+
clientIdEnv: 'ADP_OAUTH_CLIENT_ID',
53+
clientSecretEnv: 'ADP_OAUTH_CLIENT_SECRET',
54+
},
55+
category: 'hr',
56+
defaultConsistencyModel: 'authoritative',
57+
baseUrl: 'https://api.adp.com',
58+
// ADP requires a roleCode on most HR/payroll endpoints; practitioner is the
59+
// broadest read role. (PII stays masked unless the caller opts into
60+
// `Accept: application/json;masked=false`.)
61+
defaultHeaders: { roleCode: 'practitioner' },
62+
test: { method: 'GET', path: '/hr/v2/workers/meta' },
63+
capabilities: [
64+
{
65+
name: 'worker.list',
66+
class: 'read',
67+
description:
68+
'Collection of all workers for the authenticated client. OData paging via $top/$skip (default 50, max 100); total at meta.totalNumber. The associateOID on each worker is the key for per-worker endpoints. SSN/birth date are masked.',
69+
parameters: {
70+
type: 'object',
71+
properties: {
72+
top: { type: 'integer', description: 'OData $top page size (default 50, max 100).' },
73+
skip: { type: 'integer', description: 'OData $skip offset for pagination.' },
74+
filter: { type: 'string', description: 'OData $filter expression.' },
75+
select: { type: 'string', description: 'OData $select field projection.' },
76+
},
77+
},
78+
request: {
79+
method: 'GET',
80+
path: '/hr/v2/workers',
81+
query: { $top: '{top}', $skip: '{skip}', $filter: '{filter}', $select: '{select}' },
82+
},
83+
},
84+
{
85+
name: 'worker.get',
86+
class: 'read',
87+
description: 'A single worker by Associate OID (aoid). SSN/birth date are masked unless unmasking is requested.',
88+
parameters: {
89+
type: 'object',
90+
properties: {
91+
aoid: { type: 'string', description: 'Associate OID from worker.list.' },
92+
select: { type: 'string', description: 'OData $select field projection.' },
93+
},
94+
required: ['aoid'],
95+
},
96+
request: {
97+
method: 'GET',
98+
path: '/hr/v2/workers/{aoid}',
99+
query: { $select: '{select}' },
100+
},
101+
},
102+
{
103+
name: 'worker.demographics.list',
104+
class: 'read',
105+
description:
106+
'Worker collection WITHOUT PII/SPI (no SSN, no full birth date) — a lighter payload than worker.list. OData paging supported.',
107+
parameters: {
108+
type: 'object',
109+
properties: {
110+
top: { type: 'integer' },
111+
skip: { type: 'integer' },
112+
filter: { type: 'string' },
113+
select: { type: 'string' },
114+
},
115+
},
116+
request: {
117+
method: 'GET',
118+
path: '/hr/v2/worker-demographics',
119+
query: { $top: '{top}', $skip: '{skip}', $filter: '{filter}', $select: '{select}' },
120+
},
121+
},
122+
{
123+
name: 'worker.demographics.get',
124+
class: 'read',
125+
description: 'Demographic data for one worker by Associate OID, without PII/SPI.',
126+
parameters: {
127+
type: 'object',
128+
properties: {
129+
aoid: { type: 'string' },
130+
select: { type: 'string' },
131+
},
132+
required: ['aoid'],
133+
},
134+
request: {
135+
method: 'GET',
136+
path: '/hr/v2/worker-demographics/{aoid}',
137+
query: { $select: '{select}' },
138+
},
139+
},
140+
{
141+
name: 'paystatements.list',
142+
class: 'read',
143+
description:
144+
'Pay statements for one worker: payDate, net/gross pay amounts, total hours, and a statement image URI. Use numberoflastpaydates to cap results.',
145+
parameters: {
146+
type: 'object',
147+
properties: {
148+
aoid: { type: 'string', description: 'Associate OID from worker.list.' },
149+
numberoflastpaydates: { type: 'integer', description: 'Limit to the N most recent pay dates.' },
150+
},
151+
required: ['aoid'],
152+
},
153+
request: {
154+
method: 'GET',
155+
path: '/payroll/v1/workers/{aoid}/pay-statements',
156+
query: { numberoflastpaydates: '{numberoflastpaydates}' },
157+
},
158+
},
159+
{
160+
name: 'paystatements.get',
161+
class: 'read',
162+
description:
163+
'Full detail of one pay statement: pay period, net/gross/YTD amounts, earnings, deductions, and memos. The pay-statement id is resolved from paystatements.list.',
164+
parameters: {
165+
type: 'object',
166+
properties: {
167+
aoid: { type: 'string' },
168+
payStatementId: { type: 'string', description: 'Pay statement id from paystatements.list.' },
169+
},
170+
required: ['aoid', 'payStatementId'],
171+
},
172+
request: { method: 'GET', path: '/payroll/v1/workers/{aoid}/pay-statements/{payStatementId}' },
173+
},
174+
{
175+
name: 'paydistributions.get',
176+
class: 'read',
177+
description:
178+
'Direct-deposit distribution setup for one worker: accounts, routing numbers, distribution amounts, and remaining-balance flags.',
179+
parameters: {
180+
type: 'object',
181+
properties: { aoid: { type: 'string' } },
182+
required: ['aoid'],
183+
},
184+
request: { method: 'GET', path: '/payroll/v2/workers/{aoid}/pay-distributions' },
185+
},
186+
],
187+
})

src/connectors/adapters/clay.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Clay (clay.com) — GTM enrichment platform.
3+
*
4+
* Clay has NO general request/response REST API. Per Clay's own docs: "Clay
5+
* isn't built like a typical SaaS tool where you send a request to an endpoint
6+
* and get data back in milliseconds." The only programmatic surfaces are:
7+
* 1. INBOUND webhook source — POST a flat JSON row INTO a Clay table via a
8+
* per-table webhook URL. This is the outbound direction for THIS connector
9+
* (the agent pushes a row into Clay) and is implemented here as the
10+
* `push_row` mutation.
11+
* 2. OUTBOUND "HTTP API" action — Clay calls an external URL the operator
12+
* configures, to deliver enriched rows back out. That is the inbound
13+
* direction for us and is handled by `clayWebhookProvider` in
14+
* `src/webhooks/providers.ts` (Clay signs nothing, so the provider
15+
* verifies a pre-shared secret header the operator configures).
16+
*
17+
* Because there is no read/enrich endpoint on standard plans, this connector
18+
* is intentionally write-only (ingest). The per-table webhook URL lives in
19+
* `DataSource.metadata.webhookUrl` (each Clay table generates its own
20+
* UUID-suffixed URL). The optional `x-clay-webhook-auth` token (shown once at
21+
* table-webhook creation) is the only Clay-native auth; if the table was
22+
* created without a token, requests are unauthenticated and the operator can
23+
* leave the credential empty. Ingest is async — the response does NOT echo
24+
* enriched data — so the consistency model is `advisory`.
25+
*/
26+
27+
import {
28+
type CapabilityMutationResult,
29+
type ConnectorAdapter,
30+
type ConnectorCredentials,
31+
type ConnectorInvocation,
32+
CredentialsExpired,
33+
} from '../types.js'
34+
35+
export const clayConnector: ConnectorAdapter = {
36+
manifest: {
37+
kind: 'clay',
38+
displayName: 'Clay',
39+
description:
40+
'Push rows into a Clay table to trigger GTM enrichment workflows. Clay has no general REST API — this sends a JSON row to your table’s inbound webhook URL; enriched results flow back out via Clay’s HTTP API action (received through the clay webhook provider).',
41+
auth: {
42+
kind: 'api-key',
43+
hint: 'Optional Clay table webhook token (x-clay-webhook-auth), shown once when you add a webhook source to a table. Leave blank if the table’s webhook has no token. The per-table webhook URL goes in the connection metadata as `webhookUrl`.',
44+
},
45+
category: 'crm',
46+
defaultConsistencyModel: 'advisory',
47+
capabilities: [
48+
{
49+
name: 'push_row',
50+
class: 'mutation',
51+
description:
52+
'Send a flat JSON object to the configured Clay table webhook URL, creating a new row. Keys map to table columns (no fixed schema). Processing is asynchronous — the response acknowledges receipt but does not return enriched data.',
53+
cas: 'native-idempotency',
54+
externalEffect: true,
55+
parameters: {
56+
type: 'object',
57+
additionalProperties: true,
58+
description:
59+
'Flat JSON whose keys become Clay table columns, e.g. { "firstName": "Jane", "email": "jane@acme.com", "company": "Acme" }.',
60+
},
61+
},
62+
],
63+
},
64+
65+
async executeMutation(inv: ConnectorInvocation): Promise<CapabilityMutationResult> {
66+
const url = readWebhookUrl(inv.source.metadata)
67+
const body = JSON.stringify(inv.args ?? {})
68+
const headers: Record<string, string> = { 'content-type': 'application/json' }
69+
const token = clayToken(inv.source.credentials)
70+
if (token) headers['x-clay-webhook-auth'] = token
71+
const res = await fetch(url, {
72+
method: 'POST',
73+
headers,
74+
body,
75+
signal: AbortSignal.timeout(15_000),
76+
})
77+
if (res.status === 401 || res.status === 403) {
78+
throw new CredentialsExpired(`Clay rejected the webhook token (${res.status})`, inv.source.id)
79+
}
80+
if (!res.ok) {
81+
throw new Error(`clay push_row ${res.status}: ${(await res.text().catch(() => res.statusText)).slice(0, 200)}`)
82+
}
83+
// Clay returns a small JSON ack (often {} ); ingest is async so there is no
84+
// enriched payload to surface here.
85+
const data = (await res.json().catch(() => ({}))) as unknown
86+
return {
87+
status: 'committed',
88+
data,
89+
committedAt: Date.now(),
90+
idempotentReplay: false,
91+
}
92+
},
93+
94+
async test(source) {
95+
try {
96+
// The table webhook URL is write-only (POST ingest) — we can't probe it
97+
// without polluting the table, so a healthy connection is one that has a
98+
// syntactically valid webhook URL configured.
99+
readWebhookUrl(source.metadata)
100+
return { ok: true }
101+
} catch (err) {
102+
return { ok: false, reason: err instanceof Error ? err.message : String(err) }
103+
}
104+
},
105+
}
106+
107+
function readWebhookUrl(metadata: Record<string, unknown>): string {
108+
const value = metadata.webhookUrl
109+
if (typeof value !== 'string' || value.length === 0) {
110+
throw new Error('clay DataSource.metadata.webhookUrl is missing')
111+
}
112+
// Surface an invalid URL eagerly rather than failing inside fetch().
113+
return new URL(value).toString()
114+
}
115+
116+
function clayToken(credentials: ConnectorCredentials): string | undefined {
117+
if (credentials.kind === 'api-key' && credentials.apiKey) return credentials.apiKey
118+
return undefined
119+
}

0 commit comments

Comments
 (0)