From 3786c28100aff7e5fb48f652f014ad5ebf3da43f Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 23 Feb 2026 16:36:43 +0530 Subject: [PATCH 1/6] refactor: move provider registry to code-first architecture --- TECHNICAL_DEBT.md | 19 + apps/api/src/db/schema.ts | 4 +- .../connections/callback.controller.spec.ts | 43 +-- .../connections/callback.controller.ts | 15 +- .../connections/connectors.controller.spec.ts | 22 +- .../connections/connectors.controller.ts | 5 +- .../connections/token-refresh.service.spec.ts | 33 +- .../connections/token-refresh.service.ts | 2 +- .../connections/connectors.service.spec.ts | 124 ++++--- .../modules/connections/connectors.service.ts | 89 +++-- .../credential_storage_architecture.md | 93 +++++ integrations/quickbooks/eslint.config.mjs | 8 - integrations/quickbooks/package.json | 20 -- integrations/quickbooks/src/auth/config.ts | 23 -- integrations/quickbooks/src/index.ts | 1 - integrations/quickbooks/tsconfig.json | 16 - integrations/salesforce/eslint.config.mjs | 8 - integrations/salesforce/package.json | 20 -- integrations/salesforce/src/auth/config.ts | 36 -- integrations/salesforce/src/index.ts | 1 - integrations/salesforce/tsconfig.json | 16 - .../src/connectivity/provider-registry.ts | 44 +-- .../src/connectivity/providers/index.ts | 18 + .../providers/quickbooks.provider.ts | 17 + .../providers/salesforce.provider.ts | 17 + .../connections/src/connectivity/types.ts | 52 ++- .../drizzle/0000_unique_sharon_carter.sql | 41 --- .../database/drizzle/0002_careless_gideon.sql | 1 - .../database/drizzle/meta/0000_snapshot.json | 270 --------------- .../database/drizzle/meta/0001_snapshot.json | 306 ---------------- .../database/drizzle/meta/0002_snapshot.json | 327 ------------------ packages/database/drizzle/meta/_journal.json | 27 -- packages/database/push-schema-local.ts | 48 +++ packages/database/push-schema.ts | 48 +++ packages/database/src/client.ts | 3 +- packages/database/src/index.ts | 3 +- .../database/src/schema/app-credential.ts | 23 ++ packages/database/src/schema/provider.ts | 35 -- packages/database/src/schema/tenant.ts | 7 +- pnpm-workspace.yaml | 2 +- 40 files changed, 531 insertions(+), 1356 deletions(-) create mode 100644 docs/architecture/credential_storage_architecture.md delete mode 100644 integrations/quickbooks/eslint.config.mjs delete mode 100644 integrations/quickbooks/package.json delete mode 100644 integrations/quickbooks/src/auth/config.ts delete mode 100644 integrations/quickbooks/src/index.ts delete mode 100644 integrations/quickbooks/tsconfig.json delete mode 100644 integrations/salesforce/eslint.config.mjs delete mode 100644 integrations/salesforce/package.json delete mode 100644 integrations/salesforce/src/auth/config.ts delete mode 100644 integrations/salesforce/src/index.ts delete mode 100644 integrations/salesforce/tsconfig.json create mode 100644 packages/connections/src/connectivity/providers/index.ts create mode 100644 packages/connections/src/connectivity/providers/quickbooks.provider.ts create mode 100644 packages/connections/src/connectivity/providers/salesforce.provider.ts delete mode 100644 packages/database/drizzle/0000_unique_sharon_carter.sql delete mode 100644 packages/database/drizzle/0002_careless_gideon.sql delete mode 100644 packages/database/drizzle/meta/0000_snapshot.json delete mode 100644 packages/database/drizzle/meta/0001_snapshot.json delete mode 100644 packages/database/drizzle/meta/0002_snapshot.json delete mode 100644 packages/database/drizzle/meta/_journal.json create mode 100644 packages/database/push-schema-local.ts create mode 100644 packages/database/push-schema.ts create mode 100644 packages/database/src/schema/app-credential.ts delete mode 100644 packages/database/src/schema/provider.ts diff --git a/TECHNICAL_DEBT.md b/TECHNICAL_DEBT.md index 6fbe2f48..9911ce3d 100644 --- a/TECHNICAL_DEBT.md +++ b/TECHNICAL_DEBT.md @@ -65,6 +65,25 @@ Adopt industry-standard data-fetching library (React Query or SWR): - Ensure the database is accessible or service-containerized in CI. - Update the CI workflow to enable `VITE_AUTH_GOOGLE_ENABLED=true`. +### 3. Drizzle Monorepo Database Architecture + +**Location**: `packages/database`, `packages/identity`, `apps/api` +**Added**: 2026-02-22 +**Impact**: Developer Velocity, Migration Stability +**Effort**: High (1 sprint) + +**Current State (3 Compounding Issues)**: + +1. **Monorepo Schema Fragmentation**: Drizzle ORM is designed to analyze a single folder of schemas. In Nexiom, schemas are split across `@nexiom/identity` and `@nexiom/database`, then aggregated in `apps/api`. Running Drizzle's migration scripts from the workspace packages lacks full context and breaks cross-package resolution in Drizzle Studio. +2. **Environment Variable Hell**: The database connection string lives in `apps/api/.env`. Running scripts from `packages/database` fails over missing credentials without brittle `source ../../apps/api/.env` injection, which further breaks if developer environments have different Postgres users. +3. **Broken Migration Snapshots**: Drizzle's history tracking (`drizzle/.drizzle/meta.json`) is corrupted due to a missing historical snapshot (`0001_jazzy_wild_child.sql`). Drizzle CLI currently refuses to run `db:migrate` natively because the migration chain is broken. + +**Recommended Solution**: + +- **Unify Schema Management**: Move the source of truth for all schema Generation and Migrations to the `apps/api` level where the `.env` execution context actually lives, or create a dedicated operational `packages/db-migrator` package that centrally imports all other packages and manages the single `drizzle.config.ts`. +- **Reset Migration History**: Generate a fresh baseline database schema and squash all historical migrations to reset the corrupted `.drizzle` snapshot folder. +- **Centralize DB Credentials**: Export a generic database URL resolution file that automatically paths to the root or `apps/api` `.env` regardless of which workspace is currently executing the CLI. + --- ## Medium Priority diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index a549ad15..03c6c603 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -39,5 +39,5 @@ export type { AbacConditions, } from '@nexiom/identity/src/schema'; -// Engine schema — provider catalog and connection tables -export { providers, appConnections } from '@nexiom/database'; +// Engine schema — credentials and connection tables +export { appConnections, appCredentials } from '@nexiom/database'; diff --git a/apps/api/src/modules/connections/connections/callback.controller.spec.ts b/apps/api/src/modules/connections/connections/callback.controller.spec.ts index c9f3f3d7..1b4e4d5b 100644 --- a/apps/api/src/modules/connections/connections/callback.controller.spec.ts +++ b/apps/api/src/modules/connections/connections/callback.controller.spec.ts @@ -43,9 +43,7 @@ vi.mock('@nexiom/database', () => ({ })); describe('OAuthCallbackController', () => { - type ProviderResult = Awaited< - ReturnType - >; + type ProviderResult = ReturnType; let controller: OAuthCallbackController; let mockEncryptionService: Mocked; let mockProviderRegistry: Mocked; @@ -89,10 +87,17 @@ describe('OAuthCallbackController', () => { } as unknown as Mocked; mockProviderRegistry = { - getProvider: vi.fn().mockResolvedValue({ - id: 'mock-provider-id', - enabled: true, - } as unknown as ProviderResult), + getProvider: vi.fn().mockReturnValue({ + name: 'salesforce', + displayName: 'Salesforce', + description: 'CRM', + logoUrl: '', + category: 'CRM', + authType: 'OAUTH2', + authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', + tokenUrl: 'https://login.salesforce.com/services/oauth2/token', + scopes: ['api'], + } satisfies ProviderResult), getAllProviders: vi.fn(), } as unknown as Mocked; @@ -134,24 +139,8 @@ describe('OAuthCallbackController', () => { vi.clearAllMocks(); }); - it('should redirect with invalid_provider error if provider is not allowed', async () => { - mockProviderRegistry.getProvider.mockResolvedValue({ - id: 'test-provider', - enabled: false, - } as unknown as ProviderResult); - - const req = mockRequest('unsupported-provider'); - const res = mockResponse(); - - await controller.handleCallback(req as Request, res as Response); - - expect(res.redirect).toHaveBeenCalledWith( - '/app/connections?error=invalid_provider', - ); - }); - it('should redirect with invalid_provider error if provider is not found', async () => { - mockProviderRegistry.getProvider.mockResolvedValue(null); + mockProviderRegistry.getProvider.mockReturnValue(null); const req = mockRequest('unknown-provider'); const res = mockResponse(); @@ -164,7 +153,9 @@ describe('OAuthCallbackController', () => { }); it('should redirect with internal_error if provider lookup fails', async () => { - mockProviderRegistry.getProvider.mockRejectedValue(new Error('DB error')); + mockProviderRegistry.getProvider.mockImplementation(() => { + throw new Error('DB error'); + }); const req = mockRequest('salesforce'); const res = mockResponse(); @@ -292,6 +283,7 @@ describe('OAuthCallbackController', () => { expect(mockConnectorsService.exchangeCodeForTokens).toHaveBeenCalledWith( 'salesforce', '123', + VALID_TENANT_ID, ); expect(mockEncryptionService.encrypt).toHaveBeenCalledWith( @@ -310,7 +302,6 @@ describe('OAuthCallbackController', () => { expect(values).toHaveBeenCalledWith( expect.objectContaining({ tenantId: VALID_TENANT_ID, - providerId: 'mock-provider-id', appName: 'salesforce', connectionKey: 'ext-realm-id', encryptedCredentials: 'encrypted-credentials', diff --git a/apps/api/src/modules/connections/connections/callback.controller.ts b/apps/api/src/modules/connections/connections/callback.controller.ts index 5f5157a0..4c50ecb9 100644 --- a/apps/api/src/modules/connections/connections/callback.controller.ts +++ b/apps/api/src/modules/connections/connections/callback.controller.ts @@ -1,11 +1,11 @@ import { Controller, Get, Req, Res, Logger, Inject } from '@nestjs/common'; import { Request, Response } from 'express'; -import { appConnections, type providers } from '@nexiom/database'; -import type { InferSelectModel } from 'drizzle-orm'; +import { appConnections } from '@nexiom/database'; import { EncryptionService, ProviderRegistryService, DrizzleDb, + type ProviderDefinition, } from '@nexiom/connections'; import { OauthStateService } from '../oauth-state.service'; @@ -43,10 +43,10 @@ export class OAuthCallbackController { return; } - let providerData: InferSelectModel | null; + let providerData: ProviderDefinition | null; try { - providerData = await this.providerRegistry.getProvider(provider); - if (!providerData?.enabled) { + providerData = this.providerRegistry.getProvider(provider); + if (!providerData) { this.logger.warn(`Rejected unauthorized provider: ${provider}`); res.redirect(`/app/connections?error=invalid_provider`); return; @@ -108,6 +108,7 @@ export class OAuthCallbackController { tokenResponse = await this.connectorsService.exchangeCodeForTokens( provider, code, + tenantId, ); } catch (error) { this.logger.error(`Token exchange failed for ${provider}`, error); @@ -199,7 +200,7 @@ export class OAuthCallbackController { private async persistConnection( provider: string, - providerData: InferSelectModel, + _providerData: ProviderDefinition, tenantId: string, stateRealmId: string | undefined, tokenResponse: Record, @@ -252,7 +253,6 @@ export class OAuthCallbackController { .insert(appConnections) .values({ tenantId, - providerId: providerData.id, appName: provider, connectionKey, authType: 'OAUTH2', @@ -267,7 +267,6 @@ export class OAuthCallbackController { appConnections.connectionKey, ], set: { - providerId: providerData.id, encryptedCredentials: encryptedPayload, expiresAt: expiresAt, metadata: { realmId: stateRealmId }, diff --git a/apps/api/src/modules/connections/connections/connectors.controller.spec.ts b/apps/api/src/modules/connections/connections/connectors.controller.spec.ts index 5ed43568..3a6b4be0 100644 --- a/apps/api/src/modules/connections/connections/connectors.controller.spec.ts +++ b/apps/api/src/modules/connections/connections/connectors.controller.spec.ts @@ -102,6 +102,7 @@ describe('ConnectorsController', () => { expect(mockConnectorsService.getAuthorizationUrl).toHaveBeenCalledWith( 'salesforce', 'mocked_jwt_state', + 'tenant-123', ); expect(mockRes.redirect).toHaveBeenCalledWith('https://vendor.com/auth'); }); @@ -133,9 +134,9 @@ describe('ConnectorsController', () => { }); describe('getProviders', () => { - it('should map provider data exactly as required by the frontend uiSchema', async () => { + it('should map provider data exactly as required by the frontend uiSchema', () => { // Arrange - (mockProviderRegistry.getAllProviders as Mock).mockResolvedValue([ + (mockProviderRegistry.getAllProviders as Mock).mockReturnValue([ { name: 'salesforce', displayName: 'Salesforce', @@ -143,7 +144,6 @@ describe('ConnectorsController', () => { description: 'CRM platform', logoUrl: 'https://logo.com/sf.png', category: 'CRM', - enabled: true, scopes: [], uiSchema: {}, authorizeUrl: '', @@ -154,7 +154,7 @@ describe('ConnectorsController', () => { ]); // Act - const result = await controller.getProviders(); + const result = controller.getProviders(); // Assert expect(result).toHaveLength(1); @@ -171,14 +171,14 @@ describe('ConnectorsController', () => { expect(result[0]).not.toHaveProperty('authorizeUrl'); }); - it('should bubble up InternalServerErrorException from the provider registry', async () => { - mockProviderRegistry.getAllProviders.mockRejectedValue( - new Error('DB connection failed'), - ); + it('should bubble up InternalServerErrorException from the provider registry', () => { + mockProviderRegistry.getAllProviders.mockImplementation(() => { + throw new Error('DB connection failed'); + }); - const promise = controller.getProviders(); - await expect(promise).rejects.toThrow(InternalServerErrorException); - await expect(promise).rejects.toThrow('Failed to get providers'); + const action = () => controller.getProviders(); + expect(action).toThrow(InternalServerErrorException); + expect(action).toThrow('Failed to get providers'); }); }); diff --git a/apps/api/src/modules/connections/connections/connectors.controller.ts b/apps/api/src/modules/connections/connections/connectors.controller.ts index bd8efee6..0fd4097f 100644 --- a/apps/api/src/modules/connections/connections/connectors.controller.ts +++ b/apps/api/src/modules/connections/connections/connectors.controller.ts @@ -34,9 +34,9 @@ export class ConnectorsController { ) {} @Get('providers') - async getProviders() { + getProviders() { try { - const providers = await this.providerRegistry.getAllProviders(); + const providers = this.providerRegistry.getAllProviders(); // Only return the necessary public info to the frontend return providers.map((p) => ({ name: p.name, @@ -157,6 +157,7 @@ export class ConnectorsController { const url = await this.connectorsService.getAuthorizationUrl( providerName, state, + tenantId, ); // Redirect the user browser to the vendor's OAuth page diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts index eba6d2c6..06a59bd1 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts @@ -1,5 +1,8 @@ import { DefaultOAuthRefreshClient } from './token-refresh.service'; -import { ProviderRegistryService } from '@nexiom/connections'; +import { + ProviderRegistryService, + ProviderDefinition, +} from '@nexiom/connections'; import { describe, it, @@ -38,21 +41,23 @@ describe('DefaultOAuthRefreshClient', () => { }); it('should throw an error if the provider is not found in the registry', async () => { - (mockProviderRegistry.getProvider as Mock).mockResolvedValue(null); + (mockProviderRegistry.getProvider as Mock).mockReturnValue(null); await expect(client.refresh('unknown_app', 'refresh123')).rejects.toThrow( 'Provider not found for refresh: unknown_app', ); }); it('should throw an error if the provider lacks a tokenUrl', async () => { - (mockProviderRegistry.getProvider as Mock).mockResolvedValue({ - name: 'salesforce', - tokenUrl: null, - displayName: 'Salesforce', - authType: 'OAUTH2', - enabled: true, - createdAt: new Date(), - updatedAt: new Date(), + (mockProviderRegistry.getProvider as Mock).mockReturnValue({ + name: 'quickbooks', + displayName: 'QuickBooks Online', + description: 'Accounting', + logoUrl: '', + category: 'Accounting', + authType: 'OAUTH2' as const, + authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', + tokenUrl: '', + scopes: [], }); await expect(client.refresh('salesforce', 'refresh123')).rejects.toThrow( @@ -61,10 +66,10 @@ describe('DefaultOAuthRefreshClient', () => { }); it('should successfully call the vendor token URL and return the new mapped payload', async () => { - (mockProviderRegistry.getProvider as Mock).mockResolvedValue({ + (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', - }); + } as unknown as ProviderDefinition); const mockResponsePayload = { access_token: 'new_access', @@ -87,10 +92,10 @@ describe('DefaultOAuthRefreshClient', () => { }); it('should throw an error containing the status code if the vendor rejects the refresh', async () => { - (mockProviderRegistry.getProvider as Mock).mockResolvedValue({ + (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', tokenUrl: 'https://oauth.url', - }); + } as unknown as ProviderDefinition); (globalThis.fetch as Mock).mockResolvedValue({ ok: false, diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.ts b/apps/api/src/modules/connections/connections/token-refresh.service.ts index 3dafe82c..f46a464a 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.ts @@ -14,7 +14,7 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { appName: string, refreshToken: string, ): Promise> { - const provider = await this.providerRegistry.getProvider(appName); + const provider = this.providerRegistry.getProvider(appName); if (!provider) { throw new Error(`Provider not found for refresh: ${appName}`); } diff --git a/apps/api/src/modules/connections/connectors.service.spec.ts b/apps/api/src/modules/connections/connectors.service.spec.ts index 95773a51..991cab03 100644 --- a/apps/api/src/modules/connections/connectors.service.spec.ts +++ b/apps/api/src/modules/connections/connectors.service.spec.ts @@ -1,7 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { ConnectorsService } from './connectors.service'; -import { ProviderRegistryService } from '@nexiom/connections'; +import { + ProviderRegistryService, + EncryptionService, +} from '@nexiom/connections'; import { InternalServerErrorException, NotFoundException, @@ -17,24 +20,40 @@ import { Mocked, } from 'vitest'; -type ProviderResult = Awaited< - ReturnType ->; +type ProviderResult = ReturnType; describe('ConnectorsService', () => { let service: ConnectorsService; let mockProviderRegistry: Mocked; - let mockConfig: Record; + let mockEncryptionService: Mocked; + let mockDbWhere: ReturnType; + let mockDb: { + select: ReturnType; + from: ReturnType; + where: ReturnType; + }; + const testTenantId = 'tenant-123'; beforeEach(async () => { - mockConfig = { - SALESFORCE_CLIENT_ID: 'test-client-id', - SALESFORCE_CLIENT_SECRET: 'test-client-secret', - BASE_URL: 'https://tenant.nexiom.app', + mockDbWhere = vi.fn().mockResolvedValue([ + { + clientId: 'test-client-id', + encryptedClientSecret: 'encrypted-secret', + }, + ]); + mockDb = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: mockDbWhere, }; + mockEncryptionService = { + decrypt: vi.fn().mockResolvedValue('test-client-secret'), + encrypt: vi.fn(), + } as unknown as Mocked; + const mockConfigService = { - get: vi.fn().mockImplementation((key: string) => mockConfig[key]), + get: vi.fn().mockReturnValue('https://tenant.nexiom.app'), }; mockProviderRegistry = { @@ -47,6 +66,8 @@ describe('ConnectorsService', () => { ConnectorsService, { provide: ProviderRegistryService, useValue: mockProviderRegistry }, { provide: ConfigService, useValue: mockConfigService }, + { provide: EncryptionService, useValue: mockEncryptionService }, + { provide: 'DRIZZLE_DB', useValue: mockDb as unknown }, ], }).compile(); @@ -59,21 +80,22 @@ describe('ConnectorsService', () => { service.getAuthorizationUrl( 'invalid/provider_name!', 'mocked_jwt_state', + testTenantId, ), ).rejects.toThrow(BadRequestException); }); it('should generate a valid OAuth authorization URL with state and scopes', async () => { - mockProviderRegistry.getProvider.mockResolvedValue({ - id: '1', + mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', scopes: ['api', 'refresh_token'], - } as ProviderResult); + } as unknown as NonNullable); const urlString = await service.getAuthorizationUrl( 'salesforce', 'mocked_jwt_state', + testTenantId, ); const parsedUrl = new URL(urlString); @@ -91,24 +113,23 @@ describe('ConnectorsService', () => { }); it('should throw NotFoundException if provider does not exist', async () => { - mockProviderRegistry.getProvider.mockResolvedValue(null); + mockProviderRegistry.getProvider.mockReturnValue(null); await expect( - service.getAuthorizationUrl('unknown', 'state'), + service.getAuthorizationUrl('unknown', 'state', testTenantId), ).rejects.toThrow(NotFoundException); }); - it('should throw InternalServerErrorException if clientId is missing in env', async () => { - mockProviderRegistry.getProvider.mockResolvedValue({ - id: '1', + it('should throw NotFoundException if credential is missing in db', async () => { + mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', - } as ProviderResult); + } as unknown as NonNullable); - mockConfig.SALESFORCE_CLIENT_ID = ''; + mockDbWhere.mockResolvedValue([]); // No credential found await expect( - service.getAuthorizationUrl('salesforce', 'state'), - ).rejects.toThrow(InternalServerErrorException); + service.getAuthorizationUrl('salesforce', 'state', testTenantId), + ).rejects.toThrow(NotFoundException); }); }); @@ -123,35 +144,51 @@ describe('ConnectorsService', () => { it('should throw BadRequestException if providerName fails validation', async () => { await expect( - service.exchangeCodeForTokens('invalid/provider_name!', 'auth-code'), + service.exchangeCodeForTokens( + 'invalid/provider_name!', + 'auth-code', + testTenantId, + ), ).rejects.toThrow(BadRequestException); }); it('should throw NotFoundException if provider does not exist', async () => { - mockProviderRegistry.getProvider.mockResolvedValue(null); + mockProviderRegistry.getProvider.mockReturnValue(null); await expect( - service.exchangeCodeForTokens('unknown', 'auth-code'), + service.exchangeCodeForTokens('unknown', 'auth-code', testTenantId), ).rejects.toThrow(NotFoundException); }); - it('should throw InternalServerErrorException if credentials are missing', async () => { - mockProviderRegistry.getProvider.mockResolvedValue({ - id: '1', + it('should throw NotFoundException if credentials are missing in db', async () => { + mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - } as ProviderResult); + } as unknown as NonNullable); + + mockDbWhere.mockResolvedValue([]); // No credential found - mockConfig.SALESFORCE_CLIENT_ID = ''; - mockConfig.SALESFORCE_CLIENT_SECRET = ''; + await expect( + service.exchangeCodeForTokens('salesforce', 'auth-code', testTenantId), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw InternalServerErrorException if decryption fails', async () => { + mockProviderRegistry.getProvider.mockReturnValue({ + name: 'salesforce', + tokenUrl: 'https://login.salesforce.com/services/oauth2/token', + } as unknown as NonNullable); + + mockEncryptionService.decrypt.mockImplementation(() => { + throw new Error('decryption failed'); + }); await expect( - service.exchangeCodeForTokens('salesforce', 'auth-code'), + service.exchangeCodeForTokens('salesforce', 'auth-code', testTenantId), ).rejects.toThrow(InternalServerErrorException); }); it('should successfully exchange a code for tokens', async () => { - mockProviderRegistry.getProvider.mockResolvedValue({ - id: '1', + mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', } as ProviderResult); @@ -165,6 +202,7 @@ describe('ConnectorsService', () => { const result = await service.exchangeCodeForTokens( 'salesforce', 'auth-code', + testTenantId, ); expect(result).toEqual(mockTokens); @@ -182,11 +220,10 @@ describe('ConnectorsService', () => { }); it('should throw InternalServerErrorException if the token exchange fails', async () => { - mockProviderRegistry.getProvider.mockResolvedValue({ - id: '1', + mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - } as ProviderResult); + } as unknown as NonNullable); vi.mocked(fetch).mockResolvedValue({ ok: false, @@ -195,21 +232,24 @@ describe('ConnectorsService', () => { } as Response); await expect( - service.exchangeCodeForTokens('salesforce', 'bad-code'), + service.exchangeCodeForTokens('salesforce', 'bad-code', testTenantId), ).rejects.toThrow(InternalServerErrorException); }); it('should throw InternalServerErrorException on network/timeout errors', async () => { - mockProviderRegistry.getProvider.mockResolvedValue({ - id: '1', + mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - } as ProviderResult); + } as unknown as NonNullable); vi.mocked(fetch).mockRejectedValue(new Error('network unreachable')); await expect( - service.exchangeCodeForTokens('salesforce', 'timeout-code'), + service.exchangeCodeForTokens( + 'salesforce', + 'timeout-code', + testTenantId, + ), ).rejects.toThrow(InternalServerErrorException); }); }); diff --git a/apps/api/src/modules/connections/connectors.service.ts b/apps/api/src/modules/connections/connectors.service.ts index 632efa9e..32c1f58c 100644 --- a/apps/api/src/modules/connections/connectors.service.ts +++ b/apps/api/src/modules/connections/connectors.service.ts @@ -4,23 +4,28 @@ import { Logger, NotFoundException, BadRequestException, + Inject, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ProviderRegistryService } from '@nexiom/connections'; +import { + ProviderRegistryService, + DrizzleDb, + EncryptionService, +} from '@nexiom/connections'; +import { appCredentials } from '@nexiom/database'; +import { eq, and } from 'drizzle-orm'; @Injectable() export class ConnectorsService { private readonly logger = new Logger(ConnectorsService.name); constructor( + @Inject('DRIZZLE_DB') private readonly db: DrizzleDb, + private readonly crypto: EncryptionService, private readonly providerRegistry: ProviderRegistryService, private readonly configService: ConfigService, ) {} - private normalizeProviderEnvPrefix(providerName: string): string { - return providerName.replace(/[^A-Za-z0-9]/g, '_').toUpperCase(); - } - private buildRedirectUri(providerName: string): string { const baseUrl = this.configService.get('BASE_URL') || 'http://localhost:3000'; @@ -34,12 +39,13 @@ export class ConnectorsService { async getAuthorizationUrl( providerName: string, state: string, + tenantId: string, ): Promise { if (!/^[a-z0-9-]+$/.test(providerName)) { throw new BadRequestException('Invalid provider name format'); } - const provider = await this.providerRegistry.getProvider(providerName); + const provider = this.providerRegistry.getProvider(providerName); if (!provider) { throw new NotFoundException( @@ -56,21 +62,28 @@ export class ConnectorsService { ); } - // Attempt to load client credentials - const normalizedEnvName = this.normalizeProviderEnvPrefix(providerName); - const clientId = this.configService.get( - `${normalizedEnvName}_CLIENT_ID`, - ); + // Fetch tenant's BYOA credentials + const [credential] = await this.db + .select() + .from(appCredentials) + .where( + and( + eq(appCredentials.tenantId, tenantId), + eq(appCredentials.appName, providerName), + ), + ); - if (!clientId) { + if (!credential) { this.logger.error( - `Missing OAuth client ID for ${providerName} (${normalizedEnvName}_CLIENT_ID)`, + `Missing OAuth app credential for ${providerName} on tenant ${tenantId}`, ); - throw new InternalServerErrorException( - `Server is missing credentials for ${providerName}`, + throw new NotFoundException( + `Platform administrator has not configured ${providerName} integration.`, ); } + const clientId = credential.clientId; + const url = new URL(provider.authorizeUrl); url.searchParams.append('response_type', 'code'); url.searchParams.append('client_id', clientId); @@ -96,12 +109,13 @@ export class ConnectorsService { async exchangeCodeForTokens( providerName: string, code: string, + tenantId: string, ): Promise> { if (!/^[a-z0-9-]+$/.test(providerName)) { throw new BadRequestException('Invalid provider name format'); } - const provider = await this.providerRegistry.getProvider(providerName); + const provider = this.providerRegistry.getProvider(providerName); if (!provider) { throw new NotFoundException( @@ -118,18 +132,39 @@ export class ConnectorsService { ); } - const normalizedEnvName = this.normalizeProviderEnvPrefix(providerName); - const clientId = this.configService.get( - `${normalizedEnvName}_CLIENT_ID`, - ); - const clientSecret = this.configService.get( - `${normalizedEnvName}_CLIENT_SECRET`, - ); + // Fetch tenant's BYOA credentials + const [credential] = await this.db + .select() + .from(appCredentials) + .where( + and( + eq(appCredentials.tenantId, tenantId), + eq(appCredentials.appName, providerName), + ), + ); - if (!clientId || !clientSecret) { - this.logger.error(`Missing OAuth client credentials for ${providerName}`); + if (!credential) { + this.logger.error( + `Missing OAuth app credential for ${providerName} on tenant ${tenantId}`, + ); + throw new NotFoundException( + `Platform administrator has not configured ${providerName} integration.`, + ); + } + + const clientId = credential.clientId; + + let clientSecret: string; + try { + clientSecret = await this.crypto.decrypt( + credential.encryptedClientSecret, + ); + } catch { + this.logger.error( + `Failed to decrypt client secret for ${providerName} on tenant ${tenantId}`, + ); throw new InternalServerErrorException( - `Server is missing credentials for ${providerName}`, + 'Invalid connector configuration.', ); } @@ -159,7 +194,7 @@ export class ConnectorsService { /* ignore parsing errors */ } - let sanitizedError = errorBody.replace(/[\r\n]+/g, ' ').trim(); + let sanitizedError = errorBody.replaceAll(/[\r\n]+/g, ' ').trim(); if (sanitizedError.length > 500) { sanitizedError = sanitizedError.substring(0, 500) + '...(truncated)'; } diff --git a/docs/architecture/credential_storage_architecture.md b/docs/architecture/credential_storage_architecture.md new file mode 100644 index 00000000..9055d57a --- /dev/null +++ b/docs/architecture/credential_storage_architecture.md @@ -0,0 +1,93 @@ +# Credential Storage Architecture + +**Date:** 2026-02-22 +**Decision:** Keep `app_credential` and `app_connection` in the shared Catalog DB, not per-tenant databases. + +--- + +## Decision + +OAuth App Credentials (`app_credential`) and User Connection tokens (`app_connection`) are stored in the **central Catalog Database** (`nexiom_local`), in the `public` schema, isolated logically by a `tenant_id` column. + +They are **not** stored inside each tenant's physically isolated PostgreSQL database. + +--- + +## Why Not Per-Tenant DB? + +Storing credentials inside tenant databases was considered and rejected for three concrete efficiency reasons: + +### 1. Connection Pool Exhaustion + +The Nexiom Engine must proactively maintain and refresh OAuth tokens. If credentials lived in 5,000 separate tenant databases, the Engine would need to: + +- Maintain 5,000 separate Postgres connection pools simultaneously (impossible), OR +- Open a dynamic connection to each tenant's database on demand for every workflow execution + +Establishing a Postgres connection involves a TCP handshake and authentication, adding latency to every token refresh and workflow boot. + +### 2. Global Token Refresh is blocked + +OAuth tokens expire regularly (e.g., Salesforce access tokens expire in ~2 hours). The Engine runs a centralized **Token Refresh Service** that scans all tokens expiring in the next 10 minutes and refreshes them proactively. + +This is only possible with a single query: + +```sql +SELECT * FROM app_connection WHERE expires_at < NOW() + INTERVAL '10 minutes'; +``` + +If connections were per-tenant, this would require looping across 5,000 databases, which breaks down in production. + +### 3. Cross-Tenant Operations (Analytics and Migrations) + +Counting active integrations, running schema migrations, or patching a security bug in `app_credential` would all require running the same operation across 5,000 databases. This is fragile, error-prone, and difficult to monitor. + +--- + +## The Hybrid Architecture (Approved) + +Nexiom uses a **Hybrid Model** that balances security with efficiency: + +| Layer | Storage | Isolation Method | +|---|---|---| +| **Platform Credentials** (`app_credential`, `app_connection`) | Shared Catalog DB | Row-level `tenant_id` + application query guards | +| **Customer Payload Data** (CRM records, Accounting data pulled from integrations) | Isolated Tenant DB (one database per tenant) | Physical PostgreSQL database isolation | + +### How it flows + +1. A Nexiom workflow triggers for **Tenant A**. +2. The Engine reads the OAuth token from `app_connection WHERE tenant_id = 'tenant_a'` in the **Catalog DB** (instant, single connection pool). +3. The Engine makes the HTTP call to Salesforce. +4. The Engine dynamically connects to **Tenant A's isolated database** to write the returned data records. + +This means token retrieval is fast and always-available via a central pool, while the actual business payload data remains physically isolated per tenant for compliance (SOC2, HIPAA). + +--- + +## Comparison with Industry Standards + +| Platform | Credential Location | Physical Data | +|---|---|---| +| **Activepieces** | Shared Catalog DB (`projectId` row isolation) | Same Shared DB | +| **n8n Cloud** | Shared Catalog DB (control plane) | Isolated K8s pods per enterprise tenant | +| **Nexiom** | Shared Catalog DB (`tenant_id` row isolation) | Isolated Tenant DB per customer | + +Nexiom's approach is **more isolated than Activepieces** (for payload data) and **more efficient than n8n Cloud** (no per-tenant pod overhead for small/mid-size customers). + +--- + +## Security Controls + +Row-level security is enforced at the application layer in all Drizzle queries: + +```typescript +// ConnectorsService — always tenant-scoped +.where( + and( + eq(appCredentials.tenantId, tenantId), + eq(appCredentials.appName, providerName), + ), +) +``` + +Future hardening should add **PostgreSQL Row-Level Security (RLS)** policies as a second layer of defense. diff --git a/integrations/quickbooks/eslint.config.mjs b/integrations/quickbooks/eslint.config.mjs deleted file mode 100644 index 7ce65124..00000000 --- a/integrations/quickbooks/eslint.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check -import { createIntegrationConfig } from '@nexiom/eslint-config'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default createIntegrationConfig(__dirname); diff --git a/integrations/quickbooks/package.json b/integrations/quickbooks/package.json deleted file mode 100644 index 774f1751..00000000 --- a/integrations/quickbooks/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@nexiom/quickbooks", - "version": "1.0.0", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "lint": "eslint \"src/**/*.ts\" --fix", - "lint:check": "eslint \"src/**/*.ts\"" - }, - "dependencies": { - "@nexiom/connections": "workspace:*" - }, - "devDependencies": { - "@nexiom/eslint-config": "workspace:*", - "typescript": "^5.7.3" - } -} \ No newline at end of file diff --git a/integrations/quickbooks/src/auth/config.ts b/integrations/quickbooks/src/auth/config.ts deleted file mode 100644 index 34bc6b94..00000000 --- a/integrations/quickbooks/src/auth/config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { GenericCredentialType } from "@nexiom/connections"; - -export const quickbooksAuth: GenericCredentialType = { - name: "quickbooks", - authType: "OAUTH2", - uiSchema: { - type: "object", - properties: [ - { - name: "clientId", - label: "Client ID", - type: "shortText", - required: true, - }, - { - name: "clientSecret", - label: "Client Secret", - type: "secretText", - required: true, - }, - ], - }, -}; diff --git a/integrations/quickbooks/src/index.ts b/integrations/quickbooks/src/index.ts deleted file mode 100644 index ab6db7f6..00000000 --- a/integrations/quickbooks/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./auth/config"; diff --git a/integrations/quickbooks/tsconfig.json b/integrations/quickbooks/tsconfig.json deleted file mode 100644 index bea0d6e6..00000000 --- a/integrations/quickbooks/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" - }, - "include": [ - "src/**/*" - ], - "references": [ - { - "path": "../../packages/connections" - } - ] -} \ No newline at end of file diff --git a/integrations/salesforce/eslint.config.mjs b/integrations/salesforce/eslint.config.mjs deleted file mode 100644 index 7ce65124..00000000 --- a/integrations/salesforce/eslint.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check -import { createIntegrationConfig } from '@nexiom/eslint-config'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default createIntegrationConfig(__dirname); diff --git a/integrations/salesforce/package.json b/integrations/salesforce/package.json deleted file mode 100644 index 8528c866..00000000 --- a/integrations/salesforce/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@nexiom/salesforce", - "version": "1.0.0", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "lint": "eslint \"src/**/*.ts\" --fix", - "lint:check": "eslint \"src/**/*.ts\"" - }, - "dependencies": { - "@nexiom/connections": "workspace:*" - }, - "devDependencies": { - "@nexiom/eslint-config": "workspace:*", - "typescript": "^5.7.3" - } -} \ No newline at end of file diff --git a/integrations/salesforce/src/auth/config.ts b/integrations/salesforce/src/auth/config.ts deleted file mode 100644 index e75d07a2..00000000 --- a/integrations/salesforce/src/auth/config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { GenericCredentialType } from "@nexiom/connections"; - -/** - * Salesforce provider definition — used as seed data for the `providers` table. - * OAuth URLs (authorizeUrl, tokenUrl) are stored in the DB and can be - * overridden per-tenant for sandbox / custom domains. - */ -export const salesforceAuth: GenericCredentialType = { - name: "salesforce", - authType: "OAUTH2", - uiSchema: { - type: "object", - properties: [ - { - name: "clientId", - label: "Client ID", - type: "shortText", - required: true, - }, - { - name: "clientSecret", - label: "Client Secret", - type: "secretText", - required: true, - }, - { - name: "loginUrl", - label: "Login URL", - type: "shortText", - required: false, - description: - "Override for sandbox (https://test.salesforce.com) or custom domains. Defaults to https://login.salesforce.com.", - }, - ], - }, -}; diff --git a/integrations/salesforce/src/index.ts b/integrations/salesforce/src/index.ts deleted file mode 100644 index ab6db7f6..00000000 --- a/integrations/salesforce/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./auth/config"; diff --git a/integrations/salesforce/tsconfig.json b/integrations/salesforce/tsconfig.json deleted file mode 100644 index bea0d6e6..00000000 --- a/integrations/salesforce/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" - }, - "include": [ - "src/**/*" - ], - "references": [ - { - "path": "../../packages/connections" - } - ] -} \ No newline at end of file diff --git a/packages/connections/src/connectivity/provider-registry.ts b/packages/connections/src/connectivity/provider-registry.ts index 06f5a841..ccb9a7a3 100644 --- a/packages/connections/src/connectivity/provider-registry.ts +++ b/packages/connections/src/connectivity/provider-registry.ts @@ -1,44 +1,26 @@ -import { Injectable, Inject } from '@nestjs/common'; -import { providers } from '@nexiom/database'; -import { eq, and, InferSelectModel } from 'drizzle-orm'; -import { DrizzleDb } from './types.js'; +import { Injectable } from '@nestjs/common'; +import { PROVIDER_REGISTRY } from './providers/index.js'; +import type { ProviderDefinition } from './types.js'; /** - * Database-backed provider registry. - * Replaces the former static ALLOWED_PROVIDERS Set with a queryable catalog - * so providers can be added/disabled without code changes. + * Code-first provider registry. + * Provider definitions live in packages/connections/src/connectivity/providers/ + * — no database queries required. Add new providers to PROVIDER_REGISTRY. */ @Injectable() export class ProviderRegistryService { - constructor( - @Inject('DRIZZLE_DB') private readonly db: DrizzleDb, - ) { } - /** Check whether a provider exists and is enabled. */ - async isAllowed(name: string): Promise { - const row = await this.db - .select({ name: providers.name }) - .from(providers) - .where(and(eq(providers.name, name), eq(providers.enabled, true))) - .limit(1); - return row.length > 0; + isAllowed(name: string): boolean { + return name in PROVIDER_REGISTRY; } /** Retrieve full provider configuration (returns null if not found). */ - async getProvider(name: string): Promise | null> { - const rows = await this.db - .select() - .from(providers) - .where(eq(providers.name, name)) - .limit(1); - return rows[0] ?? null; + getProvider(name: string): ProviderDefinition | null { + return PROVIDER_REGISTRY[name] ?? null; } - /** List all enabled providers. */ - async getAllProviders(): Promise[]> { - return await this.db - .select() - .from(providers) - .where(eq(providers.enabled, true)); + /** List all registered providers. */ + getAllProviders(): ProviderDefinition[] { + return Object.values(PROVIDER_REGISTRY); } } diff --git a/packages/connections/src/connectivity/providers/index.ts b/packages/connections/src/connectivity/providers/index.ts new file mode 100644 index 00000000..22d2633e --- /dev/null +++ b/packages/connections/src/connectivity/providers/index.ts @@ -0,0 +1,18 @@ +import { ProviderDefinition } from '../types.js'; +import { salesforceProvider } from './salesforce.provider.js'; +import { quickbooksProvider } from './quickbooks.provider.js'; + +/** + * Central in-memory provider registry — add new providers here. + * Auth configs are derived from activepieces-reference pieces. + * API action code (createLead, createInvoice, etc.) lives in the + * integrations/* packages and is a separate concern. + */ +export const PROVIDER_REGISTRY: Record = { + salesforce: salesforceProvider, + quickbooks: quickbooksProvider, +}; + +export { salesforceProvider } from './salesforce.provider.js'; +export { quickbooksProvider } from './quickbooks.provider.js'; +export type { ProviderDefinition } from '../types.js'; diff --git a/packages/connections/src/connectivity/providers/quickbooks.provider.ts b/packages/connections/src/connectivity/providers/quickbooks.provider.ts new file mode 100644 index 00000000..5c4c1203 --- /dev/null +++ b/packages/connections/src/connectivity/providers/quickbooks.provider.ts @@ -0,0 +1,17 @@ +import { ProviderDefinition } from '../types.js'; + +/** + * QuickBooks Online OAuth2 auth config. + * Source: activepieces-reference/packages/pieces/community/quickbooks/src/index.ts + */ +export const quickbooksProvider: ProviderDefinition = { + name: 'quickbooks', + displayName: 'QuickBooks Online', + description: 'Accounting software for small and medium businesses', + logoUrl: 'https://cdn.activepieces.com/pieces/quickbooks.png', + category: 'Accounting', + authType: 'OAUTH2', + authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + scopes: ['com.intuit.quickbooks.accounting'], +}; diff --git a/packages/connections/src/connectivity/providers/salesforce.provider.ts b/packages/connections/src/connectivity/providers/salesforce.provider.ts new file mode 100644 index 00000000..a85e6503 --- /dev/null +++ b/packages/connections/src/connectivity/providers/salesforce.provider.ts @@ -0,0 +1,17 @@ +import { ProviderDefinition } from '../types.js'; + +/** + * Salesforce OAuth2 auth config. + * Source: activepieces-reference/packages/pieces/community/salesforce/src/index.ts + */ +export const salesforceProvider: ProviderDefinition = { + name: 'salesforce', + displayName: 'Salesforce', + description: 'CRM software solutions and enterprise cloud computing', + logoUrl: 'https://cdn.activepieces.com/pieces/salesforce.png', + category: 'CRM', + authType: 'OAUTH2', + authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', + tokenUrl: 'https://login.salesforce.com/services/oauth2/token', + scopes: ['refresh_token', 'full', 'api'], +}; diff --git a/packages/connections/src/connectivity/types.ts b/packages/connections/src/connectivity/types.ts index d8ac9872..5f6c1387 100644 --- a/packages/connections/src/connectivity/types.ts +++ b/packages/connections/src/connectivity/types.ts @@ -1,34 +1,28 @@ -export interface ConnectorAuthSchema { - type: 'object'; - properties: Array<{ - name: string; - label: string; - type: 'shortText' | 'secretText' | 'dropdown' | 'oauth2'; - required: boolean; - description?: string; - options?: Array<{ label: string; value: string }>; - }>; -} +import { db } from '@nexiom/database'; -interface BaseCredentialType { - name: string; - uiSchema?: ConnectorAuthSchema; -} +/** The actual inferred type of the Drizzle Postgres client. */ +export type DrizzleDb = typeof db; + +/** Auth types supported by Nexiom providers. */ +export type AuthType = 'OAUTH2' | 'API_KEY' | 'BASIC'; -/** Shape of OAuth config as stored in the `providers` DB table. */ -export interface OAuthConfig { +/** + * Code-first provider definition — the single source of truth for + * all provider OAuth configuration. No DB table required. + * Auth configs are derived from activepieces-reference pieces. + */ +export interface ProviderDefinition { + /** Unique slug used as the key in PROVIDER_REGISTRY and stored in app_credential.app_name */ + name: string; + displayName: string; + description: string; + logoUrl: string; + category: string; + authType: AuthType; + /** OAuth2 Authorization endpoint */ authorizeUrl: string; + /** OAuth2 Token exchange endpoint */ tokenUrl: string; - scopes?: string[]; + /** OAuth2 scopes requested during authorization */ + scopes: string[]; } - -// GenericCredentialType defines integration-package seed data. -// OAuth URLs live in the providers table, not in integration configs. -export type GenericCredentialType = - | (BaseCredentialType & { authType: 'OAUTH2' }) - | (BaseCredentialType & { authType: 'API_KEY' }); - -import { db } from '@nexiom/database'; - -/** The actual inferred type of the Drizzle Postgres client. */ -export type DrizzleDb = typeof db; diff --git a/packages/database/drizzle/0000_unique_sharon_carter.sql b/packages/database/drizzle/0000_unique_sharon_carter.sql deleted file mode 100644 index bc7f65de..00000000 --- a/packages/database/drizzle/0000_unique_sharon_carter.sql +++ /dev/null @@ -1,41 +0,0 @@ -CREATE TYPE "public"."auth_type_enum" AS ENUM('OAUTH2', 'API_KEY', 'BASIC');--> statement-breakpoint -CREATE TYPE "public"."connection_status_enum" AS ENUM('ACTIVE', 'INACTIVE', 'REVOKED', 'EXPIRED');--> statement-breakpoint -CREATE TABLE "provider" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" varchar(100) NOT NULL, - "display_name" varchar(255) NOT NULL, - "auth_type" "auth_type_enum" NOT NULL, - "authorize_url" text, - "token_url" text, - "scopes" jsonb DEFAULT '[]'::jsonb, - "ui_schema" jsonb DEFAULT '{}'::jsonb, - "description" text, - "logo_url" varchar(255), - "category" varchar(100), - "enabled" boolean DEFAULT true NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "provider_name_unique" UNIQUE("name"), - CONSTRAINT "oauth_check" CHECK (auth_type != 'OAUTH2' OR (authorize_url IS NOT NULL AND token_url IS NOT NULL)) -); ---> statement-breakpoint -CREATE TABLE "app_connection" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "tenant_id" uuid NOT NULL, - "provider_id" uuid NOT NULL, - "app_name" varchar(100) NOT NULL, - "auth_type" "auth_type_enum" NOT NULL, - "encrypted_credentials" text NOT NULL, - "expires_at" timestamp with time zone, - "status" "connection_status_enum" DEFAULT 'ACTIVE' NOT NULL, - "metadata" jsonb DEFAULT '{}'::jsonb, - "connection_key" varchar(255) DEFAULT 'default' NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE INDEX "app_name_idx" ON "app_connection" USING btree ("app_name");--> statement-breakpoint -CREATE INDEX "tenant_status_idx" ON "app_connection" USING btree ("tenant_id", "status");--> statement-breakpoint -CREATE UNIQUE INDEX "tenant_app_connection_unique_idx" ON "app_connection" USING btree ("tenant_id","app_name","connection_key");--> statement-breakpoint -ALTER TABLE "app_connection" ADD CONSTRAINT "fk_app_connection_tenant_id" FOREIGN KEY ("tenant_id") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;--> statement-breakpoint -ALTER TABLE "app_connection" ADD CONSTRAINT "fk_app_connection_provider_id" FOREIGN KEY ("provider_id") REFERENCES "provider"("id") ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/packages/database/drizzle/0002_careless_gideon.sql b/packages/database/drizzle/0002_careless_gideon.sql deleted file mode 100644 index 91380466..00000000 --- a/packages/database/drizzle/0002_careless_gideon.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX "tenant_status_idx" ON "app_connection" USING btree ("tenant_id","status"); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0000_snapshot.json b/packages/database/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 770f6c25..00000000 --- a/packages/database/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,270 +0,0 @@ -{ - "id": "19ffc4c8-87d0-4a0a-97d0-efe3317b9b17", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.provider": { - "name": "provider", - "schema": "", - "columns": { - "name": { - "name": "name", - "type": "varchar(100)", - "primaryKey": true, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "auth_type": { - "name": "auth_type", - "type": "auth_type_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "authorize_url": { - "name": "authorize_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "token_url": { - "name": "token_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scopes": { - "name": "scopes", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'[]'::jsonb" - }, - "ui_schema": { - "name": "ui_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo_url": { - "name": "logo_url", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "category": { - "name": "category", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.app_connection": { - "name": "app_connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "tenant_id": { - "name": "tenant_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "app_name": { - "name": "app_name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "auth_type": { - "name": "auth_type", - "type": "auth_type_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "encrypted_credentials": { - "name": "encrypted_credentials", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true, - "default": "'ACTIVE'" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "connection_key": { - "name": "connection_key", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "default": "'default'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "app_name_idx": { - "name": "app_name_idx", - "columns": [ - { - "expression": "app_name", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "status_idx": { - "name": "status_idx", - "columns": [ - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tenant_app_connection_unique_idx": { - "name": "tenant_app_connection_unique_idx", - "columns": [ - { - "expression": "tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "app_name", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "connection_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.auth_type_enum": { - "name": "auth_type_enum", - "schema": "public", - "values": [ - "OAUTH2", - "API_KEY", - "BASIC" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/database/drizzle/meta/0001_snapshot.json b/packages/database/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 3e60f9ec..00000000 --- a/packages/database/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,306 +0,0 @@ -{ - "id": "aa66e49d-cc93-44fe-9716-6cea02309655", - "prevId": "19ffc4c8-87d0-4a0a-97d0-efe3317b9b17", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.provider": { - "name": "provider", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "auth_type": { - "name": "auth_type", - "type": "auth_type_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "authorize_url": { - "name": "authorize_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "token_url": { - "name": "token_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scopes": { - "name": "scopes", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'[]'::jsonb" - }, - "ui_schema": { - "name": "ui_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo_url": { - "name": "logo_url", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "category": { - "name": "category", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "provider_name_unique": { - "name": "provider_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": { - "oauth_check": { - "name": "oauth_check", - "value": "auth_type != 'OAUTH2' OR (authorize_url IS NOT NULL AND token_url IS NOT NULL)" - } - }, - "isRLSEnabled": false - }, - "public.app_connection": { - "name": "app_connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "tenant_id": { - "name": "tenant_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "app_name": { - "name": "app_name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "auth_type": { - "name": "auth_type", - "type": "auth_type_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "encrypted_credentials": { - "name": "encrypted_credentials", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "connection_status_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'ACTIVE'" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "connection_key": { - "name": "connection_key", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "default": "'default'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "app_name_idx": { - "name": "app_name_idx", - "columns": [ - { - "expression": "app_name", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tenant_app_connection_unique_idx": { - "name": "tenant_app_connection_unique_idx", - "columns": [ - { - "expression": "tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "app_name", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "connection_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "app_connection_provider_id_provider_id_fk": { - "name": "app_connection_provider_id_provider_id_fk", - "tableFrom": "app_connection", - "tableTo": "provider", - "columnsFrom": [ - "provider_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.auth_type_enum": { - "name": "auth_type_enum", - "schema": "public", - "values": [ - "OAUTH2", - "API_KEY", - "BASIC" - ] - }, - "public.connection_status_enum": { - "name": "connection_status_enum", - "schema": "public", - "values": [ - "ACTIVE", - "INACTIVE", - "REVOKED", - "EXPIRED" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/database/drizzle/meta/0002_snapshot.json b/packages/database/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 9c72d937..00000000 --- a/packages/database/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,327 +0,0 @@ -{ - "id": "cffda3ca-c5d8-48eb-99dc-64ac9ef855c5", - "prevId": "aa66e49d-cc93-44fe-9716-6cea02309655", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.provider": { - "name": "provider", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "auth_type": { - "name": "auth_type", - "type": "auth_type_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "authorize_url": { - "name": "authorize_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "token_url": { - "name": "token_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scopes": { - "name": "scopes", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'[]'::jsonb" - }, - "ui_schema": { - "name": "ui_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo_url": { - "name": "logo_url", - "type": "varchar(255)", - "primaryKey": false, - "notNull": false - }, - "category": { - "name": "category", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, - "enabled": { - "name": "enabled", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "provider_name_unique": { - "name": "provider_name_unique", - "nullsNotDistinct": false, - "columns": [ - "name" - ] - } - }, - "policies": {}, - "checkConstraints": { - "oauth_check": { - "name": "oauth_check", - "value": "auth_type != 'OAUTH2' OR (authorize_url IS NOT NULL AND token_url IS NOT NULL)" - } - }, - "isRLSEnabled": false - }, - "public.app_connection": { - "name": "app_connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "tenant_id": { - "name": "tenant_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "app_name": { - "name": "app_name", - "type": "varchar(100)", - "primaryKey": false, - "notNull": true - }, - "auth_type": { - "name": "auth_type", - "type": "auth_type_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "encrypted_credentials": { - "name": "encrypted_credentials", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "connection_status_enum", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'ACTIVE'" - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false, - "default": "'{}'::jsonb" - }, - "connection_key": { - "name": "connection_key", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "default": "'default'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "app_name_idx": { - "name": "app_name_idx", - "columns": [ - { - "expression": "app_name", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tenant_status_idx": { - "name": "tenant_status_idx", - "columns": [ - { - "expression": "tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tenant_app_connection_unique_idx": { - "name": "tenant_app_connection_unique_idx", - "columns": [ - { - "expression": "tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "app_name", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "connection_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "app_connection_provider_id_provider_id_fk": { - "name": "app_connection_provider_id_provider_id_fk", - "tableFrom": "app_connection", - "tableTo": "provider", - "columnsFrom": [ - "provider_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.auth_type_enum": { - "name": "auth_type_enum", - "schema": "public", - "values": [ - "OAUTH2", - "API_KEY", - "BASIC" - ] - }, - "public.connection_status_enum": { - "name": "connection_status_enum", - "schema": "public", - "values": [ - "ACTIVE", - "INACTIVE", - "REVOKED", - "EXPIRED" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json deleted file mode 100644 index e2b2f30f..00000000 --- a/packages/database/drizzle/meta/_journal.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1771606824699, - "tag": "0000_unique_sharon_carter", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1771666235430, - "tag": "0001_jazzy_wild_child", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1771668071280, - "tag": "0002_careless_gideon", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/packages/database/push-schema-local.ts b/packages/database/push-schema-local.ts new file mode 100644 index 00000000..ce3282ac --- /dev/null +++ b/packages/database/push-schema-local.ts @@ -0,0 +1,48 @@ +import { Client } from 'pg'; + +async function main() { + const client = new Client({ + connectionString: 'postgres://user:password@localhost:5432/nexiom_local' + }); + + await client.connect(); + console.log('Connected to PG'); + + const query = ` + CREATE TABLE IF NOT EXISTS "app_credential" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "provider_id" uuid NOT NULL, + "app_name" varchar(100) NOT NULL, + "client_id" text NOT NULL, + "encrypted_client_secret" text NOT NULL, + "setup_metadata" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL + ); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'app_credential_provider_id_provider_id_fk' + ) THEN + ALTER TABLE "app_credential" ADD CONSTRAINT "app_credential_provider_id_provider_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."provider"("id") ON DELETE restrict ON UPDATE no action; + END IF; + END $$; + + CREATE UNIQUE INDEX IF NOT EXISTS "tenant_app_credential_unique_idx" ON "app_credential" USING btree ("tenant_id","app_name"); + `; + + try { + await client.query(query); + console.log('Successfully created app_credential table and constraints'); + } catch (e) { + console.error('Error executing query', e); + } finally { + await client.end(); + } +} + +main(); diff --git a/packages/database/push-schema.ts b/packages/database/push-schema.ts new file mode 100644 index 00000000..ff7ab13d --- /dev/null +++ b/packages/database/push-schema.ts @@ -0,0 +1,48 @@ +import { Client } from 'pg'; + +async function main() { + const client = new Client({ + connectionString: 'postgres://admin:password123@localhost:5432/nexiom_master' + }); + + await client.connect(); + console.log('Connected to PG'); + + const query = ` + CREATE TABLE IF NOT EXISTS "app_credential" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tenant_id" uuid NOT NULL, + "provider_id" uuid NOT NULL, + "app_name" varchar(100) NOT NULL, + "client_id" text NOT NULL, + "encrypted_client_secret" text NOT NULL, + "setup_metadata" jsonb DEFAULT '{}'::jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL + ); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_name = 'app_credential_provider_id_provider_id_fk' + ) THEN + ALTER TABLE "app_credential" ADD CONSTRAINT "app_credential_provider_id_provider_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."provider"("id") ON DELETE restrict ON UPDATE no action; + END IF; + END $$; + + CREATE UNIQUE INDEX IF NOT EXISTS "tenant_app_credential_unique_idx" ON "app_credential" USING btree ("tenant_id","app_name"); + `; + + try { + await client.query(query); + console.log('Successfully created app_credential table and constraints'); + } catch (e) { + console.error('Error executing query', e); + } finally { + await client.end(); + } +} + +main(); diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 786f6b94..6e478403 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -1,9 +1,8 @@ import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import * as tenantSchema from './schema/tenant'; -import * as providerSchema from './schema/provider'; -const schemaBundle = { ...tenantSchema, ...providerSchema }; +const schemaBundle = { ...tenantSchema }; type DbSchema = typeof schemaBundle; let pool: Pool | undefined; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index e8348068..2513c7ef 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,3 +1,4 @@ export * from './schema/tenant'; -export * from './schema/provider'; + +export * from './schema/app-credential'; export * from './client'; diff --git a/packages/database/src/schema/app-credential.ts b/packages/database/src/schema/app-credential.ts new file mode 100644 index 00000000..5e6f1a60 --- /dev/null +++ b/packages/database/src/schema/app-credential.ts @@ -0,0 +1,23 @@ +import { pgTable, uuid, varchar, text, jsonb, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; + +// Stores the BYOA (Bring Your Own App) OAuth credentials for a tenant +export const appCredentials = pgTable('app_credential', { + id: uuid('id').defaultRandom().primaryKey(), + tenantId: uuid('tenant_id').notNull(), + appName: varchar('app_name', { length: 100 }).notNull(), + + // The OAuth Client ID + clientId: text('client_id').notNull(), + + // The OAuth Client Secret (Encrypted via EncryptionService) + encryptedClientSecret: text('encrypted_client_secret').notNull(), + + // Any extra fields required by the vendor (e.g., custom domains) + setupMetadata: jsonb('setup_metadata').default({}), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => [ + // A tenant can only have one set of global BYOA credentials per application + uniqueIndex('tenant_app_credential_unique_idx').on(table.tenantId, table.appName), +]); diff --git a/packages/database/src/schema/provider.ts b/packages/database/src/schema/provider.ts deleted file mode 100644 index 27f7cbca..00000000 --- a/packages/database/src/schema/provider.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { pgTable, uuid, varchar, text, jsonb, boolean, timestamp, pgEnum, check } from 'drizzle-orm/pg-core'; -import { sql } from 'drizzle-orm'; - -export const authTypeEnum = pgEnum('auth_type_enum', ['OAUTH2', 'API_KEY', 'BASIC']); -/** - * Provider catalog — stores configuration for all supported integration providers. - * The frontend reads this to render connection UIs; the backend reads it for - * OAuth URL resolution and provider validation. - */ -export const providers = pgTable('provider', { - id: uuid('id').defaultRandom().primaryKey(), - name: varchar('name', { length: 100 }).unique().notNull(), // 'salesforce', 'quickbooks' - displayName: varchar('display_name', { length: 255 }).notNull(), - authType: authTypeEnum('auth_type').notNull(), // 'OAUTH2', 'API_KEY', 'BASIC' - - // OAuth configuration (null for non-OAuth providers) - authorizeUrl: text('authorize_url'), - tokenUrl: text('token_url'), - scopes: jsonb('scopes').$type().default([]), - // Dynamic form schema served to the frontend - uiSchema: jsonb('ui_schema').$type>().default({}), - - // Frontend UI metadata for the Marketplace app directory - description: text('description'), - logoUrl: varchar('logo_url', { length: 255 }), - category: varchar('category', { length: 100 }), - - // Soft-toggle: disable a provider without removing its row - enabled: boolean('enabled').default(true).notNull(), - - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull().$onUpdate(() => new Date()), -}, (table) => [ - check('oauth_check', sql`auth_type != 'OAUTH2' OR (authorize_url IS NOT NULL AND token_url IS NOT NULL)`), -]); diff --git a/packages/database/src/schema/tenant.ts b/packages/database/src/schema/tenant.ts index 24c62338..bfd895a4 100644 --- a/packages/database/src/schema/tenant.ts +++ b/packages/database/src/schema/tenant.ts @@ -1,5 +1,7 @@ import { pgTable, uuid, varchar, text, timestamp, jsonb, index, uniqueIndex, pgEnum } from 'drizzle-orm/pg-core'; -import { authTypeEnum, providers } from './provider'; + +// Auth type enum — previously in provider.ts, now inlined here since the provider table is dropped +export const authTypeEnum = pgEnum('auth_type_enum', ['OAUTH2', 'API_KEY', 'BASIC']); // Tenants table and associated identity schema are physically isolated per tenant or live in a separate DB. // Drizzle foreign keys pointing to "organization" are handled directly in raw migrations (0000_...sql) @@ -18,8 +20,7 @@ export type AppConnectionStatus = (typeof AppConnectionStatus)[keyof typeof AppC export const appConnections = pgTable('app_connection', { id: uuid('id').defaultRandom().primaryKey(), tenantId: uuid('tenant_id').notNull(), // Uses organization(id) in SQL migrations - providerId: uuid('provider_id').references(() => providers.id, { onDelete: 'restrict', onUpdate: 'cascade' }).notNull(), - appName: varchar('app_name', { length: 100 }).notNull(), // Denormalized 'quickbooks' + appName: varchar('app_name', { length: 100 }).notNull(), // 'salesforce', 'quickbooks' — validated against PROVIDER_REGISTRY in code authType: authTypeEnum('auth_type').notNull(), // 'OAUTH2', 'API_KEY', 'BASIC' // Encrypted Payload (Contains access_token, refresh_token, or api_key) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bc2b59fa..f8f575a1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - 'apps/*' - 'packages/*' - - 'integrations/*' + From 31b1a580042aeeeca5c3ed90e85e48dee1678c77 Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 23 Feb 2026 18:52:52 +0530 Subject: [PATCH 2/6] auth cycle refactor: coderabbit 1st round code review --- .../connections/callback.controller.spec.ts | 2 +- .../connections/callback.controller.ts | 4 +- .../connections/connectors.controller.spec.ts | 12 +- .../connections/token-refresh.service.spec.ts | 56 +++++++-- .../connections/token-refresh.service.ts | 27 +++-- .../connections/connectors.service.spec.ts | 9 +- .../modules/connections/connectors.service.ts | 114 +++++++++--------- .../src/connectivity/provider-registry.ts | 7 +- .../src/connectivity/providers/index.ts | 4 +- .../providers/quickbooks.provider.ts | 4 +- .../src/connectivity/token-manager.service.ts | 3 +- .../connections/src/connectivity/types.ts | 26 ++-- packages/database/push-schema-local.ts | 48 -------- packages/database/push-schema.ts | 48 -------- packages/database/src/index.ts | 6 +- .../database/src/schema/app-credential.ts | 9 +- packages/database/src/schema/tenant.ts | 4 + packages/database/src/utils/tenant-guard.ts | 20 +++ pnpm-workspace.yaml | 1 - 19 files changed, 205 insertions(+), 199 deletions(-) delete mode 100644 packages/database/push-schema-local.ts delete mode 100644 packages/database/push-schema.ts create mode 100644 packages/database/src/utils/tenant-guard.ts diff --git a/apps/api/src/modules/connections/connections/callback.controller.spec.ts b/apps/api/src/modules/connections/connections/callback.controller.spec.ts index 1b4e4d5b..795a496f 100644 --- a/apps/api/src/modules/connections/connections/callback.controller.spec.ts +++ b/apps/api/src/modules/connections/connections/callback.controller.spec.ts @@ -154,7 +154,7 @@ describe('OAuthCallbackController', () => { it('should redirect with internal_error if provider lookup fails', async () => { mockProviderRegistry.getProvider.mockImplementation(() => { - throw new Error('DB error'); + throw new Error('Unexpected registry error'); }); const req = mockRequest('salesforce'); diff --git a/apps/api/src/modules/connections/connections/callback.controller.ts b/apps/api/src/modules/connections/connections/callback.controller.ts index 4c50ecb9..cccbd3e1 100644 --- a/apps/api/src/modules/connections/connections/callback.controller.ts +++ b/apps/api/src/modules/connections/connections/callback.controller.ts @@ -200,7 +200,7 @@ export class OAuthCallbackController { private async persistConnection( provider: string, - _providerData: ProviderDefinition, + providerData: ProviderDefinition, tenantId: string, stateRealmId: string | undefined, tokenResponse: Record, @@ -255,7 +255,7 @@ export class OAuthCallbackController { tenantId, appName: provider, connectionKey, - authType: 'OAUTH2', + authType: providerData.authType, encryptedCredentials: encryptedPayload, expiresAt: expiresAt, metadata: { realmId: stateRealmId }, diff --git a/apps/api/src/modules/connections/connections/connectors.controller.spec.ts b/apps/api/src/modules/connections/connections/connectors.controller.spec.ts index 3a6b4be0..85a49e6b 100644 --- a/apps/api/src/modules/connections/connections/connectors.controller.spec.ts +++ b/apps/api/src/modules/connections/connections/connectors.controller.spec.ts @@ -173,12 +173,16 @@ describe('ConnectorsController', () => { it('should bubble up InternalServerErrorException from the provider registry', () => { mockProviderRegistry.getAllProviders.mockImplementation(() => { - throw new Error('DB connection failed'); + throw new Error('Registry initialization error'); }); - const action = () => controller.getProviders(); - expect(action).toThrow(InternalServerErrorException); - expect(action).toThrow('Failed to get providers'); + try { + controller.getProviders(); + expect.unreachable('Should have thrown an exception'); + } catch (error) { + expect(error).toBeInstanceOf(InternalServerErrorException); + expect((error as Error).message).toContain('Failed to get providers'); + } }); }); diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts index 06a59bd1..49c80a01 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts @@ -17,13 +17,23 @@ import { describe('DefaultOAuthRefreshClient', () => { let client: DefaultOAuthRefreshClient; let mockProviderRegistry: Mocked>; + let mockConnectorsService: { + fetchAppCredential: Mock; + decryptClientSecret: Mock; + }; beforeEach(() => { mockProviderRegistry = { getProvider: vi.fn(), }; + mockConnectorsService = { + fetchAppCredential: vi.fn(), + decryptClientSecret: vi.fn(), + }; + client = new DefaultOAuthRefreshClient( mockProviderRegistry as ProviderRegistryService, + mockConnectorsService as any, ); // Mock the global fetch @@ -42,25 +52,27 @@ describe('DefaultOAuthRefreshClient', () => { it('should throw an error if the provider is not found in the registry', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue(null); - await expect(client.refresh('unknown_app', 'refresh123')).rejects.toThrow( - 'Provider not found for refresh: unknown_app', - ); + await expect( + client.refresh('testTenant', 'unknown_app', 'refresh123'), + ).rejects.toThrow('Provider not found for refresh: unknown_app'); }); it('should throw an error if the provider lacks a tokenUrl', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue({ - name: 'quickbooks', - displayName: 'QuickBooks Online', - description: 'Accounting', + name: 'salesforce', + displayName: 'Salesforce', + description: 'CRM', logoUrl: '', - category: 'Accounting', + category: 'CRM', authType: 'OAUTH2' as const, - authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', + authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', tokenUrl: '', scopes: [], }); - await expect(client.refresh('salesforce', 'refresh123')).rejects.toThrow( + await expect( + client.refresh('testTenant', 'salesforce', 'refresh123'), + ).rejects.toThrow( 'Provider salesforce does not support OAuth refresh or lacks a token url', ); }); @@ -68,9 +80,18 @@ describe('DefaultOAuthRefreshClient', () => { it('should successfully call the vendor token URL and return the new mapped payload', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', + authType: 'OAUTH2', tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', } as unknown as ProviderDefinition); + mockConnectorsService.fetchAppCredential.mockResolvedValue({ + clientId: 'mock-client-id', + encryptedClientSecret: 'mock-encrypted-secret', + }); + mockConnectorsService.decryptClientSecret.mockResolvedValue( + 'mock-decrypted-secret', + ); + const mockResponsePayload = { access_token: 'new_access', refresh_token: 'new_refresh', @@ -82,7 +103,11 @@ describe('DefaultOAuthRefreshClient', () => { json: () => Promise.resolve(mockResponsePayload), }); - const result = await client.refresh('quickbooks', 'old_refresh'); + const result = await client.refresh( + 'testTenant', + 'quickbooks', + 'old_refresh', + ); expect(globalThis.fetch).toHaveBeenCalledWith( 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', @@ -94,9 +119,18 @@ describe('DefaultOAuthRefreshClient', () => { it('should throw an error containing the status code if the vendor rejects the refresh', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', + authType: 'OAUTH2', tokenUrl: 'https://oauth.url', } as unknown as ProviderDefinition); + mockConnectorsService.fetchAppCredential.mockResolvedValue({ + clientId: 'mock-client-id', + encryptedClientSecret: 'mock-encrypted-secret', + }); + mockConnectorsService.decryptClientSecret.mockResolvedValue( + 'mock-decrypted-secret', + ); + (globalThis.fetch as Mock).mockResolvedValue({ ok: false, status: 401, @@ -104,7 +138,7 @@ describe('DefaultOAuthRefreshClient', () => { }); await expect( - client.refresh('quickbooks', 'bad_refresh'), + client.refresh('testTenant', 'quickbooks', 'bad_refresh'), ).rejects.toMatchObject({ message: expect.stringContaining('OAuth Refresh failed: 401') as unknown, status: 401, diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.ts b/apps/api/src/modules/connections/connections/token-refresh.service.ts index f46a464a..086b1eee 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.ts @@ -3,14 +3,19 @@ import { OAuthRefreshClient, ProviderRegistryService, } from '@nexiom/connections'; +import { ConnectorsService } from '../connectors.service'; @Injectable() export class DefaultOAuthRefreshClient implements OAuthRefreshClient { private readonly logger = new Logger(DefaultOAuthRefreshClient.name); - constructor(private readonly providerRegistry: ProviderRegistryService) {} + constructor( + private readonly providerRegistry: ProviderRegistryService, + private readonly connectorsService: ConnectorsService, + ) {} async refresh( + tenantId: string, appName: string, refreshToken: string, ): Promise> { @@ -19,22 +24,24 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { throw new Error(`Provider not found for refresh: ${appName}`); } - if (!provider.tokenUrl) { + if (provider.authType !== 'OAUTH2' || !provider.tokenUrl) { throw new Error( `Provider ${appName} does not support OAuth refresh or lacks a token url`, ); } try { - const normalizedEnvName = appName - .replace(/[^A-Za-z0-9]/g, '_') - .toUpperCase(); - const clientId = process.env[`${normalizedEnvName}_CLIENT_ID`]; - const clientSecret = process.env[`${normalizedEnvName}_CLIENT_SECRET`]; + const credential = await this.connectorsService.fetchAppCredential( + tenantId, + appName, + ); - if (!clientId || !clientSecret) { - throw new Error(`Missing OAuth client credentials for ${appName}`); - } + const clientId = credential.clientId; + const clientSecret = await this.connectorsService.decryptClientSecret( + credential.encryptedClientSecret, + appName, + tenantId, + ); const response = await fetch(provider.tokenUrl, { method: 'POST', diff --git a/apps/api/src/modules/connections/connectors.service.spec.ts b/apps/api/src/modules/connections/connectors.service.spec.ts index 991cab03..afa0c7af 100644 --- a/apps/api/src/modules/connections/connectors.service.spec.ts +++ b/apps/api/src/modules/connections/connectors.service.spec.ts @@ -88,6 +88,7 @@ describe('ConnectorsService', () => { it('should generate a valid OAuth authorization URL with state and scopes', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', + authType: 'OAUTH2', authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', scopes: ['api', 'refresh_token'], } as unknown as NonNullable); @@ -122,6 +123,7 @@ describe('ConnectorsService', () => { it('should throw NotFoundException if credential is missing in db', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', + authType: 'OAUTH2', authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', } as unknown as NonNullable); @@ -162,6 +164,7 @@ describe('ConnectorsService', () => { it('should throw NotFoundException if credentials are missing in db', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', + authType: 'OAUTH2', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', } as unknown as NonNullable); @@ -175,6 +178,7 @@ describe('ConnectorsService', () => { it('should throw InternalServerErrorException if decryption fails', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', + authType: 'OAUTH2', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', } as unknown as NonNullable); @@ -190,8 +194,11 @@ describe('ConnectorsService', () => { it('should successfully exchange a code for tokens', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', + authType: 'OAUTH2', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - } as ProviderResult); + } as unknown as NonNullable< + ReturnType + >); const mockTokens = { access_token: 'abc', refresh_token: 'def' }; vi.mocked(fetch).mockResolvedValue({ diff --git a/apps/api/src/modules/connections/connectors.service.ts b/apps/api/src/modules/connections/connectors.service.ts index 32c1f58c..c1afb86a 100644 --- a/apps/api/src/modules/connections/connectors.service.ts +++ b/apps/api/src/modules/connections/connectors.service.ts @@ -12,8 +12,8 @@ import { DrizzleDb, EncryptionService, } from '@nexiom/connections'; -import { appCredentials } from '@nexiom/database'; -import { eq, and } from 'drizzle-orm'; +import { appCredentials, withTenantGuard } from '@nexiom/database'; +import { eq } from 'drizzle-orm'; @Injectable() export class ConnectorsService { @@ -32,6 +32,52 @@ export class ConnectorsService { return `${baseUrl}/api/connect/${providerName}/callback`; } + /** + * Fetches the OAuth app credential for a given tenant and provider. + * Extracts the database lookup logic into a shared helper to ensure RLS-like + * tenant isolation is consistently applied at the application layer. + */ + async fetchAppCredential(tenantId: string, providerName: string) { + const [credential] = await this.db + .select() + .from(appCredentials) + .where( + withTenantGuard( + appCredentials.tenantId, + tenantId, + eq(appCredentials.appName, providerName), + ), + ); + + if (!credential) { + this.logger.error( + `Missing OAuth app credential for ${providerName} on tenant ${tenantId}`, + ); + throw new NotFoundException( + `Platform administrator has not configured ${providerName} integration.`, + ); + } + + return credential; + } + + async decryptClientSecret( + encryptedSecret: string, + providerName: string, + tenantId: string, + ): Promise { + try { + return await this.crypto.decrypt(encryptedSecret); + } catch { + this.logger.error( + `Failed to decrypt client secret for ${providerName} on tenant ${tenantId}`, + ); + throw new InternalServerErrorException( + 'Invalid connector configuration.', + ); + } + } + /** * Generates the fully qualified Authorization URL for the vendor. * Redirects the user's browser to this URL to start the OAuth flow. @@ -53,34 +99,17 @@ export class ConnectorsService { ); } - if (!provider.authorizeUrl) { + if (provider.authType !== 'OAUTH2' || !provider.authorizeUrl) { this.logger.error( - `Provider ${providerName} does not have an authorizeUrl defined.`, + `Provider ${providerName} missing authorizeUrl or not an OAuth provider.`, ); throw new InternalServerErrorException( - `Provider ${providerName} configuration is incomplete.`, + `Provider ${providerName} configuration is incomplete for OAuth.`, ); } // Fetch tenant's BYOA credentials - const [credential] = await this.db - .select() - .from(appCredentials) - .where( - and( - eq(appCredentials.tenantId, tenantId), - eq(appCredentials.appName, providerName), - ), - ); - - if (!credential) { - this.logger.error( - `Missing OAuth app credential for ${providerName} on tenant ${tenantId}`, - ); - throw new NotFoundException( - `Platform administrator has not configured ${providerName} integration.`, - ); - } + const credential = await this.fetchAppCredential(tenantId, providerName); const clientId = credential.clientId; @@ -123,7 +152,7 @@ export class ConnectorsService { ); } - if (!provider.tokenUrl) { + if (provider.authType !== 'OAUTH2' || !provider.tokenUrl) { this.logger.error( `Provider ${providerName} does not have a tokenUrl defined.`, ); @@ -133,40 +162,15 @@ export class ConnectorsService { } // Fetch tenant's BYOA credentials - const [credential] = await this.db - .select() - .from(appCredentials) - .where( - and( - eq(appCredentials.tenantId, tenantId), - eq(appCredentials.appName, providerName), - ), - ); - - if (!credential) { - this.logger.error( - `Missing OAuth app credential for ${providerName} on tenant ${tenantId}`, - ); - throw new NotFoundException( - `Platform administrator has not configured ${providerName} integration.`, - ); - } + const credential = await this.fetchAppCredential(tenantId, providerName); const clientId = credential.clientId; - let clientSecret: string; - try { - clientSecret = await this.crypto.decrypt( - credential.encryptedClientSecret, - ); - } catch { - this.logger.error( - `Failed to decrypt client secret for ${providerName} on tenant ${tenantId}`, - ); - throw new InternalServerErrorException( - 'Invalid connector configuration.', - ); - } + const clientSecret = await this.decryptClientSecret( + credential.encryptedClientSecret, + providerName, + tenantId, + ); const redirectUri = this.buildRedirectUri(providerName); diff --git a/packages/connections/src/connectivity/provider-registry.ts b/packages/connections/src/connectivity/provider-registry.ts index ccb9a7a3..3104ed50 100644 --- a/packages/connections/src/connectivity/provider-registry.ts +++ b/packages/connections/src/connectivity/provider-registry.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { PROVIDER_REGISTRY } from './providers/index.js'; +import { PROVIDER_REGISTRY, type ProviderName } from './providers/index.js'; import type { ProviderDefinition } from './types.js'; /** @@ -11,12 +11,13 @@ import type { ProviderDefinition } from './types.js'; export class ProviderRegistryService { /** Check whether a provider exists and is enabled. */ isAllowed(name: string): boolean { - return name in PROVIDER_REGISTRY; + return Object.hasOwn(PROVIDER_REGISTRY, name); } /** Retrieve full provider configuration (returns null if not found). */ getProvider(name: string): ProviderDefinition | null { - return PROVIDER_REGISTRY[name] ?? null; + if (!this.isAllowed(name)) return null; + return PROVIDER_REGISTRY[name as ProviderName]; } /** List all registered providers. */ diff --git a/packages/connections/src/connectivity/providers/index.ts b/packages/connections/src/connectivity/providers/index.ts index 22d2633e..3273ed41 100644 --- a/packages/connections/src/connectivity/providers/index.ts +++ b/packages/connections/src/connectivity/providers/index.ts @@ -2,13 +2,15 @@ import { ProviderDefinition } from '../types.js'; import { salesforceProvider } from './salesforce.provider.js'; import { quickbooksProvider } from './quickbooks.provider.js'; +export type ProviderName = 'salesforce' | 'quickbooks'; + /** * Central in-memory provider registry — add new providers here. * Auth configs are derived from activepieces-reference pieces. * API action code (createLead, createInvoice, etc.) lives in the * integrations/* packages and is a separate concern. */ -export const PROVIDER_REGISTRY: Record = { +export const PROVIDER_REGISTRY: Record = { salesforce: salesforceProvider, quickbooks: quickbooksProvider, }; diff --git a/packages/connections/src/connectivity/providers/quickbooks.provider.ts b/packages/connections/src/connectivity/providers/quickbooks.provider.ts index 5c4c1203..901f93b8 100644 --- a/packages/connections/src/connectivity/providers/quickbooks.provider.ts +++ b/packages/connections/src/connectivity/providers/quickbooks.provider.ts @@ -8,10 +8,10 @@ export const quickbooksProvider: ProviderDefinition = { name: 'quickbooks', displayName: 'QuickBooks Online', description: 'Accounting software for small and medium businesses', - logoUrl: 'https://cdn.activepieces.com/pieces/quickbooks.png', + logoUrl: '/images/providers/quickbooks.png', category: 'Accounting', authType: 'OAUTH2', authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', - scopes: ['com.intuit.quickbooks.accounting'], + scopes: ['com.intuit.quickbooks.accounting', 'offline_access'], }; diff --git a/packages/connections/src/connectivity/token-manager.service.ts b/packages/connections/src/connectivity/token-manager.service.ts index a9cdf843..e03a61eb 100644 --- a/packages/connections/src/connectivity/token-manager.service.ts +++ b/packages/connections/src/connectivity/token-manager.service.ts @@ -18,7 +18,7 @@ export class OAuthRefreshError extends Error { } export abstract class OAuthRefreshClient { - abstract refresh(appName: string, refreshToken: string): Promise>; + abstract refresh(tenantId: string, appName: string, refreshToken: string): Promise>; } function parseExpiresAt(value: unknown): Date | null { @@ -156,6 +156,7 @@ export class TokenManagerService implements OnModuleDestroy { // 2. Perform HTTP call to Vendor API const newTokens = await this.oauthClient.refresh( + connection.tenantId as string, connection.appName as string, oldPayload.refreshToken as string, ); diff --git a/packages/connections/src/connectivity/types.ts b/packages/connections/src/connectivity/types.ts index 5f6c1387..99a90b04 100644 --- a/packages/connections/src/connectivity/types.ts +++ b/packages/connections/src/connectivity/types.ts @@ -6,19 +6,17 @@ export type DrizzleDb = typeof db; /** Auth types supported by Nexiom providers. */ export type AuthType = 'OAUTH2' | 'API_KEY' | 'BASIC'; -/** - * Code-first provider definition — the single source of truth for - * all provider OAuth configuration. No DB table required. - * Auth configs are derived from activepieces-reference pieces. - */ -export interface ProviderDefinition { +interface BaseProviderDefinition { /** Unique slug used as the key in PROVIDER_REGISTRY and stored in app_credential.app_name */ name: string; displayName: string; description: string; logoUrl: string; category: string; - authType: AuthType; +} + +export interface OAuth2Provider extends BaseProviderDefinition { + authType: 'OAUTH2'; /** OAuth2 Authorization endpoint */ authorizeUrl: string; /** OAuth2 Token exchange endpoint */ @@ -26,3 +24,17 @@ export interface ProviderDefinition { /** OAuth2 scopes requested during authorization */ scopes: string[]; } + +export interface ApiKeyProvider extends BaseProviderDefinition { + authType: 'API_KEY'; +} + +export interface BasicProvider extends BaseProviderDefinition { + authType: 'BASIC'; +} + +/** + * Code-first provider definition — the single source of truth for + * all provider OAuth configuration. No DB table required. + */ +export type ProviderDefinition = OAuth2Provider | ApiKeyProvider | BasicProvider; diff --git a/packages/database/push-schema-local.ts b/packages/database/push-schema-local.ts deleted file mode 100644 index ce3282ac..00000000 --- a/packages/database/push-schema-local.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Client } from 'pg'; - -async function main() { - const client = new Client({ - connectionString: 'postgres://user:password@localhost:5432/nexiom_local' - }); - - await client.connect(); - console.log('Connected to PG'); - - const query = ` - CREATE TABLE IF NOT EXISTS "app_credential" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "tenant_id" uuid NOT NULL, - "provider_id" uuid NOT NULL, - "app_name" varchar(100) NOT NULL, - "client_id" text NOT NULL, - "encrypted_client_secret" text NOT NULL, - "setup_metadata" jsonb DEFAULT '{}'::jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL - ); - - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.table_constraints - WHERE constraint_name = 'app_credential_provider_id_provider_id_fk' - ) THEN - ALTER TABLE "app_credential" ADD CONSTRAINT "app_credential_provider_id_provider_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."provider"("id") ON DELETE restrict ON UPDATE no action; - END IF; - END $$; - - CREATE UNIQUE INDEX IF NOT EXISTS "tenant_app_credential_unique_idx" ON "app_credential" USING btree ("tenant_id","app_name"); - `; - - try { - await client.query(query); - console.log('Successfully created app_credential table and constraints'); - } catch (e) { - console.error('Error executing query', e); - } finally { - await client.end(); - } -} - -main(); diff --git a/packages/database/push-schema.ts b/packages/database/push-schema.ts deleted file mode 100644 index ff7ab13d..00000000 --- a/packages/database/push-schema.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Client } from 'pg'; - -async function main() { - const client = new Client({ - connectionString: 'postgres://admin:password123@localhost:5432/nexiom_master' - }); - - await client.connect(); - console.log('Connected to PG'); - - const query = ` - CREATE TABLE IF NOT EXISTS "app_credential" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "tenant_id" uuid NOT NULL, - "provider_id" uuid NOT NULL, - "app_name" varchar(100) NOT NULL, - "client_id" text NOT NULL, - "encrypted_client_secret" text NOT NULL, - "setup_metadata" jsonb DEFAULT '{}'::jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL - ); - - DO $$ - BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM information_schema.table_constraints - WHERE constraint_name = 'app_credential_provider_id_provider_id_fk' - ) THEN - ALTER TABLE "app_credential" ADD CONSTRAINT "app_credential_provider_id_provider_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."provider"("id") ON DELETE restrict ON UPDATE no action; - END IF; - END $$; - - CREATE UNIQUE INDEX IF NOT EXISTS "tenant_app_credential_unique_idx" ON "app_credential" USING btree ("tenant_id","app_name"); - `; - - try { - await client.query(query); - console.log('Successfully created app_credential table and constraints'); - } catch (e) { - console.error('Error executing query', e); - } finally { - await client.end(); - } -} - -main(); diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 2513c7ef..77a354e6 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,4 +1,4 @@ -export * from './schema/tenant'; - -export * from './schema/app-credential'; export * from './client'; +export * from './schema/app-credential'; +export * from './schema/tenant'; +export * from './utils/tenant-guard'; diff --git a/packages/database/src/schema/app-credential.ts b/packages/database/src/schema/app-credential.ts index 5e6f1a60..47c87946 100644 --- a/packages/database/src/schema/app-credential.ts +++ b/packages/database/src/schema/app-credential.ts @@ -1,4 +1,5 @@ -import { pgTable, uuid, varchar, text, jsonb, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, varchar, text, jsonb, timestamp, uniqueIndex, foreignKey } from 'drizzle-orm/pg-core'; +import { tenants } from './tenant'; // Stores the BYOA (Bring Your Own App) OAuth credentials for a tenant export const appCredentials = pgTable('app_credential', { @@ -20,4 +21,10 @@ export const appCredentials = pgTable('app_credential', { }, (table) => [ // A tenant can only have one set of global BYOA credentials per application uniqueIndex('tenant_app_credential_unique_idx').on(table.tenantId, table.appName), + // Cascade deletes if a tenant is removed + foreignKey({ + columns: [table.tenantId], + foreignColumns: [tenants.id], + name: 'app_credential_tenant_id_tenants_id_fk' + }).onDelete('cascade'), ]); diff --git a/packages/database/src/schema/tenant.ts b/packages/database/src/schema/tenant.ts index bfd895a4..d00363d6 100644 --- a/packages/database/src/schema/tenant.ts +++ b/packages/database/src/schema/tenant.ts @@ -7,6 +7,10 @@ export const authTypeEnum = pgEnum('auth_type_enum', ['OAUTH2', 'API_KEY', 'BASI // Drizzle foreign keys pointing to "organization" are handled directly in raw migrations (0000_...sql) // rather than strict drizzle-orm foreignKey() constraints here to allow cross-database resolution. +export const tenants = pgTable('tenant', { + id: uuid('id').primaryKey(), +}); + export const connectionStatusEnum = pgEnum('connection_status_enum', ['ACTIVE', 'INACTIVE', 'REVOKED', 'EXPIRED']); export const AppConnectionStatus = { diff --git a/packages/database/src/utils/tenant-guard.ts b/packages/database/src/utils/tenant-guard.ts new file mode 100644 index 00000000..57bc06d6 --- /dev/null +++ b/packages/database/src/utils/tenant-guard.ts @@ -0,0 +1,20 @@ +import { eq, and, SQL, AnyColumn } from 'drizzle-orm'; + +/** + * Short-term Drizzle query wrapper safeguard. + * Injects the required tenantId filter to emulate application-layer Row-Level Security (RLS). + * Rejects queries where a tenantId is missing. + */ +export function withTenantGuard( + tenantColumn: AnyColumn, + tenantId: string, + extraConditions?: SQL +): SQL { + if (!tenantId) { + throw new Error('FATAL: Attempted multi-tenant database access without a valid tenantId filter.'); + } + + const tenantFilter = eq(tenantColumn, tenantId); + + return extraConditions ? and(tenantFilter, extraConditions)! : tenantFilter; +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f8f575a1..e9b0dad6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,3 @@ packages: - 'apps/*' - 'packages/*' - From d72547faf9d7f7cea01311bd76c1f7efe1d4a42b Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 23 Feb 2026 19:29:10 +0530 Subject: [PATCH 3/6] auth cycle refactor: coderabbit 2nd round code review --- .../connections/connectors.controller.spec.ts | 2 -- .../connections/token-refresh.service.spec.ts | 28 ++++++++++------- .../connections/token-refresh.service.ts | 30 ++++++++++++------- .../connections/connectors.service.spec.ts | 2 ++ .../src/connectivity/token-manager.service.ts | 6 +++- .../connections/src/connectivity/types.ts | 5 +--- packages/database/src/client.ts | 2 ++ .../database/src/schema/app-credential.ts | 2 +- 8 files changed, 48 insertions(+), 29 deletions(-) diff --git a/apps/api/src/modules/connections/connections/connectors.controller.spec.ts b/apps/api/src/modules/connections/connections/connectors.controller.spec.ts index 85a49e6b..8b8deb45 100644 --- a/apps/api/src/modules/connections/connections/connectors.controller.spec.ts +++ b/apps/api/src/modules/connections/connections/connectors.controller.spec.ts @@ -148,8 +148,6 @@ describe('ConnectorsController', () => { uiSchema: {}, authorizeUrl: '', tokenUrl: '', - createdAt: new Date(), - updatedAt: new Date(), }, ]); diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts index 49c80a01..be994bf3 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts @@ -1,8 +1,6 @@ import { DefaultOAuthRefreshClient } from './token-refresh.service'; -import { - ProviderRegistryService, - ProviderDefinition, -} from '@nexiom/connections'; +import { ProviderRegistryService } from '@nexiom/connections'; +import { ConnectorsService } from '../connectors.service'; import { describe, it, @@ -33,15 +31,11 @@ describe('DefaultOAuthRefreshClient', () => { client = new DefaultOAuthRefreshClient( mockProviderRegistry as ProviderRegistryService, - mockConnectorsService as any, + mockConnectorsService as unknown as ConnectorsService, ); // Mock the global fetch vi.stubGlobal('fetch', vi.fn()); - - // Stub environment variables for Quickbooks (used in tests) - vi.stubEnv('QUICKBOOKS_CLIENT_ID', 'test_client_id'); - vi.stubEnv('QUICKBOOKS_CLIENT_SECRET', 'test_client_secret'); }); afterEach(() => { @@ -80,9 +74,15 @@ describe('DefaultOAuthRefreshClient', () => { it('should successfully call the vendor token URL and return the new mapped payload', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', + displayName: 'QuickBooks', + description: 'Accounting', + logoUrl: '', + category: 'Accounting', authType: 'OAUTH2', + authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', - } as unknown as ProviderDefinition); + scopes: ['com.intuit.quickbooks.accounting'], + }); mockConnectorsService.fetchAppCredential.mockResolvedValue({ clientId: 'mock-client-id', @@ -119,9 +119,15 @@ describe('DefaultOAuthRefreshClient', () => { it('should throw an error containing the status code if the vendor rejects the refresh', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', + displayName: 'QuickBooks', + description: 'Accounting', + logoUrl: '', + category: 'Accounting', authType: 'OAUTH2', + authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', tokenUrl: 'https://oauth.url', - } as unknown as ProviderDefinition); + scopes: ['com.intuit.quickbooks.accounting'], + }); mockConnectorsService.fetchAppCredential.mockResolvedValue({ clientId: 'mock-client-id', diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.ts b/apps/api/src/modules/connections/connections/token-refresh.service.ts index 086b1eee..fe801d62 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.ts @@ -31,17 +31,27 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { } try { - const credential = await this.connectorsService.fetchAppCredential( - tenantId, - appName, - ); + let credential; + let clientId: string; + let clientSecret: string; - const clientId = credential.clientId; - const clientSecret = await this.connectorsService.decryptClientSecret( - credential.encryptedClientSecret, - appName, - tenantId, - ); + try { + credential = await this.connectorsService.fetchAppCredential( + tenantId, + appName, + ); + + clientId = credential.clientId; + clientSecret = await this.connectorsService.decryptClientSecret( + credential.encryptedClientSecret, + appName, + tenantId, + ); + } catch (error) { + throw new Error( + `Failed to retrieve app credential for tenantId/appName: ${error instanceof Error ? error.message : String(error)}`, + ); + } const response = await fetch(provider.tokenUrl, { method: 'POST', diff --git a/apps/api/src/modules/connections/connectors.service.spec.ts b/apps/api/src/modules/connections/connectors.service.spec.ts index afa0c7af..4abb2206 100644 --- a/apps/api/src/modules/connections/connectors.service.spec.ts +++ b/apps/api/src/modules/connections/connectors.service.spec.ts @@ -229,6 +229,7 @@ describe('ConnectorsService', () => { it('should throw InternalServerErrorException if the token exchange fails', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', + authType: 'OAUTH2', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', } as unknown as NonNullable); @@ -246,6 +247,7 @@ describe('ConnectorsService', () => { it('should throw InternalServerErrorException on network/timeout errors', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', + authType: 'OAUTH2', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', } as unknown as NonNullable); diff --git a/packages/connections/src/connectivity/token-manager.service.ts b/packages/connections/src/connectivity/token-manager.service.ts index e03a61eb..90811570 100644 --- a/packages/connections/src/connectivity/token-manager.service.ts +++ b/packages/connections/src/connectivity/token-manager.service.ts @@ -154,9 +154,13 @@ export class TokenManagerService implements OnModuleDestroy { throw new Error('No refresh token available'); } + if (typeof connection.tenantId !== 'string') { + throw new TypeError('Invalid connection: tenantId is missing or not a string'); + } + // 2. Perform HTTP call to Vendor API const newTokens = await this.oauthClient.refresh( - connection.tenantId as string, + connection.tenantId, connection.appName as string, oldPayload.refreshToken as string, ); diff --git a/packages/connections/src/connectivity/types.ts b/packages/connections/src/connectivity/types.ts index 99a90b04..5c612f94 100644 --- a/packages/connections/src/connectivity/types.ts +++ b/packages/connections/src/connectivity/types.ts @@ -1,7 +1,4 @@ -import { db } from '@nexiom/database'; - -/** The actual inferred type of the Drizzle Postgres client. */ -export type DrizzleDb = typeof db; +export type { DrizzleDb } from '@nexiom/database'; /** Auth types supported by Nexiom providers. */ export type AuthType = 'OAUTH2' | 'API_KEY' | 'BASIC'; diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 6e478403..78c51a5f 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -5,6 +5,8 @@ import * as tenantSchema from './schema/tenant'; const schemaBundle = { ...tenantSchema }; type DbSchema = typeof schemaBundle; +export type DrizzleDb = NodePgDatabase; + let pool: Pool | undefined; let dbInstance: NodePgDatabase | undefined; diff --git a/packages/database/src/schema/app-credential.ts b/packages/database/src/schema/app-credential.ts index 47c87946..9ac69ab4 100644 --- a/packages/database/src/schema/app-credential.ts +++ b/packages/database/src/schema/app-credential.ts @@ -17,7 +17,7 @@ export const appCredentials = pgTable('app_credential', { setupMetadata: jsonb('setup_metadata').default({}), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull().$onUpdate(() => new Date()), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => [ // A tenant can only have one set of global BYOA credentials per application uniqueIndex('tenant_app_credential_unique_idx').on(table.tenantId, table.appName), From 3d5f54a833aae38959f5e89d4375f275092c9782 Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 23 Feb 2026 20:28:28 +0530 Subject: [PATCH 4/6] auth cycle refactor: coderabbit 3rd round review --- .../connections/callback.controller.ts | 3 +-- .../connections/connectors.controller.ts | 8 ++++-- .../connections/token-refresh.service.spec.ts | 25 ++++++++++++++++++- .../connections/token-refresh.service.ts | 5 ++-- .../connections/connectors.service.spec.ts | 4 +-- .../modules/connections/connectors.service.ts | 7 ++++-- .../src/connectivity/token-manager.service.ts | 6 ++--- .../connections/src/connectivity/types.ts | 2 -- packages/database/src/client.ts | 12 +++++---- 9 files changed, 49 insertions(+), 23 deletions(-) diff --git a/apps/api/src/modules/connections/connections/callback.controller.ts b/apps/api/src/modules/connections/connections/callback.controller.ts index cccbd3e1..d8270d7c 100644 --- a/apps/api/src/modules/connections/connections/callback.controller.ts +++ b/apps/api/src/modules/connections/connections/callback.controller.ts @@ -1,10 +1,9 @@ import { Controller, Get, Req, Res, Logger, Inject } from '@nestjs/common'; import { Request, Response } from 'express'; -import { appConnections } from '@nexiom/database'; +import { appConnections, type DrizzleDb } from '@nexiom/database'; import { EncryptionService, ProviderRegistryService, - DrizzleDb, type ProviderDefinition, } from '@nexiom/connections'; diff --git a/apps/api/src/modules/connections/connections/connectors.controller.ts b/apps/api/src/modules/connections/connections/connectors.controller.ts index 0fd4097f..d5c9a944 100644 --- a/apps/api/src/modules/connections/connections/connectors.controller.ts +++ b/apps/api/src/modules/connections/connections/connectors.controller.ts @@ -14,10 +14,14 @@ import { HttpException, } from '@nestjs/common'; import { Request, Response } from 'express'; -import { ProviderRegistryService, DrizzleDb } from '@nexiom/connections'; +import { ProviderRegistryService } from '@nexiom/connections'; import { ConnectorsService } from '../connectors.service'; import { OauthStateService } from '../oauth-state.service'; -import { appConnections, AppConnectionStatus } from '@nexiom/database'; +import { + appConnections, + AppConnectionStatus, + type DrizzleDb, +} from '@nexiom/database'; import { eq, and, count } from 'drizzle-orm'; import { AuthGuard } from '../../identity/auth/auth.guard'; diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts index be994bf3..bcb6290a 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts @@ -41,7 +41,6 @@ describe('DefaultOAuthRefreshClient', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - vi.unstubAllEnvs(); }); it('should throw an error if the provider is not found in the registry', async () => { @@ -116,6 +115,30 @@ describe('DefaultOAuthRefreshClient', () => { expect(result).toEqual(mockResponsePayload); }); + it('should throw an error with specific message if credential retrieval fails', async () => { + (mockProviderRegistry.getProvider as Mock).mockReturnValue({ + name: 'quickbooks', + displayName: 'QuickBooks', + description: 'Accounting', + logoUrl: '', + category: 'Accounting', + authType: 'OAUTH2', + authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', + tokenUrl: 'https://oauth.url', + scopes: ['com.intuit.quickbooks.accounting'], + }); + + mockConnectorsService.fetchAppCredential.mockRejectedValue( + new Error('Credential not found'), + ); + + await expect( + client.refresh('testTenant', 'quickbooks', 'refresh123'), + ).rejects.toThrow( + 'Failed to retrieve app credential for tenantId/appName: Credential not found', + ); + }); + it('should throw an error containing the status code if the vendor rejects the refresh', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.ts b/apps/api/src/modules/connections/connections/token-refresh.service.ts index fe801d62..3f590b8d 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.ts @@ -31,12 +31,11 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { } try { - let credential; let clientId: string; let clientSecret: string; try { - credential = await this.connectorsService.fetchAppCredential( + const credential = await this.connectorsService.fetchAppCredential( tenantId, appName, ); @@ -47,7 +46,7 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { appName, tenantId, ); - } catch (error) { + } catch (error: unknown) { throw new Error( `Failed to retrieve app credential for tenantId/appName: ${error instanceof Error ? error.message : String(error)}`, ); diff --git a/apps/api/src/modules/connections/connectors.service.spec.ts b/apps/api/src/modules/connections/connectors.service.spec.ts index 4abb2206..b8d71e53 100644 --- a/apps/api/src/modules/connections/connectors.service.spec.ts +++ b/apps/api/src/modules/connections/connectors.service.spec.ts @@ -196,9 +196,7 @@ describe('ConnectorsService', () => { name: 'salesforce', authType: 'OAUTH2', tokenUrl: 'https://login.salesforce.com/services/oauth2/token', - } as unknown as NonNullable< - ReturnType - >); + } as unknown as NonNullable); const mockTokens = { access_token: 'abc', refresh_token: 'def' }; vi.mocked(fetch).mockResolvedValue({ diff --git a/apps/api/src/modules/connections/connectors.service.ts b/apps/api/src/modules/connections/connectors.service.ts index c1afb86a..da740fdb 100644 --- a/apps/api/src/modules/connections/connectors.service.ts +++ b/apps/api/src/modules/connections/connectors.service.ts @@ -9,10 +9,13 @@ import { import { ConfigService } from '@nestjs/config'; import { ProviderRegistryService, - DrizzleDb, EncryptionService, } from '@nexiom/connections'; -import { appCredentials, withTenantGuard } from '@nexiom/database'; +import { + appCredentials, + withTenantGuard, + type DrizzleDb, +} from '@nexiom/database'; import { eq } from 'drizzle-orm'; @Injectable() diff --git a/packages/connections/src/connectivity/token-manager.service.ts b/packages/connections/src/connectivity/token-manager.service.ts index 90811570..9f15564f 100644 --- a/packages/connections/src/connectivity/token-manager.service.ts +++ b/packages/connections/src/connectivity/token-manager.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, Inject, OnModuleDestroy } from '@nestjs/common'; import { appConnections } from '@nexiom/database'; import { eq } from 'drizzle-orm'; import Redis from 'ioredis'; -import { DrizzleDb } from './types.js'; +import type { DrizzleDb } from '@nexiom/database'; // Abstract contracts — consumers must provide real implementations via DI export abstract class EncryptionService { @@ -154,8 +154,8 @@ export class TokenManagerService implements OnModuleDestroy { throw new Error('No refresh token available'); } - if (typeof connection.tenantId !== 'string') { - throw new TypeError('Invalid connection: tenantId is missing or not a string'); + if (typeof connection.tenantId !== 'string' || !connection.tenantId.trim()) { + throw new TypeError('Invalid connection: tenantId is missing, empty or not a string'); } // 2. Perform HTTP call to Vendor API diff --git a/packages/connections/src/connectivity/types.ts b/packages/connections/src/connectivity/types.ts index 5c612f94..2d5b6e17 100644 --- a/packages/connections/src/connectivity/types.ts +++ b/packages/connections/src/connectivity/types.ts @@ -1,5 +1,3 @@ -export type { DrizzleDb } from '@nexiom/database'; - /** Auth types supported by Nexiom providers. */ export type AuthType = 'OAUTH2' | 'API_KEY' | 'BASIC'; diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 78c51a5f..c52d3287 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -1,16 +1,18 @@ import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import * as tenantSchema from './schema/tenant'; +import * as appCredentialSchema from './schema/app-credential'; -const schemaBundle = { ...tenantSchema }; +const schemaBundle = { ...tenantSchema, ...appCredentialSchema }; type DbSchema = typeof schemaBundle; -export type DrizzleDb = NodePgDatabase; - let pool: Pool | undefined; -let dbInstance: NodePgDatabase | undefined; +let tempDbInstance: ReturnType | undefined; + +export type DrizzleDb = ReturnType>; +let dbInstance: DrizzleDb | undefined; -export function getDb(): NodePgDatabase { +export function getDb(): DrizzleDb { if (dbInstance) return dbInstance; if (!process.env.DATABASE_URL) { From 5544a1a392a738fa23c3b5238bcdff6836fd2c1d Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 23 Feb 2026 21:11:36 +0530 Subject: [PATCH 5/6] auth cycle refactor: coderabbit 4th round review --- .../connections/token-refresh.service.spec.ts | 2 +- .../connections/token-refresh.service.ts | 9 ++- .../connections/connectors.service.spec.ts | 64 +++++++++++++------ .../modules/connections/connectors.service.ts | 16 ++++- packages/database/src/client.ts | 7 +- 5 files changed, 70 insertions(+), 28 deletions(-) diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts index bcb6290a..9832c433 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts @@ -163,7 +163,7 @@ describe('DefaultOAuthRefreshClient', () => { (globalThis.fetch as Mock).mockResolvedValue({ ok: false, status: 401, - text: () => Promise.resolve('Unauthorized'), + statusText: 'Unauthorized', }); await expect( diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.ts b/apps/api/src/modules/connections/connections/token-refresh.service.ts index 3f590b8d..e8e26b2e 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.ts @@ -3,6 +3,7 @@ import { OAuthRefreshClient, ProviderRegistryService, } from '@nexiom/connections'; +import { OAuthRefreshError } from '@nexiom/connections/dist/connectivity/token-manager.service'; import { ConnectorsService } from '../connectors.service'; @Injectable() @@ -40,6 +41,10 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { appName, ); + if (!credential) { + throw new Error(`Credential not found for ${appName}`); + } + clientId = credential.clientId; clientSecret = await this.connectorsService.decryptClientSecret( credential.encryptedClientSecret, @@ -65,9 +70,9 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { }); if (!response.ok) { - const err = new Error( + const err = new OAuthRefreshError( `OAuth Refresh failed: ${response.status} ${response.statusText || ''}`.trim(), - ) as Error & { status: number }; + ) as OAuthRefreshError & { status: number }; err.status = response.status; throw err; } diff --git a/apps/api/src/modules/connections/connectors.service.spec.ts b/apps/api/src/modules/connections/connectors.service.spec.ts index b8d71e53..cf0e1411 100644 --- a/apps/api/src/modules/connections/connectors.service.spec.ts +++ b/apps/api/src/modules/connections/connectors.service.spec.ts @@ -20,6 +20,10 @@ import { Mocked, } from 'vitest'; +const safeStringify = (obj: unknown): string => + JSON.stringify(obj, (key: string, value: unknown) => + key === 'table' ? undefined : value, + ); type ProviderResult = ReturnType; describe('ConnectorsService', () => { @@ -85,32 +89,37 @@ describe('ConnectorsService', () => { ).rejects.toThrow(BadRequestException); }); - it('should generate a valid OAuth authorization URL with state and scopes', async () => { + it('should generate a valid OAuth URL with scopes', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', authType: 'OAUTH2', authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', - scopes: ['api', 'refresh_token'], + scopes: ['full', 'refresh_token'], } as unknown as NonNullable); - const urlString = await service.getAuthorizationUrl( + const result = await service.getAuthorizationUrl( 'salesforce', - 'mocked_jwt_state', + 'random-state-123', testTenantId, ); - const parsedUrl = new URL(urlString); - expect(parsedUrl.origin).toBe('https://login.salesforce.com'); - expect(parsedUrl.pathname).toBe('/services/oauth2/authorize'); - - const searchParams = parsedUrl.searchParams; - expect(searchParams.get('response_type')).toBe('code'); - expect(searchParams.get('client_id')).toBe('test-client-id'); - expect(searchParams.get('state')).toBe('mocked_jwt_state'); - expect(searchParams.get('scope')).toBe('api refresh_token'); - expect(searchParams.get('redirect_uri')).toBe( + const url = new URL(result); + expect(url.origin).toBe('https://login.salesforce.com'); + expect(url.pathname).toBe('/services/oauth2/authorize'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('client_id')).toBe('test-client-id'); + expect(url.searchParams.get('state')).toBe('random-state-123'); + expect(url.searchParams.get('scope')).toBe('full refresh_token'); + expect(url.searchParams.get('redirect_uri')).toBe( 'https://tenant.nexiom.app/api/connect/salesforce/callback', ); + + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDbWhere).toHaveBeenCalledWith(expect.any(Object)); + const whereArg = mockDbWhere.mock.calls[0]?.[0] as unknown; + // Asserting Drizzle ORM's shape loosely, omitting table to prevent circular JSON errors + expect(safeStringify(whereArg)).toContain('tenant_id'); }); it('should throw NotFoundException if provider does not exist', async () => { @@ -182,9 +191,9 @@ describe('ConnectorsService', () => { tokenUrl: 'https://login.salesforce.com/services/oauth2/token', } as unknown as NonNullable); - mockEncryptionService.decrypt.mockImplementation(() => { - throw new Error('decryption failed'); - }); + mockEncryptionService.decrypt.mockRejectedValue( + new Error('decryption failed'), + ); await expect( service.exchangeCodeForTokens('salesforce', 'auth-code', testTenantId), @@ -211,17 +220,36 @@ describe('ConnectorsService', () => { ); expect(result).toEqual(mockTokens); + + // Verify tenant isolation DB call shape + expect(mockDbWhere).toHaveBeenCalledWith(expect.any(Object)); + const whereArg = mockDbWhere.mock.calls[0]?.[0] as unknown; + expect(safeStringify(whereArg)).toContain('tenant_id'); + + // Verify the decryption is used based off retrieved DB row + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockEncryptionService.decrypt).toHaveBeenCalledWith( + 'encrypted-secret', + ); + + // Validate fetch call payload expect(fetch).toHaveBeenCalledWith( 'https://login.salesforce.com/services/oauth2/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - body: expect.stringContaining('grant_type=authorization_code'), + body: expect.any(String), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment signal: expect.any(AbortSignal), }, ); + + const fetchCallArgs = vi.mocked(fetch).mock.calls[0]; + const fetchBody = fetchCallArgs?.[1]?.body as string; + expect(fetchBody).toContain('grant_type=authorization_code'); + expect(fetchBody).toContain('client_id=test-client-id'); + expect(fetchBody).toContain('client_secret=test-client-secret'); }); it('should throw InternalServerErrorException if the token exchange fails', async () => { diff --git a/apps/api/src/modules/connections/connectors.service.ts b/apps/api/src/modules/connections/connectors.service.ts index da740fdb..7794678f 100644 --- a/apps/api/src/modules/connections/connectors.service.ts +++ b/apps/api/src/modules/connections/connectors.service.ts @@ -56,9 +56,7 @@ export class ConnectorsService { this.logger.error( `Missing OAuth app credential for ${providerName} on tenant ${tenantId}`, ); - throw new NotFoundException( - `Platform administrator has not configured ${providerName} integration.`, - ); + return null; } return credential; @@ -114,6 +112,12 @@ export class ConnectorsService { // Fetch tenant's BYOA credentials const credential = await this.fetchAppCredential(tenantId, providerName); + if (!credential) { + throw new NotFoundException( + `Platform administrator has not configured ${providerName} integration.`, + ); + } + const clientId = credential.clientId; const url = new URL(provider.authorizeUrl); @@ -167,6 +171,12 @@ export class ConnectorsService { // Fetch tenant's BYOA credentials const credential = await this.fetchAppCredential(tenantId, providerName); + if (!credential) { + throw new NotFoundException( + `Platform administrator has not configured ${providerName} integration.`, + ); + } + const clientId = credential.clientId; const clientSecret = await this.decryptClientSecret( diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index c52d3287..5e20cd1d 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -1,4 +1,4 @@ -import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import * as tenantSchema from './schema/tenant'; import * as appCredentialSchema from './schema/app-credential'; @@ -7,7 +7,6 @@ const schemaBundle = { ...tenantSchema, ...appCredentialSchema }; type DbSchema = typeof schemaBundle; let pool: Pool | undefined; -let tempDbInstance: ReturnType | undefined; export type DrizzleDb = ReturnType>; let dbInstance: DrizzleDb | undefined; @@ -35,9 +34,9 @@ export function getDb(): DrizzleDb { // For backwards compatibility where `db` was used directly, we can define a proxy // that initializes the DB on the first query. -export const db = new Proxy({} as NodePgDatabase, { +export const db = new Proxy({} as DrizzleDb, { get(_target, prop) { - return getDb()[prop as keyof NodePgDatabase]; + return getDb()[prop as keyof DrizzleDb]; } }); From a7136f512edef1b974bdf87fa507ee3d81c3d252 Mon Sep 17 00:00:00 2001 From: Pramod Date: Mon, 23 Feb 2026 22:12:14 +0530 Subject: [PATCH 6/6] auth cycle refactor: coderabbit 5th round code review --- .../connections/token-refresh.service.spec.ts | 29 ++++++++++ .../connections/token-refresh.service.ts | 9 ++-- .../connections/connectors.service.spec.ts | 53 ++++++++++++++++++- .../modules/connections/connectors.service.ts | 10 ++-- .../src/connectivity/token-manager.service.ts | 7 +++ 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts index 9832c433..ff6ac4c0 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.spec.ts @@ -139,6 +139,35 @@ describe('DefaultOAuthRefreshClient', () => { ); }); + it('should throw an error with specific message if credential decryption fails', async () => { + (mockProviderRegistry.getProvider as Mock).mockReturnValue({ + name: 'quickbooks', + displayName: 'QuickBooks', + description: 'Accounting', + logoUrl: '', + category: 'Accounting', + authType: 'OAUTH2', + authorizeUrl: 'https://appcenter.intuit.com/connect/oauth2', + tokenUrl: 'https://oauth.url', + scopes: ['com.intuit.quickbooks.accounting'], + }); + + mockConnectorsService.fetchAppCredential.mockResolvedValue({ + clientId: 'mock-client-id', + encryptedClientSecret: 'mock-encrypted-secret', + }); + + mockConnectorsService.decryptClientSecret.mockRejectedValue( + new Error('decryption failed'), + ); + + await expect( + client.refresh('testTenant', 'quickbooks', 'refresh123'), + ).rejects.toThrow( + 'Failed to retrieve app credential for tenantId/appName: decryption failed', + ); + }); + it('should throw an error containing the status code if the vendor rejects the refresh', async () => { (mockProviderRegistry.getProvider as Mock).mockReturnValue({ name: 'quickbooks', diff --git a/apps/api/src/modules/connections/connections/token-refresh.service.ts b/apps/api/src/modules/connections/connections/token-refresh.service.ts index e8e26b2e..68a95b39 100644 --- a/apps/api/src/modules/connections/connections/token-refresh.service.ts +++ b/apps/api/src/modules/connections/connections/token-refresh.service.ts @@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { OAuthRefreshClient, ProviderRegistryService, + OAuthRefreshError, } from '@nexiom/connections'; -import { OAuthRefreshError } from '@nexiom/connections/dist/connectivity/token-manager.service'; import { ConnectorsService } from '../connectors.service'; @Injectable() @@ -70,11 +70,10 @@ export class DefaultOAuthRefreshClient implements OAuthRefreshClient { }); if (!response.ok) { - const err = new OAuthRefreshError( + throw new OAuthRefreshError( `OAuth Refresh failed: ${response.status} ${response.statusText || ''}`.trim(), - ) as OAuthRefreshError & { status: number }; - err.status = response.status; - throw err; + response.status, + ); } return (await response.json()) as Record; diff --git a/apps/api/src/modules/connections/connectors.service.spec.ts b/apps/api/src/modules/connections/connectors.service.spec.ts index cf0e1411..1077980d 100644 --- a/apps/api/src/modules/connections/connectors.service.spec.ts +++ b/apps/api/src/modules/connections/connectors.service.spec.ts @@ -4,6 +4,7 @@ import { ConnectorsService } from './connectors.service'; import { ProviderRegistryService, EncryptionService, + AppCredentialError, } from '@nexiom/connections'; import { InternalServerErrorException, @@ -120,6 +121,7 @@ describe('ConnectorsService', () => { const whereArg = mockDbWhere.mock.calls[0]?.[0] as unknown; // Asserting Drizzle ORM's shape loosely, omitting table to prevent circular JSON errors expect(safeStringify(whereArg)).toContain('tenant_id'); + expect(safeStringify(whereArg)).toContain(testTenantId); }); it('should throw NotFoundException if provider does not exist', async () => { @@ -142,6 +144,29 @@ describe('ConnectorsService', () => { service.getAuthorizationUrl('salesforce', 'state', testTenantId), ).rejects.toThrow(NotFoundException); }); + + it('should throw InternalServerErrorException if authType is not OAUTH2', async () => { + mockProviderRegistry.getProvider.mockReturnValue({ + name: 'salesforce', + authType: 'API_KEY', + authorizeUrl: 'https://login.salesforce.com/services/oauth2/authorize', + } as unknown as NonNullable); + + await expect( + service.getAuthorizationUrl('salesforce', 'state', testTenantId), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw InternalServerErrorException if authorizeUrl is missing', async () => { + mockProviderRegistry.getProvider.mockReturnValue({ + name: 'salesforce', + authType: 'OAUTH2', + } as unknown as NonNullable); + + await expect( + service.getAuthorizationUrl('salesforce', 'state', testTenantId), + ).rejects.toThrow(InternalServerErrorException); + }); }); describe('exchangeCodeForTokens', () => { @@ -184,7 +209,30 @@ describe('ConnectorsService', () => { ).rejects.toThrow(NotFoundException); }); - it('should throw InternalServerErrorException if decryption fails', async () => { + it('should throw InternalServerErrorException if authType is not OAUTH2', async () => { + mockProviderRegistry.getProvider.mockReturnValue({ + name: 'salesforce', + authType: 'API_KEY', + tokenUrl: 'https://login.salesforce.com/services/oauth2/token', + } as unknown as NonNullable); + + await expect( + service.exchangeCodeForTokens('salesforce', 'auth-code', testTenantId), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw InternalServerErrorException if tokenUrl is missing', async () => { + mockProviderRegistry.getProvider.mockReturnValue({ + name: 'salesforce', + authType: 'OAUTH2', + } as unknown as NonNullable); + + await expect( + service.exchangeCodeForTokens('salesforce', 'auth-code', testTenantId), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('should throw AppCredentialError if decryption fails', async () => { mockProviderRegistry.getProvider.mockReturnValue({ name: 'salesforce', authType: 'OAUTH2', @@ -197,7 +245,7 @@ describe('ConnectorsService', () => { await expect( service.exchangeCodeForTokens('salesforce', 'auth-code', testTenantId), - ).rejects.toThrow(InternalServerErrorException); + ).rejects.toThrow(AppCredentialError); }); it('should successfully exchange a code for tokens', async () => { @@ -225,6 +273,7 @@ describe('ConnectorsService', () => { expect(mockDbWhere).toHaveBeenCalledWith(expect.any(Object)); const whereArg = mockDbWhere.mock.calls[0]?.[0] as unknown; expect(safeStringify(whereArg)).toContain('tenant_id'); + expect(safeStringify(whereArg)).toContain(testTenantId); // Verify the decryption is used based off retrieved DB row // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/apps/api/src/modules/connections/connectors.service.ts b/apps/api/src/modules/connections/connectors.service.ts index 7794678f..3c06ee8c 100644 --- a/apps/api/src/modules/connections/connectors.service.ts +++ b/apps/api/src/modules/connections/connectors.service.ts @@ -10,6 +10,7 @@ import { ConfigService } from '@nestjs/config'; import { ProviderRegistryService, EncryptionService, + AppCredentialError, } from '@nexiom/connections'; import { appCredentials, @@ -73,9 +74,7 @@ export class ConnectorsService { this.logger.error( `Failed to decrypt client secret for ${providerName} on tenant ${tenantId}`, ); - throw new InternalServerErrorException( - 'Invalid connector configuration.', - ); + throw new AppCredentialError('Invalid connector configuration.'); } } @@ -226,7 +225,10 @@ export class ConnectorsService { return (await response.json()) as Record; } catch (error) { - if (error instanceof InternalServerErrorException) { + if ( + error instanceof InternalServerErrorException || + error instanceof AppCredentialError + ) { throw error; } this.logger.error( diff --git a/packages/connections/src/connectivity/token-manager.service.ts b/packages/connections/src/connectivity/token-manager.service.ts index 9f15564f..a16fa17d 100644 --- a/packages/connections/src/connectivity/token-manager.service.ts +++ b/packages/connections/src/connectivity/token-manager.service.ts @@ -17,6 +17,13 @@ export class OAuthRefreshError extends Error { } } +export class AppCredentialError extends Error { + constructor(message: string) { + super(message); + this.name = 'AppCredentialError'; + } +} + export abstract class OAuthRefreshClient { abstract refresh(tenantId: string, appName: string, refreshToken: string): Promise>; }