diff --git a/apps/api/package.json b/apps/api/package.json index f58d7015..2c594912 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -37,6 +37,8 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nexiom/identity": "workspace:*", + "@nexiom/database": "workspace:*", + "@nexiom/engine": "workspace:*", "bcryptjs": "^3.0.3", "better-auth": "^1.4.10", "class-transformer": "^0.5.1", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7caccf9f..a549ad15 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -38,3 +38,6 @@ export type { Verification, AbacConditions, } from '@nexiom/identity/src/schema'; + +// Engine schema — provider catalog and connection tables +export { providers, appConnections } from '@nexiom/database'; diff --git a/apps/api/src/modules/engine/connections/callback.controller.spec.ts b/apps/api/src/modules/engine/connections/callback.controller.spec.ts new file mode 100644 index 00000000..6f63a5ea --- /dev/null +++ b/apps/api/src/modules/engine/connections/callback.controller.spec.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeEach, vi, Mocked } from 'vitest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { OAuthCallbackController } from './callback.controller.js'; +import { EncryptionService, ProviderRegistryService } from '@nexiom/engine'; + +const VALID_TENANT_ID = '550e8400-e29b-41d4-a716-446655440000'; + +const { mockOnConflictDoUpdate, mockInsert, mockDb } = vi.hoisted(() => { + const onConflictDoUpdate = vi.fn().mockResolvedValue(true); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = vi.fn().mockReturnValue({ values }); + return { + mockOnConflictDoUpdate: onConflictDoUpdate, + mockInsert: insert, + mockDb: { insert }, + }; +}); + +// Mock the database module — prevents real Pool/Drizzle connections +vi.mock('@nexiom/database', () => ({ + appConnections: { + tenantId: 'tenantId', + appName: 'appName', + connectionKey: 'connectionKey', + }, +})); + +import { Request, Response } from 'express'; + +describe('OAuthCallbackController', () => { + let controller: OAuthCallbackController; + let mockEncryptionService: Mocked; + let mockProviderRegistry: { isAllowed: ReturnType }; + + const mockRequest = ( + provider: string, + session?: Record, + ): Partial => + ({ + params: { provider }, + session, + }) as unknown as Partial; + + const mockResponse = (): Partial => { + const res: Partial = {}; + res.redirect = vi.fn().mockReturnValue(res); + return res; + }; + + let originalDatabaseUrl: string | undefined; + + beforeAll(() => { + originalDatabaseUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = 'postgres://mock:mock@localhost:5432/mock'; + }); + + afterAll(() => { + if (originalDatabaseUrl) { + process.env.DATABASE_URL = originalDatabaseUrl; + } else { + delete process.env.DATABASE_URL; + } + }); + + beforeEach(async () => { + mockEncryptionService = { + encrypt: vi.fn(), + decrypt: vi.fn(), + } as unknown as Mocked; + + mockProviderRegistry = { + isAllowed: vi.fn().mockResolvedValue(true), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [OAuthCallbackController], + providers: [ + { + provide: 'DRIZZLE_DB', + useValue: mockDb, + }, + { + provide: EncryptionService, + useValue: mockEncryptionService, + }, + { + provide: ProviderRegistryService, + useValue: mockProviderRegistry, + }, + ], + }).compile(); + + controller = module.get(OAuthCallbackController); + vi.clearAllMocks(); + // Re-apply default: known providers are allowed + mockProviderRegistry.isAllowed.mockResolvedValue(true); + }); + + it('should redirect with invalid_provider error if provider is not allowed', async () => { + mockProviderRegistry.isAllowed.mockResolvedValue(false); + + 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 auth_failed if grant session is missing', async () => { + const req = mockRequest('salesforce'); + const res = mockResponse(); + + await controller.handleCallback(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + '/app/connections?error=auth_failed', + ); + }); + + it('should redirect with auth_failed if grant response contains error', async () => { + const req = mockRequest('salesforce', { + grant: { response: { error: 'invalid_grant' } }, + }); + const res = mockResponse(); + + await controller.handleCallback(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + '/app/connections?error=auth_failed', + ); + }); + + it('should redirect with invalid_state if state is missing', async () => { + const req = mockRequest('salesforce', { + grant: { response: { access_token: '123' } }, // No raw.state + }); + const res = mockResponse(); + + await controller.handleCallback(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + '/app/connections?error=invalid_state', + ); + }); + + it('should redirect with invalid_state if decryption fails', async () => { + mockEncryptionService.decrypt.mockRejectedValue( + new Error('Decryption failed'), + ); + + const req = mockRequest('salesforce', { + grant: { response: { raw: { state: 'bad-encrypted-state' } } }, + }); + const res = mockResponse(); + + await controller.handleCallback(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + '/app/connections?error=invalid_state', + ); + }); + + it('should redirect with invalid_credentials if access_token is missing', async () => { + mockEncryptionService.decrypt.mockResolvedValue(VALID_TENANT_ID); + + const req = mockRequest('salesforce', { + grant: { + response: { + raw: { state: 'encrypted-state', expires_in: 3600 }, + // No access_token provided + }, + }, + }); + const res = mockResponse(); + + await controller.handleCallback(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + '/app/connections?error=invalid_credentials', + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockEncryptionService.encrypt).not.toHaveBeenCalled(); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('should successfully store credentials and redirect on success', async () => { + mockEncryptionService.decrypt.mockResolvedValue(VALID_TENANT_ID); + mockEncryptionService.encrypt.mockResolvedValue('encrypted-credentials'); + + const req = mockRequest('salesforce', { + grant: { + response: { + access_token: 'acc-123', + refresh_token: 'ref-123', + raw: { + state: 'encrypted-state', + expires_in: 3600, + realmId: 'realm-id', + }, + }, + }, + }); + const res = mockResponse(); + + await controller.handleCallback(req as Request, res as Response); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockEncryptionService.decrypt).toHaveBeenCalledWith( + 'encrypted-state', + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockEncryptionService.encrypt).toHaveBeenCalledWith( + expect.stringContaining('"accessToken":"acc-123"'), + ); + expect(mockInsert).toHaveBeenCalled(); + // Verify the exact upsert payload + const rawValue = mockInsert.mock.results[0].value as { + values: typeof vi.fn; + }; + const { values } = rawValue; + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: VALID_TENANT_ID, + appName: 'salesforce', + connectionKey: 'realm-id', + encryptedCredentials: 'encrypted-credentials', + authType: 'OAUTH2', + }), + ); + expect(res.redirect).toHaveBeenCalledWith('/app/connections?success=true'); + }); + + it('should redirect with internal_error if database insert fails', async () => { + mockEncryptionService.decrypt.mockResolvedValue(VALID_TENANT_ID); + mockEncryptionService.encrypt.mockResolvedValue('encrypted-credentials'); + + // Override the mock to simulate failure + mockOnConflictDoUpdate.mockRejectedValueOnce(new Error('DB Error')); + + const req = mockRequest('salesforce', { + grant: { + response: { + access_token: 'acc-123', + raw: { state: 'encrypted-state', expires_in: 3600 }, + }, + }, + }); + const res = mockResponse(); + + await controller.handleCallback(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith( + '/app/connections?error=internal_error', + ); + }); +}); diff --git a/apps/api/src/modules/engine/connections/callback.controller.ts b/apps/api/src/modules/engine/connections/callback.controller.ts new file mode 100644 index 00000000..460e99a9 --- /dev/null +++ b/apps/api/src/modules/engine/connections/callback.controller.ts @@ -0,0 +1,166 @@ +import { Controller, Get, Req, Res, Logger, Inject } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { appConnections } from '@nexiom/database'; +import { + EncryptionService, + ProviderRegistryService, + DrizzleDb, +} from '@nexiom/engine'; +import { validate as uuidValidate } from 'uuid'; + +interface GrantResponse { + error?: string; + access_token?: string; + refresh_token?: string; + raw?: { + state?: string; + expires_in?: number; + realmId?: string; + [key: string]: unknown; + }; +} + +interface GrantSession { + grant?: { + response?: GrantResponse; + }; +} + +@Controller('connect/:provider/callback') +export class OAuthCallbackController { + private readonly logger = new Logger(OAuthCallbackController.name); + + constructor( + @Inject('DRIZZLE_DB') private readonly db: DrizzleDb, + private readonly crypto: EncryptionService, + private readonly providerRegistry: ProviderRegistryService, + ) {} + + @Get() + async handleCallback(@Req() req: Request, @Res() res: Response) { + const request = req as Request & { session?: GrantSession }; + const provider = request.params.provider; + + try { + if (!(await this.providerRegistry.isAllowed(provider))) { + this.logger.warn(`Rejected unauthorized provider: ${provider}`); + res.redirect(`/app/connections?error=invalid_provider`); + return; + } + } catch (error) { + this.logger.error( + `Provider registry check failed for: ${provider}`, + error, + ); + res.redirect(`/app/connections?error=internal_error`); + return; + } + + // 1. Grant.js populates req.session.grant.response + const grantResponse = request.session?.grant?.response; + if (!grantResponse || grantResponse.error) { + this.logger.error( + `OAuth failed for ${provider}`, + grantResponse?.error ?? 'Unknown error', + ); + res.redirect(`/app/connections?error=auth_failed`); + return; + } + + // 2. Extract and Validate State (Tenant Context) + const rawState = grantResponse.raw?.state; + + let tenantId: string; + try { + if (!rawState) throw new Error('Missing state'); + // Using the real encryption service instead of mocking + tenantId = await this.crypto.decrypt(rawState); + if (!uuidValidate(tenantId)) throw new Error('Invalid tenant ID format'); + } catch (error: unknown) { + const errMessage = error instanceof Error ? error.message : String(error); + this.logger.warn( + `Missing or invalid OAuth state for provider: ${provider}`, + errMessage, + ); + res.redirect(`/app/connections?error=invalid_state`); + return; + } + + // 3. Validate OAuth Payload + if (!grantResponse.access_token) { + this.logger.warn( + `Missing access_token in OAuth response for ${provider}, tenant: ${tenantId}`, + ); + res.redirect(`/app/connections?error=invalid_credentials`); + return; + } + + // 4. Prepare Encrypted Payload + const credentials = { + accessToken: grantResponse.access_token, + refreshToken: grantResponse.refresh_token, + realmId: grantResponse.raw?.realmId, // Store only needed metadata + }; + + let encryptedPayload: string; + try { + encryptedPayload = await this.crypto.encrypt(JSON.stringify(credentials)); + } catch (error) { + this.logger.error( + `Encryption failed for ${provider}, tenant: ${tenantId}`, + error, + ); + res.redirect(`/app/connections?error=internal_error`); + return; + } + + // 5. Calculate Expiry + const expiresIn = + typeof grantResponse.raw?.expires_in === 'number' && + grantResponse.raw.expires_in > 0 + ? grantResponse.raw.expires_in + : 3600; + const expiresAt = new Date(Date.now() + expiresIn * 1000); + + // 6. Derive connectionKey for multi-realm providers (e.g., QuickBooks realmId) + const connectionKey = grantResponse.raw?.realmId ?? 'default'; + + // 7. Save to Database using Upsert to prevent duplicate tenant+provider rows + try { + await this.db + .insert(appConnections) + .values({ + tenantId, + appName: provider, + connectionKey, + authType: 'OAUTH2', + encryptedCredentials: encryptedPayload, + expiresAt: expiresAt, + metadata: { realmId: grantResponse.raw?.realmId }, + }) + .onConflictDoUpdate({ + target: [ + appConnections.tenantId, + appConnections.appName, + appConnections.connectionKey, + ], + set: { + encryptedCredentials: encryptedPayload, + expiresAt: expiresAt, + metadata: { realmId: grantResponse.raw?.realmId }, + status: 'ACTIVE', + updatedAt: new Date(), + }, + }); + + this.logger.log( + `Successfully stored credentials for ${provider}, tenant: ${tenantId}`, + ); + } catch (error) { + this.logger.error(`Failed to store credentials for ${provider}`, error); + res.redirect(`/app/connections?error=internal_error`); + return; + } + res.redirect(`/app/connections?success=true`); + } +} diff --git a/apps/api/vitest.config.mts b/apps/api/vitest.config.mts index e3341c31..39921830 100644 --- a/apps/api/vitest.config.mts +++ b/apps/api/vitest.config.mts @@ -47,8 +47,6 @@ export default defineConfig({ ], reporter: ['text', 'json', 'html'], thresholds: { - // TODO: Restore to 90% after PBAC refactor stabilizes (Technical Debt: ISSUE-123) - // Lowered to 80% temporarily to accommodate rapid changes during the DB-based policy migration. statements: 80, branches: 80, functions: 80, diff --git a/docs/architecture/connector_scaling_architecture.md b/docs/architecture/connector_scaling_architecture.md new file mode 100644 index 00000000..9b0e182d --- /dev/null +++ b/docs/architecture/connector_scaling_architecture.md @@ -0,0 +1,332 @@ +# Detailed Design: Connector Auth Lifecycle + +**Target Audience:** Senior Backend Engineers +**Scope:** This document defines the exact data models, sequence flows, and service implementations required to securely authenticate, store, and refresh OAuth2/API credentials for 500+ FluxNex integrations. + +**Out of Scope:** Execution logic, data routing, mapping, and API payload construction are explicitly excluded from this document. + +## 1. Architectural Overview + +The Auth Lifecycle handles everything required to establish and maintain a secure connection to a third-party application on behalf of a Tenant. + +It consists of three primary pillars: + +1. **Dynamic UI Generation:** The backend telling the frontend exactly what fields (API Key, Subdomain, Password) are required for a specific app so we don't build 500 React forms. + +2. **The Auth Handshake:** Managed by **Grant.js** and the API Gateway (User clicks "Connect" -> Gets Token -> Saves to DB). + +3. **The Refresh Engine:** A centralized service utilizing distributed locks to guarantee tokens are always valid while strictly preventing external API rate-limit bans. + +## 2. The Inspiration: What to "Steal" (Study) + +To build a massively scalable authentication layer, we draw direct inspiration from the credential management patterns of the top two open-source automation platforms. + +*(Note: We are only looking at their Auth patterns here, not their Execution engines).* + +### A. Activepieces (~200+ Apps) + +Activepieces is written in modern TypeScript and provides the best architectural reference for isolated credential management. + +* **The "Piece" Architecture:** Look at their `packages/pieces` folder on GitHub. Every integration's authentication definition is strictly isolated. The QuickBooks auth code never accidentally pollutes the HubSpot auth code. + +* **Dynamic UI Generation:** Study how Activepieces handles user inputs for credentials. They don't hardcode React forms. Their backend defines a JSON schema for the required credentials, and the frontend renders it dynamically. + +* **The OAuth2 Refresh Loop:** Study their `oauth2.service.ts`. It handles the complex logic of locking the database, checking if an access token is expired, refreshing it, saving it, and unlocking the database so background workers don't fail. + +### B. n8n (700+ Apps) + +n8n is the heavyweight champion of integrations. Their backend is Node.js, making their credential abstraction highly relevant. + +* **The "Generic" Credential System:** n8n reached 700+ apps by realizing that 80% of APIs use standard OAuth2 or API Keys. Study their `GenericCredentialType` codebase. They abstracted the handshake so developers only have to paste an API's `Authorization URL` and `Token URL`, and the core engine handles the entire OAuth flow automatically. We emulate this using **Grant.js**. + +## 3. Database Schema (Drizzle ORM) + +Credentials must be stored in the isolated **Tenant Schema** (`tenant_{id}`). We use a unified `app_connection` table to handle OAuth2, Basic, and API Key credentials. + +```typescript +// packages/database-schema/src/tenant/app_connection.ts +import { pgTable, uuid, varchar, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; + +export const appConnections = pgTable('app_connection', { + id: uuid('id').defaultRandom().primaryKey(), + appName: varchar('app_name', { length: 100 }).notNull(), // e.g., 'quickbooks' + authType: varchar('auth_type', { length: 50 }).notNull(), // 'OAUTH2', 'API_KEY', 'BASIC' + + // Encrypted Payload (Contains access_token, refresh_token, or api_key) + // Must be encrypted via AWS KMS or AES-256-GCM before insert + encryptedCredentials: text('encrypted_credentials').notNull(), + + // Extracted for fast querying without decryption + expiresAt: timestamp('expires_at', { withTimezone: true }), + status: varchar('status', { length: 50 }).default('ACTIVE').notNull(), // ACTIVE, EXPIRED, REVOKED + + // Public metadata (e.g., connected account email, realmId) + metadata: jsonb('metadata').default({}), + + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, (table) => ({ + appNameIdx: index('app_name_idx').on(table.appName), + statusIdx: index('status_idx').on(table.status) +})); +``` + +## 4. Dynamic UI Generation Schema + +To support 500+ apps without frontend changes, the backend dictates the connection form requirements via a standard JSON schema. + +### 4.1 Backend Definition + +```typescript +// packages/core-kernel/src/types/connector-auth.ts +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 }>; + }>; +} + +// Example Implementation: Shopify (API Key) +export const shopifyAuthSchema = (): ConnectorAuthSchema => ({ + type: 'object', + properties: [ + { name: 'shopName', label: 'Shop Subdomain', type: 'shortText', required: true, description: 'e.g., my-store' }, + { name: 'adminToken', label: 'Admin API Token', type: 'secretText', required: true } + ] +}); +``` + +## 5. The Auth Handshake (Grant.js) + +When a user initiates an OAuth connection, FluxNex must maintain the `tenantId` across the redirect boundary. We achieve this by encoding the `tenantId` into the OAuth2 `state` parameter. + +### 5.1 Handshake Sequence Diagram + +```mermaid +sequenceDiagram + participant User + participant Web as Dashboard UI + participant API as NestJS Gateway + participant Grant as Grant.js + participant Provider as External App (QuickBooks) + participant DB as Postgres (Tenant Schema) + + User->>Web: Clicks "Connect QuickBooks" + Web->>API: GET /connect/quickbooks?tenantId=123 + API->>Grant: Redirect with `state=enc_tenant_123` + Grant->>Provider: 302 Redirect to Vendor Login + User->>Provider: Logs in & Approves + Provider->>Grant: 302 Redirect to /callback?code=xyz&state=enc_tenant_123 + Grant->>Provider: POST /token (Exchanges code for tokens) + Provider-->>Grant: access_token, refresh_token + Grant->>API: Forwards Tokens + State to Callback Controller + API->>API: Decrypt State -> Extract tenantId + API->>API: Encrypt Tokens (AWS KMS) + API->>DB: INSERT INTO tenant_123.app_connection + API->>Web: 302 Redirect to Dashboard (Success) +``` + +### 5.2 Callback Controller Implementation + +```typescript +// apps/api-gateway/src/modules/connections/callback.controller.ts +import { Controller, Get, Req, Res, BadRequestException } from '@nestjs/common'; +import { EncryptionService, TenantContext } from '@fluxnex/core-kernel'; +import { db } from '@fluxnex/database'; +import { appConnections } from '@fluxnex/database-schema/tenant'; + +@Controller('connect/:provider/callback') +export class OAuthCallbackController { + constructor(private crypto: EncryptionService) {} + + @Get() + async handleCallback(@Req() req, @Res() res) { + // 1. Grant.js populates req.session.grant.response + const grantResponse = req.session?.grant?.response; + if (!grantResponse || grantResponse.error) { + return res.redirect(`/app/connections?error=auth_failed`); + } + + // 2. Extract and Validate State (Tenant Context) + const rawState = grantResponse.raw?.state; + const tenantId = this.crypto.decryptStateParam(rawState); + if (!tenantId) throw new BadRequestException('Invalid State/Tenant Context'); + + // 3. Prepare Encrypted Payload + const credentials = { + accessToken: grantResponse.access_token, + refreshToken: grantResponse.refresh_token, + rawResponse: grantResponse.raw + }; + + const encryptedPayload = await this.crypto.encrypt(JSON.stringify(credentials)); + + // 4. Calculate Expiry + const expiresIn = grantResponse.raw?.expires_in || 3600; + const expiresAt = new Date(Date.now() + (expiresIn * 1000)); + + // 5. Save to Database (Strictly within Tenant Context) + await TenantContext.run({ tenantId }, async () => { + await db.insert(appConnections).values({ + appName: req.params.provider, + authType: 'OAUTH2', + encryptedCredentials: encryptedPayload, + expiresAt: expiresAt, + metadata: { realmId: grantResponse.raw?.realmId } // App-specific metadata extraction + }); + }); + + return res.redirect(`/app/connections?success=true`); + } +} +``` + +## 6. Token Refresh Engine (Concurrency Lock) + +External integration workers will request tokens. If the token is expired, the Auth Engine must refresh it. +**Constraint:** If 10,000 workers request a token simultaneously and it is expired, only **one** worker should perform the HTTP refresh. The others must wait. + +### 6.1 Refresh Lock Sequence + +```mermaid +sequenceDiagram + participant WorkerA + participant WorkerB + participant TokenManager + participant Redis + participant Provider API + participant DB + + WorkerA->>TokenManager: getCredentials(conn_123) + WorkerB->>TokenManager: getCredentials(conn_123) + + TokenManager->>DB: Check conn_123 + DB-->>TokenManager: expiresAt = PAST + + Note over TokenManager: Token Expired! + + WorkerA->>Redis: SETNX lock:refresh:conn_123 + Redis-->>WorkerA: SUCCESS (Lock Acquired) + + WorkerB->>Redis: SETNX lock:refresh:conn_123 + Redis-->>WorkerB: FAILED (Lock exists) + Note over WorkerB: Worker B sleeps for 1.5s + + WorkerA->>Provider API: POST /token (refresh_token) + Provider API-->>WorkerA: New Tokens + + WorkerA->>DB: UPDATE conn_123 + WorkerA->>Redis: DEL lock:refresh:conn_123 + WorkerA-->>WorkerA: Return Valid Token + + Note over WorkerB: Worker B wakes up + WorkerB->>TokenManager: getCredentials(conn_123) + TokenManager->>DB: Check conn_123 + DB-->>TokenManager: expiresAt = FUTURE + WorkerB-->>WorkerB: Return Valid Token +``` + +### 6.2 Detailed Service Implementation + +```typescript +// packages/core-kernel/src/auth/token-manager.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { db } from '@fluxnex/database'; +import { appConnections } from '@fluxnex/database-schema/tenant'; +import { eq } from 'drizzle-orm'; +import { Redis } from 'ioredis'; +import { EncryptionService } from '../security/encryption.service'; +import { OAuthRefreshClient } from './oauth-refresh.client'; + +@Injectable() +export class TokenManagerService { + private readonly logger = new Logger(TokenManagerService.name); + + constructor( + private redis: Redis, + private crypto: EncryptionService, + private oauthClient: OAuthRefreshClient + ) {} + + /** + * Primary Entrypoint for fetching tokens. + * Guarantees returning a VALID, unexpired token payload. + */ + async getValidCredentials(connectionId: string): Promise> { + const connection = await db.query.appConnections.findFirst({ + where: eq(appConnections.id, connectionId) + }); + + if (!connection) throw new Error(`Connection ${connectionId} not found`); + if (connection.status === 'REVOKED') throw new Error(`Connection revoked by user/provider`); + + // 1. Check Expiry (with 5-minute buffer to prevent mid-flight expiration) + const isExpired = connection.expiresAt && new Date(connection.expiresAt.getTime() - 5 * 60000) < new Date(); + + if (isExpired && connection.authType === 'OAUTH2') { + return await this.refreshWithLock(connection); + } + + // 2. Return decrypted credentials + return JSON.parse(await this.crypto.decrypt(connection.encryptedCredentials)); + } + + private async refreshWithLock(connection: any): Promise> { + const lockKey = `lock:refresh:${connection.id}`; + + // Acquire Lock (TTL 10 seconds to prevent deadlocks if worker crashes) + const lockAcquired = await this.redis.set(lockKey, 'locked', 'PX', 10000, 'NX'); + + if (!lockAcquired) { + this.logger.debug(`Connection ${connection.id} is currently refreshing. Waiting...`); + await new Promise(resolve => setTimeout(resolve, 1500)); + return this.getValidCredentials(connection.id); // Recursive retry + } + + try { + this.logger.log(`Acquired lock. Refreshing OAuth token for ${connection.appName}`); + + // 1. Decrypt old payload to get refresh_token + const oldPayload = JSON.parse(await this.crypto.decrypt(connection.encryptedCredentials)); + if (!oldPayload.refreshToken) throw new Error('No refresh token available'); + + // 2. Perform HTTP call to Vendor API + const newTokens = await this.oauthClient.refresh(connection.appName, oldPayload.refreshToken); + + // 3. Preserve the old refresh token if the vendor didn't return a new one (Standard OAuth2 behavior) + const updatedPayload = { + ...oldPayload, + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token || oldPayload.refreshToken, + }; + + // 4. Encrypt & Calculate Expiry + const encryptedPayload = await this.crypto.encrypt(JSON.stringify(updatedPayload)); + const expiresAt = new Date(Date.now() + (newTokens.expires_in * 1000)); + + // 5. Save to DB + await db.update(appConnections) + .set({ encryptedCredentials: encryptedPayload, expiresAt, updatedAt: new Date() }) + .where(eq(appConnections.id, connection.id)); + + return updatedPayload; + + } catch (error) { + // Handle cases where the user revoked access in the external app + if (error?.response?.status === 400 || error?.response?.status === 401) { + await db.update(appConnections).set({ status: 'REVOKED' }).where(eq(appConnections.id, connection.id)); + this.logger.error(`Token refresh rejected. Marked connection as REVOKED.`); + } + throw error; + } finally { + // Always release lock + await this.redis.del(lockKey); + } + } +} +``` diff --git a/docs/architecture/connector_scaling_strategy.md b/docs/architecture/connector_scaling_strategy.md new file mode 100644 index 00000000..be89f287 --- /dev/null +++ b/docs/architecture/connector_scaling_strategy.md @@ -0,0 +1,57 @@ +# Connector Scaling Strategy: Reaching 500+ Integrations + +To scale Nexiom to 500+ integrations, we must understand the difference between **Authentication** (getting the token) and **Integration** (making the API calls). + +* **Grant.js** handles *Authentication*. Its 200+ native providers are just presets. You can add *any* OAuth2 provider to Grant by passing a custom configuration (Auth URL, Token URL). + +* **Activepieces** and **n8n** handle *Integration* (Credentials, API mapping, Webhooks). + +Here is exactly what we can learn from the industry leaders to scale Nexiom rapidly. + +## 1. Activepieces (~200+ Apps) + +Activepieces is written in modern TypeScript and is the **best architectural reference for Nexiom**. While they don't have 500+ apps yet, their *foundation* is built to scale infinitely. + +### What to "Steal" (Study) from Activepieces + +* **The "Piece" Architecture:** Look at their `packages/pieces` folder on GitHub. Every integration is a strict, isolated TypeScript module with its own `package.json`. This means the QuickBooks code never accidentally breaks the HubSpot code. Nexiom should copy this exact folder structure for `packages/integrations`. + +* **Dynamic UI Generation:** Activepieces doesn't hardcode React forms for every app. The backend sends a JSON schema (e.g., `[{"name": "api_key", "type": "SecretText"}]`), and the frontend renders it dynamically. This is how you build 500 apps without touching the frontend code. + +* **The OAuth2 Refresh Loop:** Study their `oauth2.service.ts`. It handles the complex logic of locking the database, checking if a token is expired, refreshing it, and unlocking it so background workers don't fail. + +## 2. n8n (700+ Apps) + +n8n is the heavyweight champion of open-source integrations. Their backend is Node.js, making it highly relevant. + +### What to "Steal" (Study) from n8n + +* **The "Generic" Node/OAuth System:** n8n reached 700+ by realizing that 80% of APIs are just standard REST over OAuth2. Study their `GenericCredentialType` codebase. They created a system where developers just paste an API's base URL and Auth URLs, and n8n handles the rest dynamically. + +* **Declarative HTTP Routing:** Instead of writing raw `axios.post()` code 500 times, n8n uses a declarative JSON-like structure to define API endpoints. + +* **Pagination Abstractions:** Study how n8n handles pagination automatically. They have a core engine feature that automatically follows `next_page` cursors so connector developers don't have to write `while` loops. + +## 3. The Execution Plan & Timeline (2 - 3 Weeks) + +Building an enterprise-grade integration engine that scales to 500+ apps is a significant undertaking. For a single senior developer, it will take about **80 to 120 hours** across four distinct phases. + +**Crucial Advice:** Do NOT try to build the generic 500+ engine right away. Hardcode the integration for **just QuickBooks** or **just Salesforce** first to validate the data flow, then abstract it into the generic engine in Phase 3. + +### Phase 1: Grant.js Handshake & Storage (1 - 2 Days) + +* **Goal:** The basic OAuth2 login flow. + +* **Tasks:** Setting up Grant in NestJS, catching the callback, encrypting the initial `access_token` and `refresh_token`, and saving them to the `App_Connection` table. + +* **UI:** A simple "Connect to QuickBooks" button that redirects to the OAuth screen. + +* **Pro-Tip:** Grant supports an "Override" feature. If a customer wants an obscure TMS software, you just pass a custom `authorize_url` and `access_url` to Grant. + +### Phase 2: The "Activepieces" Token Refresh Engine (3 - 5 Days) + +* **Goal:** Manage the lifecycle of the token (the hardest backend engineering task). + +* **Tasks:** + + * Write the **Token Expiration Checker** in your Layer 5 (Delivery Worker diff --git a/integrations/quickbooks/eslint.config.mjs b/integrations/quickbooks/eslint.config.mjs new file mode 100644 index 00000000..7ce65124 --- /dev/null +++ b/integrations/quickbooks/eslint.config.mjs @@ -0,0 +1,8 @@ +// @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 new file mode 100644 index 00000000..6ec896a9 --- /dev/null +++ b/integrations/quickbooks/package.json @@ -0,0 +1,20 @@ +{ + "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/engine": "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 new file mode 100644 index 00000000..ba297423 --- /dev/null +++ b/integrations/quickbooks/src/auth/config.ts @@ -0,0 +1,23 @@ +import { GenericCredentialType } from "@nexiom/engine"; + +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 new file mode 100644 index 00000000..ab6db7f6 --- /dev/null +++ b/integrations/quickbooks/src/index.ts @@ -0,0 +1 @@ +export * from "./auth/config"; diff --git a/integrations/quickbooks/tsconfig.json b/integrations/quickbooks/tsconfig.json new file mode 100644 index 00000000..6011e554 --- /dev/null +++ b/integrations/quickbooks/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": [ + "src/**/*" + ], + "references": [ + { + "path": "../../packages/engine" + } + ] +} \ No newline at end of file diff --git a/integrations/salesforce/eslint.config.mjs b/integrations/salesforce/eslint.config.mjs new file mode 100644 index 00000000..7ce65124 --- /dev/null +++ b/integrations/salesforce/eslint.config.mjs @@ -0,0 +1,8 @@ +// @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 new file mode 100644 index 00000000..8c5d44d8 --- /dev/null +++ b/integrations/salesforce/package.json @@ -0,0 +1,20 @@ +{ + "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/engine": "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 new file mode 100644 index 00000000..01c37548 --- /dev/null +++ b/integrations/salesforce/src/auth/config.ts @@ -0,0 +1,36 @@ +import { GenericCredentialType } from "@nexiom/engine"; + +/** + * 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 new file mode 100644 index 00000000..ab6db7f6 --- /dev/null +++ b/integrations/salesforce/src/index.ts @@ -0,0 +1 @@ +export * from "./auth/config"; diff --git a/integrations/salesforce/tsconfig.json b/integrations/salesforce/tsconfig.json new file mode 100644 index 00000000..6011e554 --- /dev/null +++ b/integrations/salesforce/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": [ + "src/**/*" + ], + "references": [ + { + "path": "../../packages/engine" + } + ] +} \ No newline at end of file diff --git a/packages/database/drizzle.config.ts b/packages/database/drizzle.config.ts new file mode 100644 index 00000000..aae6aa1d --- /dev/null +++ b/packages/database/drizzle.config.ts @@ -0,0 +1,10 @@ +import type { Config } from 'drizzle-kit'; + +export default { + schema: './src/schema', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +} satisfies Config; diff --git a/packages/database/package.json b/packages/database/package.json new file mode 100644 index 00000000..1e2daf63 --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nexiom/database", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "drizzle-orm": "^0.45.1", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/pg": "^8.16.0", + "drizzle-kit": "^0.31.8", + "typescript": "^5.7.3" + } +} \ No newline at end of file diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts new file mode 100644 index 00000000..786f6b94 --- /dev/null +++ b/packages/database/src/client.ts @@ -0,0 +1,40 @@ +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 }; +type DbSchema = typeof schemaBundle; + +let pool: Pool | undefined; +let dbInstance: NodePgDatabase | undefined; + +export function getDb(): NodePgDatabase { + if (dbInstance) return dbInstance; + + if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is required'); + } + + pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 20, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + }); + + dbInstance = drizzle({ client: pool, schema: schemaBundle }); + // Drain the pool on graceful shutdown so in-flight queries finish cleanly. + process.once('SIGTERM', () => pool!.end()); + process.once('SIGINT', () => pool!.end()); + return dbInstance; +} + +// 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, { + get(_target, prop) { + return getDb()[prop as keyof NodePgDatabase]; + } +}); + diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts new file mode 100644 index 00000000..e8348068 --- /dev/null +++ b/packages/database/src/index.ts @@ -0,0 +1,3 @@ +export * from './schema/tenant'; +export * from './schema/provider'; +export * from './client'; diff --git a/packages/database/src/schema/provider.ts b/packages/database/src/schema/provider.ts new file mode 100644 index 00000000..9cd2c8d2 --- /dev/null +++ b/packages/database/src/schema/provider.ts @@ -0,0 +1,26 @@ +import { pgTable, varchar, text, jsonb, boolean, timestamp, pgEnum } from 'drizzle-orm/pg-core'; + +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', { + name: varchar('name', { length: 100 }).primaryKey(), // '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({}), + + // 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()), +}); diff --git a/packages/database/src/schema/tenant.ts b/packages/database/src/schema/tenant.ts new file mode 100644 index 00000000..dc0e4fa9 --- /dev/null +++ b/packages/database/src/schema/tenant.ts @@ -0,0 +1,29 @@ +import { pgTable, uuid, varchar, text, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core'; + +export const appConnections = pgTable('app_connection', { + id: uuid('id').defaultRandom().primaryKey(), + tenantId: uuid('tenant_id').notNull(), // FK enforced at migration level — tenants table lives in identity/catalog schema (Database-per-Tenant) + appName: varchar('app_name', { length: 100 }).notNull(), // e.g., 'quickbooks' + authType: varchar('auth_type', { length: 50 }).notNull(), // 'OAUTH2', 'API_KEY', 'BASIC' + + // Encrypted Payload (Contains access_token, refresh_token, or api_key) + encryptedCredentials: text('encrypted_credentials').notNull(), + + // Extracted for fast querying without decryption + expiresAt: timestamp('expires_at', { withTimezone: true }), + status: varchar('status', { length: 50 }).default('ACTIVE').notNull(), // ACTIVE, EXPIRED, REVOKED + + // Public metadata (e.g., connected account email, realmId) + metadata: jsonb('metadata').default({}), + + // Stable per-connection key for multi-realm providers (e.g., QB realmId). + // Defaults to 'default' for single-realm providers like Salesforce. + connectionKey: varchar('connection_key', { length: 255 }).default('default').notNull(), + + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => [ + index('app_name_idx').on(table.appName), + index('status_idx').on(table.status), + uniqueIndex('tenant_app_connection_unique_idx').on(table.tenantId, table.appName, table.connectionKey), +]); \ No newline at end of file diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 00000000..bd819bdd --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 00000000..83dfe606 --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,22 @@ +{ + "name": "@nexiom/engine", + "version": "1.0.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@nexiom/database": "workspace:*", + "drizzle-orm": "^0.45.1", + "ioredis": "^5.3.2" + }, + "devDependencies": { + "@nestjs/common": "^11.0.1", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.1" + } +} \ No newline at end of file diff --git a/packages/engine/src/connectivity/provider-registry.ts b/packages/engine/src/connectivity/provider-registry.ts new file mode 100644 index 00000000..06f5a841 --- /dev/null +++ b/packages/engine/src/connectivity/provider-registry.ts @@ -0,0 +1,44 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { providers } from '@nexiom/database'; +import { eq, and, InferSelectModel } from 'drizzle-orm'; +import { DrizzleDb } 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. + */ +@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; + } + + /** 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; + } + + /** List all enabled providers. */ + async getAllProviders(): Promise[]> { + return await this.db + .select() + .from(providers) + .where(eq(providers.enabled, true)); + } +} diff --git a/packages/engine/src/connectivity/token-manager.service.ts b/packages/engine/src/connectivity/token-manager.service.ts new file mode 100644 index 00000000..a9cdf843 --- /dev/null +++ b/packages/engine/src/connectivity/token-manager.service.ts @@ -0,0 +1,214 @@ +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'; + +// Abstract contracts — consumers must provide real implementations via DI +export abstract class EncryptionService { + abstract decrypt(val: string): Promise; + abstract encrypt(val: string): Promise; +} + +export class OAuthRefreshError extends Error { + constructor(message: string, public status?: number) { + super(message); + this.name = 'OAuthRefreshError'; + } +} + +export abstract class OAuthRefreshClient { + abstract refresh(appName: string, refreshToken: string): Promise>; +} + +function parseExpiresAt(value: unknown): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value as string | number); + return Number.isNaN(date.getTime()) ? null : date; +} + +@Injectable() +export class TokenManagerService implements OnModuleDestroy { + private readonly logger = new Logger(TokenManagerService.name); + + constructor( + @Inject('DRIZZLE_DB') private readonly db: DrizzleDb, + @Inject('REDIS_CLIENT') private readonly redis: Redis, + private readonly crypto: EncryptionService, + private readonly oauthClient: OAuthRefreshClient, + ) { } + + async onModuleDestroy() { + await this.redis.quit(); + } + + /** + * Primary Entrypoint for fetching tokens. + * Guarantees returning a VALID, unexpired token payload. + */ + async getValidCredentials(connectionId: string): Promise> { + const connection = await this.db.query.appConnections.findFirst({ + where: eq(appConnections.id, connectionId) + }); + + if (!connection) throw new Error(`Connection ${connectionId} not found`); + if (connection.status === 'REVOKED') throw new Error(`Connection revoked by user/provider`); + + // 1. Check Expiry (with 5-minute buffer to prevent mid-flight expiration) + // Treat null/missing/invalid expiresAt as expired for OAUTH2 — forces a refresh to populate it. + const expiresAtObj = parseExpiresAt(connection.expiresAt); + + const isExpired = connection.authType === 'OAUTH2' && + (!expiresAtObj || new Date(expiresAtObj.getTime() - 5 * 60000) < new Date()); + + if (isExpired) { + this.logger.warn(`Token expired or missing expiresAt for ${connection.appName as string}. Refreshing...`); + return await this.refreshWithLock(connection); + } + + // 2. Return decrypted credentials + return JSON.parse(await this.crypto.decrypt(connection.encryptedCredentials)) as Record; + } + + private async refreshWithLock(connection: Record): Promise> { + const lockKey = `lock:refresh:${connection.id as string}`; + const lockValue = Math.random().toString(36).substring(2); + + // Acquire Lock (TTL 10 seconds to prevent deadlocks if worker crashes) + const lockAcquired = await this.redis.set(lockKey, lockValue, 'PX', 10000, 'NX'); + + if (!lockAcquired) { + this.logger.debug(`Connection ${connection.id as string} is currently refreshing. Waiting...`); + const result = await this.waitForRefreshOrAcquireLock(connection, lockKey, lockValue); + if (result.credentials) return result.credentials; + connection = result.connection; + } + + try { + return await this.performTokenRefresh(connection); + } catch (error: unknown) { + await this.handleRefreshError(error, connection); + throw error; + } finally { + await this.releaseLock(lockKey, lockValue); + } + } + + /** + * Waits for another worker to finish refreshing, or acquires the lock itself. + * Returns decrypted credentials if another worker already refreshed, otherwise the latest connection. + */ + private async waitForRefreshOrAcquireLock( + connection: Record, + lockKey: string, + lockValue: string, + ): Promise<{ credentials?: Record; connection: Record }> { + const MAX_RETRIES = 3; + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + await new Promise(resolve => setTimeout(resolve, 1500)); + + const freshConnection = await this.db.query.appConnections.findFirst({ + where: eq(appConnections.id, connection.id) + }); + + const parsedExpiry = parseExpiresAt(freshConnection?.expiresAt); + + if (parsedExpiry && + new Date(parsedExpiry.getTime() - 5 * 60000) > new Date()) { + // Token was refreshed by another worker + const credentials = JSON.parse( + await this.crypto.decrypt(freshConnection!.encryptedCredentials) + ) as Record; + return { credentials, connection }; + } + + const retryLock = await this.redis.set(lockKey, lockValue, 'PX', 10000, 'NX'); + if (retryLock) { + // Re-read after acquiring the lock to avoid refreshing stale data + const latestConnection = await this.db.query.appConnections.findFirst({ + where: eq(appConnections.id, connection.id) + }); + return { connection: latestConnection ?? connection }; + } + + if (attempt === MAX_RETRIES - 1) { + throw new Error(`Unable to acquire refresh lock for connection ${connection.id as string}`); + } + } + + // Unreachable, but satisfies TypeScript + throw new Error(`Unable to acquire refresh lock for connection ${connection.id as string}`); + } + + /** Decrypts, refreshes via the vendor API, encrypts, and persists the new token. */ + private async performTokenRefresh(connection: Record): Promise> { + this.logger.log(`Acquired lock. Refreshing OAuth token for ${connection.appName as string}`); + + // 1. Decrypt old payload to get refresh_token + const oldPayload = JSON.parse( + await this.crypto.decrypt(connection.encryptedCredentials) + ) as Record; + + if (!oldPayload.refreshToken) { + throw new Error('No refresh token available'); + } + + // 2. Perform HTTP call to Vendor API + const newTokens = await this.oauthClient.refresh( + connection.appName as string, + oldPayload.refreshToken as string, + ); + + // 3. Explicitly map known snake_case fields to camelCase and merge unknowns. + // This keeps the persisted payload normalized while preserving custom vendor fields. + const updatedPayload: Record = { + ...oldPayload, + accessToken: newTokens.access_token ?? oldPayload.accessToken, + refreshToken: newTokens.refresh_token || oldPayload.refreshToken, + ...(typeof newTokens.expires_in === 'number' && { expiresIn: newTokens.expires_in }), + ...('id_token' in newTokens && { idToken: newTokens.id_token }), + ...('token_type' in newTokens && { tokenType: newTokens.token_type }), + }; + + // 4. Encrypt & Calculate Expiry + const encryptedPayload = await this.crypto.encrypt(JSON.stringify(updatedPayload)); + const expiresInMs = typeof newTokens.expires_in === 'number' + ? newTokens.expires_in * 1000 + : 3600 * 1000; // default 1-hour fallback + const expiresAt = new Date(Date.now() + expiresInMs); + + // 5. Save to DB + await this.db.update(appConnections) + .set({ encryptedCredentials: encryptedPayload, expiresAt, updatedAt: new Date() }) + .where(eq(appConnections.id, connection.id)); + + return updatedPayload; + } + + /** Marks the connection as REVOKED if the vendor rejected the refresh (400/401). */ + private async handleRefreshError(error: unknown, connection: Record): Promise { + if (!(error instanceof OAuthRefreshError)) return; + + const isRevoked = error.status === 400 || error.status === 401; + if (!isRevoked) return; + + await this.db.update(appConnections) + .set({ status: 'REVOKED' }) + .where(eq(appConnections.id, connection.id)); + this.logger.error(`Token refresh rejected. Marked connection as REVOKED.`); + } + + /** Releases the distributed lock using a Lua script to prevent deleting another worker's lock. */ + private async releaseLock(lockKey: string, lockValue: string): Promise { + const luaScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + `; + await this.redis.eval(luaScript, 1, lockKey, lockValue); + } +} + diff --git a/packages/engine/src/connectivity/types.ts b/packages/engine/src/connectivity/types.ts new file mode 100644 index 00000000..d8ac9872 --- /dev/null +++ b/packages/engine/src/connectivity/types.ts @@ -0,0 +1,34 @@ +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 }>; + }>; +} + +interface BaseCredentialType { + name: string; + uiSchema?: ConnectorAuthSchema; +} + +/** Shape of OAuth config as stored in the `providers` DB table. */ +export interface OAuthConfig { + authorizeUrl: string; + tokenUrl: string; + 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/engine/src/index.ts b/packages/engine/src/index.ts new file mode 100644 index 00000000..ba61da81 --- /dev/null +++ b/packages/engine/src/index.ts @@ -0,0 +1,3 @@ +export * from './connectivity/types'; +export * from './connectivity/token-manager.service'; +export * from './connectivity/provider-registry'; diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json new file mode 100644 index 00000000..6e7a8b37 --- /dev/null +++ b/packages/engine/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo", + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": [ + "src/**/*" + ], + "references": [ + { + "path": "../database" + } + ] +} \ No newline at end of file diff --git a/packages/eslint-config/index.mjs b/packages/eslint-config/index.mjs new file mode 100644 index 00000000..8955d55c --- /dev/null +++ b/packages/eslint-config/index.mjs @@ -0,0 +1,52 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +/** + * Creates a shared ESLint config for Nexiom integration packages. + * @param {string} tsconfigRootDir - The __dirname of the consuming package + * @param {object} [overrides] - Optional per-package rule overrides + * @returns {import('typescript-eslint').ConfigArray} + */ +export function createIntegrationConfig(tsconfigRootDir, overrides = {}) { + return tseslint.config( + { + ignores: ['eslint.config.mjs', 'dist/**'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + 'prettier/prettier': ['error', { endOfLine: 'auto' }], + ...overrides, + }, + }, + ); +} diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json new file mode 100644 index 00000000..e100d420 --- /dev/null +++ b/packages/eslint-config/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nexiom/eslint-config", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.mjs", + "exports": { + ".": "./index.mjs" + }, + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.3", + "globals": "^15.14.0" + }, + "peerDependencies": { + "@eslint/js": "^9.20.0", + "eslint": "^9.20.1", + "prettier": ">=3.0.0", + "typescript-eslint": "^8.24.0" + } +} \ No newline at end of file diff --git a/packages/eslint-config/tsconfig.json b/packages/eslint-config/tsconfig.json new file mode 100644 index 00000000..5f5be728 --- /dev/null +++ b/packages/eslint-config/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": [ + "." + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98c5116e..17b71853 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,12 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11) + '@nexiom/database': + specifier: workspace:* + version: link:../../packages/database + '@nexiom/engine': + specifier: workspace:* + version: link:../../packages/engine '@nexiom/identity': specifier: workspace:* version: link:../../packages/identity @@ -59,7 +65,7 @@ importers: version: 3.0.3 better-auth: specifier: ^1.4.10 - version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) + version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -105,7 +111,7 @@ importers: version: 9.39.2 '@nestjs/cli': specifier: ^11.0.0 - version: 11.0.14(@swc/core@1.15.11)(@types/node@22.19.3) + version: 11.0.14(@swc/core@1.15.11)(@types/node@22.19.3)(esbuild@0.25.12) '@nestjs/schematics': specifier: ^11.0.0 version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) @@ -114,7 +120,7 @@ importers: version: 11.1.11(@nestjs/common@11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.11)(@nestjs/platform-express@11.1.11) '@nx/webpack': specifier: ^22.4.5 - version: 22.4.5(@babel/traverse@7.29.0)(@swc/core@1.15.11)(lightningcss@1.30.2)(nx@22.4.5(@swc/core@1.15.11))(typescript@5.9.3) + version: 22.4.5(@babel/traverse@7.29.0)(@swc/core@1.15.11)(esbuild@0.25.12)(lightningcss@1.30.2)(nx@22.4.5(@swc/core@1.15.11))(typescript@5.9.3) '@swc/core': specifier: ^1.15.11 version: 1.15.11 @@ -171,7 +177,7 @@ importers: version: 7.2.2 ts-loader: specifier: ^9.5.2 - version: 9.5.4(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)) + version: 9.5.4(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.15.11)(@types/node@22.19.3)(typescript@5.9.3) @@ -372,6 +378,94 @@ importers: specifier: ^3.6.20 version: 3.6.20 + integrations/quickbooks: + dependencies: + '@nexiom/engine': + specifier: workspace:* + version: link:../../packages/engine + devDependencies: + '@nexiom/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + integrations/salesforce: + dependencies: + '@nexiom/engine': + specifier: workspace:* + version: link:../../packages/engine + devDependencies: + '@nexiom/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/database: + dependencies: + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) + pg: + specifier: ^8.16.3 + version: 8.16.3 + devDependencies: + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 + drizzle-kit: + specifier: ^0.31.8 + version: 0.31.8 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/engine: + dependencies: + '@nexiom/database': + specifier: workspace:* + version: link:../database + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) + ioredis: + specifier: ^5.3.2 + version: 5.9.3 + devDependencies: + '@nestjs/common': + specifier: ^11.0.1 + version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/eslint-config: + dependencies: + '@eslint/js': + specifier: ^9.20.0 + version: 9.39.2 + eslint: + specifier: ^9.20.1 + version: 9.39.2(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.0.1 + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.3 + version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4) + globals: + specifier: ^15.14.0 + version: 15.15.0 + prettier: + specifier: '>=3.0.0' + version: 3.7.4 + typescript-eslint: + specifier: ^8.24.0 + version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + packages/identity: dependencies: bcryptjs: @@ -379,7 +473,7 @@ importers: version: 3.0.3 better-auth: specifier: ^1.4.10 - version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) + version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) @@ -1883,6 +1977,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -4205,6 +4302,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -4525,6 +4626,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -5160,6 +5265,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -5170,6 +5276,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + globals@16.5.0: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} @@ -5331,6 +5441,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.9.3: + resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -5706,6 +5820,12 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -6722,6 +6842,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -7154,6 +7282,9 @@ packages: stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -9868,6 +9999,8 @@ snapshots: '@types/node': 25.0.5 optional: true + '@ioredis/commands@1.5.0': {} + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -10081,7 +10214,7 @@ snapshots: '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.9.0 - '@nestjs/cli@11.0.14(@swc/core@1.15.11)(@types/node@22.19.3)': + '@nestjs/cli@11.0.14(@swc/core@1.15.11)(@types/node@22.19.3)(esbuild@0.25.12)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) @@ -10092,14 +10225,14 @@ snapshots: chokidar: 4.0.3 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)) + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) glob: 13.0.0 node-emoji: 1.11.0 ora: 5.4.1 tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.2.0 typescript: 5.9.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) webpack-node-externals: 3.0.0 optionalDependencies: '@swc/core': 1.15.11 @@ -10305,7 +10438,7 @@ snapshots: '@nx/nx-win32-x64-msvc@22.4.5': optional: true - '@nx/webpack@22.4.5(@babel/traverse@7.29.0)(@swc/core@1.15.11)(lightningcss@1.30.2)(nx@22.4.5(@swc/core@1.15.11))(typescript@5.9.3)': + '@nx/webpack@22.4.5(@babel/traverse@7.29.0)(@swc/core@1.15.11)(esbuild@0.25.12)(lightningcss@1.30.2)(nx@22.4.5(@swc/core@1.15.11))(typescript@5.9.3)': dependencies: '@babel/core': 7.28.5 '@nx/devkit': 22.4.5(nx@22.4.5(@swc/core@1.15.11)) @@ -10313,36 +10446,36 @@ snapshots: '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) ajv: 8.17.1 autoprefixer: 10.4.23(postcss@8.5.6) - babel-loader: 9.2.1(@babel/core@7.28.5)(webpack@5.103.0(@swc/core@1.15.11)) + babel-loader: 9.2.1(@babel/core@7.28.5)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) browserslist: 4.28.1 - copy-webpack-plugin: 10.2.4(webpack@5.103.0(@swc/core@1.15.11)) - css-loader: 6.11.0(webpack@5.103.0(@swc/core@1.15.11)) - css-minimizer-webpack-plugin: 5.0.1(lightningcss@1.30.2)(webpack@5.103.0(@swc/core@1.15.11)) - fork-ts-checker-webpack-plugin: 7.2.13(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)) + copy-webpack-plugin: 10.2.4(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + css-loader: 6.11.0(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + css-minimizer-webpack-plugin: 5.0.1(esbuild@0.25.12)(lightningcss@1.30.2)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + fork-ts-checker-webpack-plugin: 7.2.13(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) less: 4.5.1 - less-loader: 11.1.4(less@4.5.1)(webpack@5.103.0(@swc/core@1.15.11)) - license-webpack-plugin: 4.0.2(webpack@5.103.0(@swc/core@1.15.11)) + less-loader: 11.1.4(less@4.5.1)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + license-webpack-plugin: 4.0.2(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) loader-utils: 2.0.4 - mini-css-extract-plugin: 2.4.7(webpack@5.103.0(@swc/core@1.15.11)) + mini-css-extract-plugin: 2.4.7(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) parse5: 4.0.0 picocolors: 1.1.1 postcss: 8.5.6 postcss-import: 14.1.0(postcss@8.5.6) - postcss-loader: 6.2.1(postcss@8.5.6)(webpack@5.103.0(@swc/core@1.15.11)) + postcss-loader: 6.2.1(postcss@8.5.6)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) rxjs: 7.8.2 sass: 1.97.3 sass-embedded: 1.97.3 - sass-loader: 16.0.7(sass-embedded@1.97.3)(sass@1.97.3)(webpack@5.103.0(@swc/core@1.15.11)) - source-map-loader: 5.0.0(webpack@5.103.0(@swc/core@1.15.11)) - style-loader: 3.3.4(webpack@5.103.0(@swc/core@1.15.11)) - terser-webpack-plugin: 5.3.16(@swc/core@1.15.11)(webpack@5.103.0(@swc/core@1.15.11)) - ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)) + sass-loader: 16.0.7(sass-embedded@1.97.3)(sass@1.97.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + source-map-loader: 5.0.0(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + style-loader: 3.3.4(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.11)(esbuild@0.25.12)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) + ts-loader: 9.5.4(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) tsconfig-paths-webpack-plugin: 4.2.0 tslib: 2.8.1 - webpack: 5.103.0(@swc/core@1.15.11) - webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) + webpack-dev-server: 5.2.3(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) webpack-node-externals: 3.0.0 - webpack-subresource-integrity: 5.1.0(webpack@5.103.0(@swc/core@1.15.11)) + webpack-subresource-integrity: 5.1.0(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) transitivePeerDependencies: - '@babel/traverse' - '@parcel/css' @@ -12191,12 +12324,12 @@ snapshots: transitivePeerDependencies: - debug - babel-loader@9.2.1(@babel/core@7.28.5)(webpack@5.103.0(@swc/core@1.15.11)): + babel-loader@9.2.1(@babel/core@7.28.5)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: '@babel/core': 7.28.5 find-cache-dir: 4.0.0 schema-utils: 4.3.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) babel-plugin-const-enum@1.2.0(@babel/core@7.28.5): dependencies: @@ -12265,7 +12398,7 @@ snapshots: bcryptjs@3.0.3: {} - better-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)): + better-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@24.10.4)(happy-dom@20.1.0)(jsdom@25.0.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@24.10.4)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)): dependencies: '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) @@ -12286,9 +12419,9 @@ snapshots: pg: 8.16.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vitest: 2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) + vitest: 2.1.9(@types/node@24.10.4)(happy-dom@20.1.0)(jsdom@25.0.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@24.10.4)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) - better-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.28.5)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@24.10.4)(happy-dom@20.1.0)(jsdom@25.0.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@24.10.4)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)): + better-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3))(pg@8.16.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)): dependencies: '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) @@ -12309,7 +12442,7 @@ snapshots: pg: 8.16.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vitest: 2.1.9(@types/node@24.10.4)(happy-dom@20.1.0)(jsdom@25.0.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@24.10.4)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) + vitest: 2.1.9(@types/node@22.19.3)(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) better-call@1.1.7(zod@4.3.5): dependencies: @@ -12530,6 +12663,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -12627,7 +12762,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@10.2.4(webpack@5.103.0(@swc/core@1.15.11)): + copy-webpack-plugin@10.2.4(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -12635,7 +12770,7 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 6.0.2 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) core-js-compat@3.48.0: dependencies: @@ -12677,7 +12812,7 @@ snapshots: dependencies: postcss: 8.5.6 - css-loader@6.11.0(webpack@5.103.0(@swc/core@1.15.11)): + css-loader@6.11.0(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -12688,9 +12823,9 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.7.3 optionalDependencies: - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) - css-minimizer-webpack-plugin@5.0.1(lightningcss@1.30.2)(webpack@5.103.0(@swc/core@1.15.11)): + css-minimizer-webpack-plugin@5.0.1(esbuild@0.25.12)(lightningcss@1.30.2)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 6.1.2(postcss@8.5.6) @@ -12698,8 +12833,9 @@ snapshots: postcss: 8.5.6 schema-utils: 4.3.3 serialize-javascript: 6.0.2 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) optionalDependencies: + esbuild: 0.25.12 lightningcss: 1.30.2 css-select@5.2.2: @@ -12843,6 +12979,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -13360,7 +13498,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@7.2.13(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)): + fork-ts-checker-webpack-plugin@7.2.13(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -13375,9 +13513,9 @@ snapshots: semver: 7.7.3 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) - fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)): + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -13392,7 +13530,7 @@ snapshots: semver: 7.7.3 tapable: 2.3.0 typescript: 5.9.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) form-data@4.0.5: dependencies: @@ -13499,6 +13637,8 @@ snapshots: globals@14.0.0: {} + globals@15.15.0: {} + globals@16.5.0: {} globby@12.2.0: @@ -13665,6 +13805,20 @@ snapshots: inherits@2.0.4: {} + ioredis@5.9.3: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -13905,10 +14059,10 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.3 - less-loader@11.1.4(less@4.5.1)(webpack@5.103.0(@swc/core@1.15.11)): + less-loader@11.1.4(less@4.5.1)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: less: 4.5.1 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) less@4.5.1: dependencies: @@ -13931,11 +14085,11 @@ snapshots: libphonenumber-js@1.12.33: {} - license-webpack-plugin@4.0.2(webpack@5.103.0(@swc/core@1.15.11)): + license-webpack-plugin@4.0.2(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: webpack-sources: 3.3.3 optionalDependencies: - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) lightningcss-android-arm64@1.30.2: optional: true @@ -14036,6 +14190,10 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} @@ -14173,10 +14331,10 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.4.7(webpack@5.103.0(@swc/core@1.15.11)): + mini-css-extract-plugin@2.4.7(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: schema-utils: 4.3.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) minimalistic-assert@1.0.1: {} @@ -14423,7 +14581,7 @@ snapshots: open: 8.4.2 ora: 5.3.0 resolve.exports: 2.0.3 - semver: 7.7.3 + semver: 7.7.4 string-width: 4.2.3 tar-stream: 2.2.0 tmp: 0.2.5 @@ -14743,13 +14901,13 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@swc/core@1.15.11)(@types/node@24.10.4)(typescript@5.9.3) - postcss-loader@6.2.1(postcss@8.5.6)(webpack@5.103.0(@swc/core@1.15.11)): + postcss-loader@6.2.1(postcss@8.5.6)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: cosmiconfig: 7.1.0 klona: 2.0.6 postcss: 8.5.6 semver: 7.7.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) postcss-merge-longhand@6.0.5(postcss@8.5.6): dependencies: @@ -15089,6 +15247,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} regenerate-unicode-properties@10.2.2: @@ -15307,13 +15471,13 @@ snapshots: sass-embedded-win32-arm64: 1.97.3 sass-embedded-win32-x64: 1.97.3 - sass-loader@16.0.7(sass-embedded@1.97.3)(sass@1.97.3)(webpack@5.103.0(@swc/core@1.15.11)): + sass-loader@16.0.7(sass-embedded@1.97.3)(sass@1.97.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: neo-async: 2.6.2 optionalDependencies: sass: 1.97.3 sass-embedded: 1.97.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) sass@1.97.3: dependencies: @@ -15359,8 +15523,7 @@ snapshots: semver@7.7.3: {} - semver@7.7.4: - optional: true + semver@7.7.4: {} send@0.19.2: dependencies: @@ -15525,11 +15688,11 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.103.0(@swc/core@1.15.11)): + source-map-loader@5.0.0(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) source-map-support@0.5.19: dependencies: @@ -15578,6 +15741,8 @@ snapshots: stackframe@1.3.4: {} + standard-as-callback@2.1.0: {} + statuses@1.5.0: {} statuses@2.0.2: {} @@ -15645,9 +15810,9 @@ snapshots: dependencies: '@tokenizer/token': 0.3.0 - style-loader@3.3.4(webpack@5.103.0(@swc/core@1.15.11)): + style-loader@3.3.4(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): dependencies: @@ -15774,16 +15939,17 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - terser-webpack-plugin@5.3.16(@swc/core@1.15.11)(webpack@5.103.0(@swc/core@1.15.11)): + terser-webpack-plugin@5.3.16(@swc/core@1.15.11)(esbuild@0.25.12)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.1 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) optionalDependencies: '@swc/core': 1.15.11 + esbuild: 0.25.12 terser@5.44.1: dependencies: @@ -15884,7 +16050,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-loader@9.5.4(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)): + ts-loader@9.5.4(typescript@5.9.3)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.18.4 @@ -15892,7 +16058,7 @@ snapshots: semver: 7.7.3 source-map: 0.7.6 typescript: 5.9.3 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) ts-node@10.9.2(@swc/core@1.15.11)(@types/node@22.19.3)(typescript@5.9.3): dependencies: @@ -16367,7 +16533,7 @@ snapshots: webidl-conversions@8.0.1: optional: true - webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)): + webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: colorette: 2.0.20 memfs: 4.56.10(tslib@2.8.1) @@ -16376,11 +16542,11 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) transitivePeerDependencies: - tslib - webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)): + webpack-dev-server@5.2.3(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -16408,10 +16574,10 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)) + webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) ws: 8.19.0 optionalDependencies: - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) transitivePeerDependencies: - bufferutil - debug @@ -16423,14 +16589,14 @@ snapshots: webpack-sources@3.3.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.103.0(@swc/core@1.15.11)): + webpack-subresource-integrity@5.1.0(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)): dependencies: typed-assert: 1.0.9 - webpack: 5.103.0(@swc/core@1.15.11) + webpack: 5.103.0(@swc/core@1.15.11)(esbuild@0.25.12) webpack-virtual-modules@0.6.2: {} - webpack@5.103.0(@swc/core@1.15.11): + webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -16454,7 +16620,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(@swc/core@1.15.11)(webpack@5.103.0(@swc/core@1.15.11)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.11)(esbuild@0.25.12)(webpack@5.103.0(@swc/core@1.15.11)(esbuild@0.25.12)) watchpack: 2.5.0 webpack-sources: 3.3.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9b0dad6..bc2b59fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'apps/*' - 'packages/*' + - 'integrations/*' diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..1df52de6 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "composite": true + } +} \ No newline at end of file