From 1e1735dcf97d2a3a7138eadc401048951c2bf6b7 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Wed, 11 Oct 2023 16:33:38 -0700 Subject: [PATCH] Keeping nango integrations in sync with oauth-based venice integrations --- apps/app-config/backendConfig.ts | 1 + apps/app-config/env.ts | 2 + apps/cli/_cli.ts | 4 +- integrations/integration-qbo/def.ts | 1 + packages/cdk-core/index.ts | 1 + packages/cdk-core/integration.types.ts | 1 + packages/cdk-core/nango.ts | 214 ++++++++++++++++++ packages/cdk-core/providers.types.ts | 3 + packages/engine-backend/context.ts | 5 + packages/engine-backend/router/adminRouter.ts | 36 ++- 10 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 packages/cdk-core/nango.ts diff --git a/apps/app-config/backendConfig.ts b/apps/app-config/backendConfig.ts index 7f882b0b..3aa1747d 100644 --- a/apps/app-config/backendConfig.ts +++ b/apps/app-config/backendConfig.ts @@ -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}), diff --git a/apps/app-config/env.ts b/apps/app-config/env.ts index c482fd23..db1acaf5 100644 --- a/apps/app-config/env.ts +++ b/apps/app-config/env.ts @@ -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(), @@ -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'], diff --git a/apps/cli/_cli.ts b/apps/cli/_cli.ts index 63218045..99aa735f 100644 --- a/apps/cli/_cli.ts +++ b/apps/cli/_cli.ts @@ -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' @@ -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']!}), + nango: () => + makeNangoClient({secretKey: process.env['_NANGO_SECRET_KEY']!}), } const clientFactory = z diff --git a/integrations/integration-qbo/def.ts b/integrations/integration-qbo/def.ts index 927266c6..16ac1377 100644 --- a/integrations/integration-qbo/def.ts +++ b/integrations/integration-qbo/def.ts @@ -54,6 +54,7 @@ export const qboDef = { stage: 'beta', categories: ['accounting'], logoUrl: '/_assets/logo-qbo.svg', + nangoProvider: 'quickbooks', }, extension: { sourceMapEntity: { diff --git a/packages/cdk-core/index.ts b/packages/cdk-core/index.ts index e8ff803f..ac7ab355 100644 --- a/packages/cdk-core/index.ts +++ b/packages/cdk-core/index.ts @@ -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' diff --git a/packages/cdk-core/integration.types.ts b/packages/cdk-core/integration.types.ts index 9cbcdece..3aa2b461 100644 --- a/packages/cdk-core/integration.types.ts +++ b/packages/cdk-core/integration.types.ts @@ -47,6 +47,7 @@ export interface IntegrationDef< T extends IntHelpers = IntHelpers, > { name: TSchemas['name']['_def']['value'] + // TODO: Rename def to schemas... def: TSchemas metadata?: IntegrationMetadata diff --git a/packages/cdk-core/nango.ts b/packages/cdk-core/nango.ts new file mode 100644 index 00000000..dd198975 --- /dev/null +++ b/packages/cdk-core/nango.ts @@ -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 + +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) { + const client = makeOpenApiClient>({ + baseUrl: 'https://api.nango.dev', + auth: {bearerToken: config.secretKey}, + }) + return client +} + +export type NangoClient = ReturnType diff --git a/packages/cdk-core/providers.types.ts b/packages/cdk-core/providers.types.ts index b964d79b..350afe17 100644 --- a/packages/cdk-core/providers.types.ts +++ b/packages/cdk-core/providers.types.ts @@ -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 @@ -67,6 +68,8 @@ export interface IntegrationMetadata { stage?: z.infer // labels?: Array<'featured' | 'banking' | 'accounting' | 'enrichment'> categories?: Array> + /** Whether this is an oauth integration? */ + nangoProvider?: NangoProvider } // MARK: - Shared connect types diff --git a/packages/engine-backend/context.ts b/packages/engine-backend/context.ts index 6f6a197d..4a76d715 100644 --- a/packages/engine-backend/context.ts +++ b/packages/engine-backend/context.ts @@ -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' @@ -28,6 +30,7 @@ export interface RouterContext { // Non-viewer dependent providerMap: Record 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 @@ -51,6 +54,7 @@ export interface ContextFactoryOptions< /** Used for authentication */ jwtSecret: string + nangoSecretKey: string /** Used to store metadata */ getMetaService: (viewer: Viewer) => MetaService @@ -97,6 +101,7 @@ export function getContextFactory< // --- Non-viewer dependent providerMap, jwt, + nango: makeNangoClient({secretKey: config.nangoSecretKey}), apiUrl, getRedirectUrl, } diff --git a/packages/engine-backend/router/adminRouter.ts b/packages/engine-backend/router/adminRouter.ts index caf75a96..14b72bce 100644 --- a/packages/engine-backend/router/adminRouter.ts +++ b/packages/engine-backend/router/adminRouter.ts @@ -1,6 +1,7 @@ import {TRPCError} from '@trpc/server' import { + extractProviderName, handlersLink, makeId, sync, @@ -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 @@ -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}) => {