Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions TECHNICAL_DEBT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,7 @@ vi.mock('@nexiom/database', () => ({
}));

describe('OAuthCallbackController', () => {
type ProviderResult = Awaited<
ReturnType<ProviderRegistryService['getProvider']>
>;
type ProviderResult = ReturnType<ProviderRegistryService['getProvider']>;
let controller: OAuthCallbackController;
let mockEncryptionService: Mocked<EncryptionService>;
let mockProviderRegistry: Mocked<ProviderRegistryService>;
Expand Down Expand Up @@ -89,10 +87,17 @@ describe('OAuthCallbackController', () => {
} as unknown as Mocked<EncryptionService>;

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<ProviderRegistryService>;

Expand Down Expand Up @@ -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();
Expand All @@ -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('Unexpected registry error');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const req = mockRequest('salesforce');
const res = mockResponse();
Expand Down Expand Up @@ -292,6 +283,7 @@ describe('OAuthCallbackController', () => {
expect(mockConnectorsService.exchangeCodeForTokens).toHaveBeenCalledWith(
'salesforce',
'123',
VALID_TENANT_ID,
);

expect(mockEncryptionService.encrypt).toHaveBeenCalledWith(
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
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, type DrizzleDb } from '@nexiom/database';
import {
EncryptionService,
ProviderRegistryService,
DrizzleDb,
type ProviderDefinition,
} from '@nexiom/connections';

import { OauthStateService } from '../oauth-state.service';
Expand Down Expand Up @@ -43,10 +42,10 @@ export class OAuthCallbackController {
return;
}

let providerData: InferSelectModel<typeof providers> | 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;
Expand Down Expand Up @@ -108,6 +107,7 @@ export class OAuthCallbackController {
tokenResponse = await this.connectorsService.exchangeCodeForTokens(
provider,
code,
tenantId,
);
} catch (error) {
this.logger.error(`Token exchange failed for ${provider}`, error);
Expand Down Expand Up @@ -199,7 +199,7 @@ export class OAuthCallbackController {

private async persistConnection(
provider: string,
providerData: InferSelectModel<typeof providers>,
providerData: ProviderDefinition,
tenantId: string,
stateRealmId: string | undefined,
tokenResponse: Record<string, unknown>,
Expand Down Expand Up @@ -252,10 +252,9 @@ export class OAuthCallbackController {
.insert(appConnections)
.values({
tenantId,
providerId: providerData.id,
appName: provider,
connectionKey,
authType: 'OAUTH2',
authType: providerData.authType,
encryptedCredentials: encryptedPayload,
expiresAt: expiresAt,
metadata: { realmId: stateRealmId },
Expand All @@ -267,7 +266,6 @@ export class OAuthCallbackController {
appConnections.connectionKey,
],
set: {
providerId: providerData.id,
encryptedCredentials: encryptedPayload,
expiresAt: expiresAt,
metadata: { realmId: stateRealmId },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('ConnectorsController', () => {
expect(mockConnectorsService.getAuthorizationUrl).toHaveBeenCalledWith(
'salesforce',
'mocked_jwt_state',
'tenant-123',
);
expect(mockRes.redirect).toHaveBeenCalledWith('https://vendor.com/auth');
});
Expand Down Expand Up @@ -133,28 +134,25 @@ 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',
authType: 'OAUTH2',
description: 'CRM platform',
logoUrl: 'https://logo.com/sf.png',
category: 'CRM',
enabled: true,
scopes: [],
uiSchema: {},
authorizeUrl: '',
tokenUrl: '',
createdAt: new Date(),
updatedAt: new Date(),
},
]);

// Act
const result = await controller.getProviders();
const result = controller.getProviders();

// Assert
expect(result).toHaveLength(1);
Expand All @@ -171,14 +169,18 @@ 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('Registry initialization error');
});

const promise = controller.getProviders();
await expect(promise).rejects.toThrow(InternalServerErrorException);
await expect(promise).rejects.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');
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -34,9 +38,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,
Expand Down Expand Up @@ -157,6 +161,7 @@ export class ConnectorsController {
const url = await this.connectorsService.getAuthorizationUrl(
providerName,
state,
tenantId,
);

// Redirect the user browser to the vendor's OAuth page
Expand Down
Loading