Skip to content

Commit

Permalink
Keeping nango integrations in sync with oauth-based venice integrations
Browse files Browse the repository at this point in the history
  • Loading branch information
tonyxiao committed Oct 11, 2023
1 parent 1d3e7ed commit 1e1735d
Show file tree
Hide file tree
Showing 10 changed files with 263 additions and 5 deletions.
1 change: 1 addition & 0 deletions apps/app-config/backendConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const contextFactory = getContextFactory({
// routerUrl: 'http://localhost:3010/api', // apiUrl?
apiUrl: joinPath(getServerUrl(null), '/api/trpc'),
jwtSecret: env.JWT_SECRET_OR_PUBLIC_KEY,
nangoSecretKey: env.NANGO_SECRET_KEY,
getRedirectUrl: (_, _ctx) => joinPath(getServerUrl(null), '/'),
getMetaService: (viewer) =>
makePostgresMetaService({databaseUrl: env.POSTGRES_OR_WEBHOOK_URL, viewer}),
Expand Down
2 changes: 2 additions & 0 deletions apps/app-config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Pass a valid http(s):// url for stateless mode. Sync data and metadata be sent t

INNGEST_EVENT_KEY: z.string(),
INNGEST_SIGNING_KEY: z.string(),
NANGO_SECRET_KEY: z.string(),
},
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string(),
Expand All @@ -36,6 +37,7 @@ Pass a valid http(s):// url for stateless mode. Sync data and metadata be sent t
CLERK_SECRET_KEY: process.env['CLERK_SECRET_KEY'],
INNGEST_EVENT_KEY: process.env['INNGEST_EVENT_KEY'],
INNGEST_SIGNING_KEY: process.env['INNGEST_SIGNING_KEY'],
NANGO_SECRET_KEY: process.env['NANGO_SECRET_KEY'],
JWT_SECRET_OR_PUBLIC_KEY: process.env['JWT_SECRET_OR_PUBLIC_KEY'],
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env['NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY'],
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/_cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import '@usevenice/app-config/register.node'

import {parseIntConfigsFromRawEnv} from '@usevenice/app-config/integration-envs'
import type {defIntegrations} from '@usevenice/app-config/integrations/integrations.def'
import {makeJwtClient} from '@usevenice/cdk-core'
import {makeJwtClient, makeNangoClient} from '@usevenice/cdk-core'
import {makeAlphavantageClient} from '@usevenice/integration-alphavantage'
import {makeHeronClient} from '@usevenice/integration-heron'
import {makeLunchmoneyClient} from '@usevenice/integration-lunchmoney'
Expand Down Expand Up @@ -103,6 +103,8 @@ if (require.main === module) {
accountToken: process.env['MERGE_TEST_LINKED_ACCOUNT_TOKEN'] ?? '',
}).integrations,
heron: () => makeHeronClient({apiKey: process.env['HERON_API_KEY']!}),

Check warning on line 105 in apps/cli/_cli.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Forbidden non-null assertion
nango: () =>
makeNangoClient({secretKey: process.env['_NANGO_SECRET_KEY']!}),

Check warning on line 107 in apps/cli/_cli.ts

View workflow job for this annotation

GitHub Actions / Run type checks, lint, and tests

Forbidden non-null assertion
}

const clientFactory = z
Expand Down
1 change: 1 addition & 0 deletions integrations/integration-qbo/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const qboDef = {
stage: 'beta',
categories: ['accounting'],
logoUrl: '/_assets/logo-qbo.svg',
nangoProvider: 'quickbooks',
},
extension: {
sourceMapEntity: {
Expand Down
1 change: 1 addition & 0 deletions packages/cdk-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './integration.types'
export * from './kvStore'
export * from './meta.types'
export * from './metaService'
export * from './nango'
export * from './NoopMetaService'
export * from './protocol'
export * from './providers.types'
Expand Down
1 change: 1 addition & 0 deletions packages/cdk-core/integration.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface IntegrationDef<
T extends IntHelpers<TSchemas> = IntHelpers<TSchemas>,
> {
name: TSchemas['name']['_def']['value']
// TODO: Rename def to schemas...
def: TSchemas
metadata?: IntegrationMetadata

Expand Down
214 changes: 214 additions & 0 deletions packages/cdk-core/nango.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import type {Endpoints, InfoFromEndpoints} from '@usevenice/util'
import {makeOpenApiClient, z} from '@usevenice/util'

const zNangoProvider = z.enum([
'accelo',
'adobe',
'aircall',
'airtable',
'amazon',
'amplitude',
'asana',
'ashby',
'atlassian',
'bamboohr',
'battlenet',
'bitbucket',
'boldsign',
'box',
'braintree',
'braintree-sandbox',
'brex',
'brex-staging',
'calendly',
'clickup',
'confluence',
'contentstack',
'deel',
'deel-sandbox',
'digitalocean',
'discord',
'docusign',
'docusign-sandbox',
'dropbox',
'epic-games',
'evaluagent',
'eventbrite',
'exact-online',
'factorial',
'facebook',
'figjam',
'figma',
'fitbit',
'freshbooks',
'freshservice',
'front',
'github',
'github-app',
'gitlab',
'gong',
'google',
'google-calendar',
'google-mail',
'google-sheet',
'gorgias',
'greenhouse',
'gumroad',
'gusto',
'health-gorilla',
'highlevel',
'hubspot',
'instagram',
'intercom',
'intuit',
'jira',
'keap',
'lever',
'linear',
'linkedin',
'linkhut',
'mailchimp',
'microsoft-teams',
'mixpanel',
'miro',
'monday',
'mural',
'nationbuilder',
'netsuite',
'notion',
'one-drive',
'osu',
'outreach',
'pagerduty',
'pandadoc',
'payfit',
'pennylane',
'pipedrive',
'qualtrics',
'quickbooks',
'ramp',
'ramp-sandbox',
'reddit',
'ring-central',
'ring-central-sandbox',
'segment',
'sage',
'salesforce',
'salesforce-sandbox',
'salesloft',
'servicem8',
'shopify',
'shortcut',
'slack',
'smugmug',
'splitwise',
'spotify',
'squareup',
'squareup-sandbox',
'stackexchange',
'strava',
'stripe',
'stripe-express',
'survey-monkey',
'teamwork',
'timely',
'trello',
'todoist',
'twitch',
'twitter',
'twitter-v2',
'twinfield',
'typeform',
'uber',
'unauthenticated',
'wakatime',
'wave-accounting',
'wildix-pbx',
'workable',
'xero',
'yahoo',
'yandex',
'youtube',
'zapier-nla',
'zendesk',
'zenefits',
'zoho',
'zoho-books',
'zoho-crm',
'zoho-desk',
'zoho-inventory',
'zoho-invoice',
'zoom',
])

export type NangoProvider = z.infer<typeof zNangoProvider>

export const zIntegrationShort = z.object({
provider: zNangoProvider,
/** aka providerConfigKey */
unique_key: z.string(),
})

export const zIntegration = zIntegrationShort.extend({
client_id: z.string(),
client_secret: z.string(),
scopes: z.string(),
app_link: z.string().nullish(),
auth_mode: z.enum(['OAUTH2', 'OAUTH1', 'BASIC']),
})

export const zUpsertIntegration = zIntegration
.omit({
unique_key: true,
client_id: true,
client_secret: true,
scopes: true,
})
.extend({
provider_config_key: z.string(),
oauth_client_id: z.string(),
oauth_client_secret: z.string(),
oauth_scopes: z.string().optional(),
})
.partial({auth_mode: true})

export const endpoints = {
get: {
'/config': {input: {}, output: z.array(zIntegrationShort)},
'/config/{providerConfigKey}': {
input: {
path: z.object({providerConfigKey: z.string()}),
query: z.object({include_creds: z.boolean().optional()}),
},
output: z.union([zIntegration, zIntegrationShort]),
},
},
post: {
'/config': {input: {bodyJson: zUpsertIntegration}, output: z.undefined()},
},
put: {
'/config': {input: {bodyJson: zUpsertIntegration}, output: z.undefined()},
},
delete: {
'/config/{providerConfigKey}': {
input: {path: z.object({providerConfigKey: z.string()})},
output: z.undefined(),
},
},
} satisfies Endpoints

// ---

export const zNangoConfig = z.object({
secretKey: z.string(),
})

export function makeNangoClient(config: z.infer<typeof zNangoConfig>) {
const client = makeOpenApiClient<InfoFromEndpoints<typeof endpoints>>({
baseUrl: 'https://api.nango.dev',
auth: {bearerToken: config.secretKey},
})
return client
}

export type NangoClient = ReturnType<typeof makeNangoClient>
3 changes: 3 additions & 0 deletions packages/cdk-core/providers.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
IntegrationSchemas,
IntHelpers,
} from './integration.types'
import type {NangoProvider} from './nango'
import type {AnyEntityPayload, ResoUpdateData, Source} from './protocol'

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
Expand Down Expand Up @@ -67,6 +68,8 @@ export interface IntegrationMetadata {
stage?: z.infer<typeof zIntegrationStage>
// labels?: Array<'featured' | 'banking' | 'accounting' | 'enrichment'>
categories?: Array<z.infer<typeof zIntegrationCategory>>
/** Whether this is an oauth integration? */
nangoProvider?: NangoProvider
}

// MARK: - Shared connect types
Expand Down
5 changes: 5 additions & 0 deletions packages/engine-backend/context.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {TRPCError} from '@trpc/server'

import {makeNangoClient} from '@usevenice/cdk-core'
import type {
AnyIntegrationImpl,
EndUserId,
Link,
LinkFactory,
MetaService,
NangoClient,
} from '@usevenice/cdk-core'
import type {JWTClient, Viewer, ViewerRole} from '@usevenice/cdk-core/viewer'
import {makeJwtClient, zViewerFromJwtPayload} from '@usevenice/cdk-core/viewer'
Expand All @@ -28,6 +30,7 @@ export interface RouterContext {
// Non-viewer dependent
providerMap: Record<string, AnyIntegrationImpl>
jwt: JWTClient
nango: NangoClient
/**
* Base url of the engine-backend router when deployed, e.g. `localhost:3000/api/usevenice`
* This is needed for 1) server side rendering and 2) webhook handling
Expand All @@ -51,6 +54,7 @@ export interface ContextFactoryOptions<

/** Used for authentication */
jwtSecret: string
nangoSecretKey: string

/** Used to store metadata */
getMetaService: (viewer: Viewer) => MetaService
Expand Down Expand Up @@ -97,6 +101,7 @@ export function getContextFactory<
// --- Non-viewer dependent
providerMap,
jwt,
nango: makeNangoClient({secretKey: config.nangoSecretKey}),
apiUrl,
getRedirectUrl,
}
Expand Down
36 changes: 32 additions & 4 deletions packages/engine-backend/router/adminRouter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {TRPCError} from '@trpc/server'

import {
extractProviderName,
handlersLink,
makeId,
sync,
Expand Down Expand Up @@ -91,7 +92,7 @@ export const adminRouter = trpc.router({
// this makes me wonder if UPSERT should always be the default....
.required({orgId: true}),
)
.mutation(({input: {id: _id, providerName, ...input}, ctx}) => {
.mutation(async ({input: {id: _id, providerName, ...input}, ctx}) => {
const id = _id
? _id
: providerName && input.orgId
Expand All @@ -103,14 +104,41 @@ export const adminRouter = trpc.router({
message: 'Missing id or providerName/orgId',
})
}
const provider = ctx.providerMap[extractProviderName(id)]

if (!provider) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Missing provider for ${extractProviderName(id)}`,
})
}
if (provider.metadata?.nangoProvider) {
// Create nango integration here...
await ctx.nango.post('/config', {
bodyJson: {
provider_config_key: id,
provider: provider.metadata.nangoProvider,
// TODO: gotta fix the typing here...
oauth_client_id: (input.config as any).clientId,
oauth_client_secret: (input.config as any).clientSecret,
},
})
}

return ctx.helpers.patchReturning('integration', id, input)
}),
// Need a tuple for some reason... otherwise seems to not work in practice.
adminDeleteIntegration: adminProcedure
.input(z.tuple([zId('int')]))
.mutation(({input: [intId], ctx}) =>
ctx.helpers.metaService.tables.integration.delete(intId),
),
.mutation(async ({input: [intId], ctx}) => {
const provider = ctx.providerMap[extractProviderName(intId)]
if (provider?.metadata?.nangoProvider) {
await ctx.nango.delete('/config/{providerConfigKey}', {
path: {providerConfigKey: intId},
})
}
return ctx.helpers.metaService.tables.integration.delete(intId)
}),
adminCreateConnectToken: adminProcedure
.input(adminRouterSchema.adminCreateConnectToken.input)
.mutation(({input: {endUserId, orgId, validityInSeconds}, ctx}) => {
Expand Down

0 comments on commit 1e1735d

Please sign in to comment.