Skip to content

Commit 0cdc3db

Browse files
authored
fix(gong): capture api_base_url_for_customer into connection metadata via tokenMetadata + release 0.44.0 (#112)
Adds declarative auth.tokenMetadata manifest contract; completeAuth persists declared token-response fields into connection metadata; Gong declares apiBaseUrlForCustomer <- api_base_url_for_customer (required, fail-loud). Release 0.44.0.
1 parent ce5c01e commit 0cdc3db

6 files changed

Lines changed: 239 additions & 13 deletions

File tree

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.43.0",
3+
"version": "0.44.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/__tests__/adapter-provider-oauth.test.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { describe, expect, it, vi } from 'vitest'
22
import { createConnectorAdapterProvider } from '../adapter-provider.js'
33
import { IntegrationError } from '../index.js'
4-
import type { ConnectorAdapter } from '../connectors/types.js'
4+
import type { ConnectorAdapter, TokenMetadataSource } from '../connectors/types.js'
55

66
const OWNER = { type: 'user' as const, id: 'user_42' }
77
const REDIRECT = 'https://app.example/oauth/callback'
88

9-
function oauthAdapter(): ConnectorAdapter {
9+
function oauthAdapter(tokenMetadata?: Record<string, TokenMetadataSource>): ConnectorAdapter {
1010
return {
1111
manifest: {
1212
kind: 'demo-oauth',
@@ -20,6 +20,7 @@ function oauthAdapter(): ConnectorAdapter {
2020
clientIdEnv: 'DEMO_CLIENT_ID',
2121
clientSecretEnv: 'DEMO_CLIENT_SECRET',
2222
extraAuthParams: { access_type: 'offline' },
23+
...(tokenMetadata ? { tokenMetadata } : {}),
2324
},
2425
capabilities: [],
2526
defaultConsistencyModel: 'authoritative',
@@ -223,4 +224,150 @@ describe('createConnectorAdapterProvider OAuth flow', () => {
223224
}),
224225
).rejects.toMatchObject({ code: 'provider_failure' })
225226
})
227+
228+
it('completeAuth captures declared tokenMetadata fields (string + object form), merging with — and overriding same-key — request.metadata', async () => {
229+
const fetchImpl = vi.fn(async () =>
230+
tokenResponse({
231+
access_token: 'acc_xyz',
232+
token_type: 'Bearer',
233+
// Provider-specific fields the standard parser would otherwise drop.
234+
api_base_url_for_customer: 'https://company-17.api.gong.io',
235+
instance_url: 'https://eu.example.com',
236+
}),
237+
) as unknown as typeof fetch
238+
239+
const provider = createConnectorAdapterProvider({
240+
adapters: [
241+
oauthAdapter({
242+
// object form (required) + string shorthand
243+
apiBaseUrlForCustomer: { field: 'api_base_url_for_customer', required: true },
244+
instanceUrl: 'instance_url',
245+
}),
246+
],
247+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
248+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
249+
fetchImpl,
250+
})
251+
252+
const conn = await provider.completeAuth!({
253+
connectorId: 'demo-oauth',
254+
owner: OWNER,
255+
code: 'the_code',
256+
state: 'state_xyz',
257+
redirectUri: REDIRECT,
258+
// `tenant` is a non-colliding key → preserved (merge, not replace).
259+
// `apiBaseUrlForCustomer` collides → the token-exchange value is
260+
// authoritative and MUST win over the stale request.metadata value.
261+
metadata: { tenant: 'acme', apiBaseUrlForCustomer: 'https://stale.example' },
262+
})
263+
264+
expect(conn.metadata).toEqual({
265+
tenant: 'acme',
266+
apiBaseUrlForCustomer: 'https://company-17.api.gong.io',
267+
instanceUrl: 'https://eu.example.com',
268+
})
269+
})
270+
271+
it('completeAuth omits a non-required tokenMetadata field that is absent (capture-if-present)', async () => {
272+
const fetchImpl = vi.fn(async () =>
273+
tokenResponse({ access_token: 'acc_xyz', token_type: 'Bearer' }),
274+
) as unknown as typeof fetch
275+
276+
const provider = createConnectorAdapterProvider({
277+
adapters: [oauthAdapter({ instanceUrl: 'instance_url' })],
278+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
279+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
280+
fetchImpl,
281+
})
282+
283+
const conn = await provider.completeAuth!({
284+
connectorId: 'demo-oauth',
285+
owner: OWNER,
286+
code: 'the_code',
287+
state: 'state_xyz',
288+
redirectUri: REDIRECT,
289+
})
290+
291+
expect(conn.metadata).toEqual({})
292+
expect('instanceUrl' in (conn.metadata ?? {})).toBe(false)
293+
})
294+
295+
it('completeAuth fails loud (provider_failure) when a required tokenMetadata field is absent', async () => {
296+
const fetchImpl = vi.fn(async () =>
297+
tokenResponse({ access_token: 'acc_xyz', token_type: 'Bearer' }),
298+
) as unknown as typeof fetch
299+
300+
const provider = createConnectorAdapterProvider({
301+
adapters: [oauthAdapter({ apiBaseUrlForCustomer: { field: 'api_base_url_for_customer', required: true } })],
302+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
303+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
304+
fetchImpl,
305+
})
306+
307+
let caught: unknown
308+
try {
309+
await provider.completeAuth!({
310+
connectorId: 'demo-oauth',
311+
owner: OWNER,
312+
code: 'the_code',
313+
state: 'state_xyz',
314+
redirectUri: REDIRECT,
315+
})
316+
} catch (e) {
317+
caught = e
318+
}
319+
expect(caught).toBeInstanceOf(IntegrationError)
320+
expect((caught as IntegrationError).code).toBe('provider_failure')
321+
expect((caught as Error).message).toMatch(/api_base_url_for_customer/)
322+
})
323+
324+
it('completeAuth treats a present-but-empty required tokenMetadata field as absent and fails loud', async () => {
325+
// Locks the `=== ''` (post-trim) emptiness guard against a regression to a
326+
// null-only check that would mint an active connection whose every call 404s.
327+
for (const emptyish of ['', ' ', '\n\t']) {
328+
const fetchImpl = vi.fn(async () =>
329+
tokenResponse({ access_token: 'acc_xyz', token_type: 'Bearer', api_base_url_for_customer: emptyish }),
330+
) as unknown as typeof fetch
331+
332+
const provider = createConnectorAdapterProvider({
333+
adapters: [oauthAdapter({ apiBaseUrlForCustomer: { field: 'api_base_url_for_customer', required: true } })],
334+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
335+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
336+
fetchImpl,
337+
})
338+
339+
await expect(
340+
provider.completeAuth!({
341+
connectorId: 'demo-oauth',
342+
owner: OWNER,
343+
code: 'the_code',
344+
state: 'state_xyz',
345+
redirectUri: REDIRECT,
346+
}),
347+
).rejects.toMatchObject({ code: 'provider_failure' })
348+
}
349+
})
350+
351+
it('completeAuth omits a non-required tokenMetadata field that is present but empty/whitespace', async () => {
352+
const fetchImpl = vi.fn(async () =>
353+
tokenResponse({ access_token: 'acc_xyz', token_type: 'Bearer', instance_url: ' ' }),
354+
) as unknown as typeof fetch
355+
356+
const provider = createConnectorAdapterProvider({
357+
adapters: [oauthAdapter({ instanceUrl: 'instance_url' })],
358+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
359+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
360+
fetchImpl,
361+
})
362+
363+
const conn = await provider.completeAuth!({
364+
connectorId: 'demo-oauth',
365+
owner: OWNER,
366+
code: 'the_code',
367+
state: 'state_xyz',
368+
redirectUri: REDIRECT,
369+
})
370+
371+
expect('instanceUrl' in (conn.metadata ?? {})).toBe(false)
372+
})
226373
})

src/adapter-provider.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ConnectorCredentials,
66
ConnectorInvocation,
77
ResolvedDataSource,
8+
TokenMetadataSource,
89
} from './connectors/types.js'
910
import type {
1011
CompleteAuthRequest,
@@ -204,7 +205,7 @@ export function createConnectorAdapterProvider(options: ConnectorAdapterProvider
204205
expires_in?: number
205206
scope?: string
206207
token_type?: string
207-
}
208+
} & Record<string, unknown>
208209
try {
209210
json = await res.json()
210211
} catch {
@@ -227,6 +228,11 @@ export function createConnectorAdapterProvider(options: ConnectorAdapterProvider
227228
const expiresAt = typeof json.expires_in === 'number' && json.expires_in > 0
228229
? new Date(issued.getTime() + json.expires_in * 1000).toISOString()
229230
: undefined
231+
// Persist provider-specific token-response fields the connector declared
232+
// via `auth.tokenMetadata` into the connection metadata (e.g. Gong's
233+
// per-customer `api_base_url_for_customer`). The captured values win over
234+
// any same-key request.metadata — the token exchange is authoritative.
235+
const capturedMetadata = captureTokenMetadata(auth.tokenMetadata, json, request.connectorId)
230236
return {
231237
id: randomConnectionId(),
232238
owner: request.owner,
@@ -237,7 +243,7 @@ export function createConnectorAdapterProvider(options: ConnectorAdapterProvider
237243
createdAt: issuedIso,
238244
updatedAt: issuedIso,
239245
expiresAt,
240-
metadata: request.metadata,
246+
metadata: { ...request.metadata, ...capturedMetadata },
241247
}
242248
},
243249
async invokeAction(connection, request) {
@@ -408,6 +414,42 @@ function oauthManifestAuth(auth: ConnectorAdapter['manifest']['auth']): Extract<
408414
return auth.options.find((option): option is Extract<typeof option, { kind: 'oauth2' }> => option.kind === 'oauth2')
409415
}
410416

417+
/** Project the declared `auth.tokenMetadata` mappings out of an OAuth
418+
* token-response body into connection metadata. Each entry maps a
419+
* connection-metadata key → the token-response field to read (string
420+
* shorthand) or `{ field, required }`. Required fields that are absent/empty
421+
* throw `provider_failure` so the break surfaces at connect; non-required
422+
* fields are captured if present and otherwise skipped. */
423+
function captureTokenMetadata(
424+
tokenMetadata: Record<string, TokenMetadataSource> | undefined,
425+
json: Record<string, unknown>,
426+
connectorId: string,
427+
): Record<string, string> {
428+
if (!tokenMetadata) return {}
429+
const captured: Record<string, string> = {}
430+
for (const [metaKey, source] of Object.entries(tokenMetadata)) {
431+
const field = typeof source === 'string' ? source : source.field
432+
const required = typeof source === 'string' ? false : source.required === true
433+
const raw = json[field]
434+
// Trim string values so capture-side emptiness matches the consumer's
435+
// emptiness check (e.g. resolveBaseUrl's `.trim()` guard). A whitespace-only
436+
// value must fail loud here at connect for a required field, not defer the
437+
// break to first call where the host already looks active.
438+
const value = typeof raw === 'string' ? raw.trim() : raw
439+
if (value === undefined || value === null || value === '') {
440+
if (required) {
441+
throw new IntegrationError(
442+
`OAuth token exchange for ${connectorId} did not return required field "${field}" for metadata.${metaKey}.`,
443+
'provider_failure',
444+
)
445+
}
446+
continue
447+
}
448+
captured[metaKey] = String(value)
449+
}
450+
return captured
451+
}
452+
411453
function mapCategory(category: ConnectorAdapter['manifest']['category']): IntegrationConnector['category'] {
412454
if (category === 'comms') return 'chat'
413455
if (category === 'spreadsheet') return 'database'

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ describe('gong adapter', () => {
5252
expect(gongConnector.manifest.category).toBe('comms')
5353
})
5454

55+
it('declares a required tokenMetadata capture for the per-customer base URL', () => {
56+
// The contract that completeAuth honors: persist the token-exchange
57+
// `api_base_url_for_customer` into metadata.apiBaseUrlForCustomer (required).
58+
// The companion test below proves call-time `baseUrl` resolution reads that
59+
// same metadata key, closing the loop from token exchange to first call.
60+
const auth = gongConnector.manifest.auth
61+
if (auth.kind !== 'oauth2') throw new Error('auth narrowing failed')
62+
expect(auth.tokenMetadata).toEqual({
63+
apiBaseUrlForCustomer: { field: 'api_base_url_for_customer', required: true },
64+
})
65+
})
66+
5567
it('exposes the expected capability surface and read/mutation split', () => {
5668
const names = gongConnector.manifest.capabilities.map((c) => c.name).sort()
5769
expect(names).toEqual([

src/connectors/adapters/gong.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import { declarativeRestConnector } from './declarative-rest.js'
88
* app.gong.io/oauth2/generate-customer-token. The token response carries an
99
* `api_base_url_for_customer` (e.g. https://company-17.api.gong.io) that ALL
1010
* subsequent API calls must target — the generic api.gong.io host is only
11-
* valid for the legacy access-key (Basic) auth, NOT for OAuth apps. The hub
12-
* connect flow MUST persist that per-customer host (returned at token
13-
* exchange) into `metadata.apiBaseUrlForCustomer`; we resolve `baseUrl`
14-
* strictly from it with NO fallback. If the metadata is absent every call
15-
* fails loud (`missing metadata.apiBaseUrlForCustomer base URL`) rather than
16-
* silently routing to the OAuth-invalid generic host and looking active while
17-
* every request fails. Because the resolved base is a bare host (no version),
18-
* each capability path carries its own `/v2` prefix.
11+
* valid for the legacy access-key (Basic) auth, NOT for OAuth apps. We capture
12+
* that per-customer host into `metadata.apiBaseUrlForCustomer` declaratively
13+
* via `auth.tokenMetadata` (required), so the token-exchange path persists it;
14+
* we then resolve `baseUrl` strictly from that metadata key with NO fallback.
15+
* If the metadata is absent every call fails loud (`missing
16+
* metadata.apiBaseUrlForCustomer base URL`) rather than silently routing to
17+
* the OAuth-invalid generic host and looking active while every request fails.
18+
* Because the resolved base is a bare host (no version), each capability path
19+
* carries its own `/v2` prefix.
1920
*
2021
* Scopes are selected when registering the OAuth app in Gong's admin center
2122
* and echoed back on the token; we request the read+create scopes that back
@@ -40,6 +41,12 @@ export const gongConnector = declarativeRestConnector({
4041
],
4142
clientIdEnv: 'GONG_OAUTH_CLIENT_ID',
4243
clientSecretEnv: 'GONG_OAUTH_CLIENT_SECRET',
44+
// Gong returns the per-customer API host on the token response; capture it
45+
// into metadata.apiBaseUrlForCustomer so `baseUrl` resolution below finds
46+
// it. Required: the generic api.gong.io host is invalid for OAuth apps, so
47+
// a missing value fails the exchange loud rather than minting a connection
48+
// that looks active while every call 404s.
49+
tokenMetadata: { apiBaseUrlForCustomer: { field: 'api_base_url_for_customer', required: true } },
4350
},
4451
category: 'comms',
4552
defaultConsistencyModel: 'authoritative',

src/connectors/types.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,12 @@ export interface RateLimitSpec {
343343
scope?: 'oauth-client' | 'data-source'
344344
}
345345

346+
/** Source mapping for `OAuth2AuthSpec.tokenMetadata`. A bare string names the
347+
* token-response field to capture (capture-if-present). The object form marks
348+
* the field `required` so a missing/empty value fails the token exchange loud
349+
* rather than silently capturing nothing. */
350+
export type TokenMetadataSource = string | { field: string; required?: boolean }
351+
346352
type OAuth2AuthSpec = {
347353
kind: 'oauth2'
348354
/** OAuth2 grant type. Defaults to `'authorization_code'` (the interactive
@@ -374,6 +380,18 @@ type OAuth2AuthSpec = {
374380
* Defaults to true. Set false for providers that reject a per-request
375381
* scope and pin scopes app-side (e.g. HelloSign/Dropbox Sign). */
376382
sendScopeParam?: boolean
383+
/** Declarative capture of provider-specific fields from the OAuth token
384+
* response into the connection's `metadata`, keyed by the connection-metadata
385+
* key to write → the token-response field to read. The counterpart to
386+
* `baseUrl.metadataKey`: providers like Gong return a per-customer host
387+
* (`api_base_url_for_customer`) at token exchange that every later call must
388+
* target, but the standard token parser keeps only the canonical OAuth
389+
* fields and drops the rest. Declaring the mapping here makes `completeAuth`
390+
* persist the value into `metadata` so `baseUrl` resolution finds it. Mark a
391+
* field `required` to fail the exchange loud when it is absent (surfacing the
392+
* break at connect, not first call). Optional and backward-compatible —
393+
* connectors that omit it are unaffected. */
394+
tokenMetadata?: Record<string, TokenMetadataSource>
377395
}
378396
type ApiKeyAuthSpec = {
379397
kind: 'api-key'

0 commit comments

Comments
 (0)