From 3f205fdf14e72768c7f10803751427598ee9e2da Mon Sep 17 00:00:00 2001 From: Pramod Narayana Date: Thu, 4 Jun 2026 13:15:48 +0530 Subject: [PATCH 1/5] hot reloading engine using plugin architecture --- apps/api/src/app/app.module.ts | 2 + .../plugins/plugins.controller.spec.ts | 155 +++++++ .../src/modules/plugins/plugins.controller.ts | 108 +++++ .../api/src/modules/plugins/plugins.module.ts | 9 + .../pieces/platform/quickbooks/package.json | 1 - .../pieces/platform/registry/package.json | 13 +- .../pieces/platform/registry/src/index.ts | 2 + .../pieces/execution-worker.service.spec.ts | 67 ++++ .../src/pieces/execution-worker.service.ts | 67 ++++ .../pieces/migration-worker.service.spec.ts | 95 +++++ .../src/pieces/migration-worker.service.ts | 88 ++++ .../src/pieces/piece-execution.worker.ts | 53 +++ .../src/pieces/piece-loader.service.spec.ts | 72 ++++ .../src/pieces/piece-loader.service.ts | 51 +-- .../registry/src/pieces/pieces.module.ts | 8 +- .../src/pieces/plugin-manager.service.spec.ts | 91 +++++ .../src/pieces/plugin-manager.service.ts | 84 ++++ .../platform/registry/vitest.config.mts | 82 ++++ .../pieces/platform/salesforce/package.json | 1 - .../src/events/plugin-migration.event.ts | 5 + packages/queue/src/index.ts | 1 + pnpm-lock.yaml | 378 +++++++++++++++++- 22 files changed, 1386 insertions(+), 47 deletions(-) create mode 100644 apps/api/src/modules/plugins/plugins.controller.spec.ts create mode 100644 apps/api/src/modules/plugins/plugins.controller.ts create mode 100644 apps/api/src/modules/plugins/plugins.module.ts create mode 100644 packages/pieces/platform/registry/src/pieces/execution-worker.service.spec.ts create mode 100644 packages/pieces/platform/registry/src/pieces/execution-worker.service.ts create mode 100644 packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts create mode 100644 packages/pieces/platform/registry/src/pieces/migration-worker.service.ts create mode 100644 packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts create mode 100644 packages/pieces/platform/registry/src/pieces/piece-loader.service.spec.ts create mode 100644 packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts create mode 100644 packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts create mode 100644 packages/pieces/platform/registry/vitest.config.mts create mode 100644 packages/queue/src/events/plugin-migration.event.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 15eaa1e4..550622a4 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -36,6 +36,7 @@ import { ExceptionsModule } from '../modules/exceptions/exceptions.module.js'; import { AiModule } from '../modules/ai/ai.module.js'; import { MappingsModule } from '../modules/mappings/mappings.module.js'; import { GitopsModule } from '../modules/gitops/gitops.module.js'; +import { PluginsModule } from '../modules/plugins/plugins.module.js'; @Module({ imports: [ @@ -115,6 +116,7 @@ import { GitopsModule } from '../modules/gitops/gitops.module.js'; AiModule, MappingsModule, GitopsModule, + PluginsModule, ], controllers: [AppController], providers: [AppService, ShutdownService], diff --git a/apps/api/src/modules/plugins/plugins.controller.spec.ts b/apps/api/src/modules/plugins/plugins.controller.spec.ts new file mode 100644 index 00000000..a29aa5b5 --- /dev/null +++ b/apps/api/src/modules/plugins/plugins.controller.spec.ts @@ -0,0 +1,155 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PluginsController, WebhookPayloadDto } from './plugins.controller.js'; +import { PluginManagerService } from '@soopa/piece-registry'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mocked } from 'vitest'; + +describe('PluginsController', () => { + let controller: PluginsController; + let pluginManagerService: Mocked; + let configService: Mocked; + let installPieceMock: ReturnType; + + const TEST_SECRET = 'test-secret'; + + beforeEach(async () => { + installPieceMock = vi.fn(); + + // Mock the dependencies + pluginManagerService = { + installPiece: installPieceMock, + } as unknown as Mocked; + + configService = { + get: vi.fn().mockImplementation((key: string) => { + if (key === 'NPM_WEBHOOK_SECRET') return TEST_SECRET; + return null; + }), + } as unknown as Mocked; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [PluginsController], + providers: [ + { provide: PluginManagerService, useValue: pluginManagerService }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + controller = module.get(PluginsController); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // Helper to generate valid HMAC signature + const generateValidSignature = (payload: any): string => { + const hmac = crypto.createHmac('sha256', TEST_SECRET); + return 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex'); + }; + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('handleNpmWebhook', () => { + it('should throw UnauthorizedException if signature is missing but secret is configured', async () => { + const payload = { + name: '@soopa/piece-slack', + version: '1.0.0', + } as WebhookPayloadDto; + + await expect(controller.handleNpmWebhook('', payload)).rejects.toThrow( + UnauthorizedException, + ); + expect(installPieceMock).not.toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException if signature is invalid', async () => { + const payload = { + name: '@soopa/piece-slack', + version: '1.0.0', + } as WebhookPayloadDto; + const invalidSignature = 'sha256=invalidhash12345'; + + await expect( + controller.handleNpmWebhook(invalidSignature, payload), + ).rejects.toThrow(UnauthorizedException); + expect(installPieceMock).not.toHaveBeenCalled(); + }); + + it('should ignore payload without a package name gracefully', async () => { + const payload = { version: '1.0.0' } as WebhookPayloadDto; + const signature = generateValidSignature(payload); + + const result = await controller.handleNpmWebhook(signature, payload); + + expect(result).toEqual({ + status: 'ignored', + reason: 'No package name found in payload', + }); + expect(installPieceMock).not.toHaveBeenCalled(); + }); + + it('should ignore payload that does not belong to @soopa scope', async () => { + const payload = { + name: '@other/piece-slack', + version: '1.0.0', + } as WebhookPayloadDto; + const signature = generateValidSignature(payload); + + const result = await controller.handleNpmWebhook(signature, payload); + + expect(result).toEqual({ + status: 'ignored', + reason: 'Only @soopa packages are hot-loaded', + }); + expect(installPieceMock).not.toHaveBeenCalled(); + }); + + it('should successfully install a valid piece and return success', async () => { + const payload = { + name: '@soopa/piece-slack', + version: '1.2.3', + } as unknown as WebhookPayloadDto; + const signature = generateValidSignature(payload); + + pluginManagerService.installPiece.mockResolvedValueOnce({ + location: '/tmp/plugin', + version: '1.2.3', + }); + + const result = await controller.handleNpmWebhook(signature, payload); + + expect(result).toEqual({ + status: 'success', + message: 'Successfully installed @soopa/piece-slack@1.2.3', + }); + expect(installPieceMock).toHaveBeenCalledWith( + '@soopa/piece-slack', + '1.2.3', + ); + }); + + it('should return error status if pluginManager fails to install', async () => { + const payload = { + name: '@soopa/piece-slack', + version: '1.2.3', + } as unknown as WebhookPayloadDto; + const signature = generateValidSignature(payload); + + installPieceMock.mockRejectedValueOnce(new Error('Network Failure')); + + const result = await controller.handleNpmWebhook(signature, payload); + + expect(result).toEqual({ status: 'error', message: 'Network Failure' }); + expect(installPieceMock).toHaveBeenCalledWith( + '@soopa/piece-slack', + '1.2.3', + ); + }); + }); +}); diff --git a/apps/api/src/modules/plugins/plugins.controller.ts b/apps/api/src/modules/plugins/plugins.controller.ts new file mode 100644 index 00000000..4cd817f5 --- /dev/null +++ b/apps/api/src/modules/plugins/plugins.controller.ts @@ -0,0 +1,108 @@ +import { + Controller, + Post, + Body, + Headers, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PluginManagerService } from '@soopa/piece-registry'; +import * as crypto from 'crypto'; +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +const WebhookPayloadSchema = z + .object({ + name: z.string().optional(), + version: z.string().optional(), + package: z + .object({ + name: z.string().optional(), + version: z.string().optional(), + }) + .optional(), + }) + .passthrough(); + +export type WebhookPayload = z.infer; +export class WebhookPayloadDto extends createZodDto(WebhookPayloadSchema) {} + +@Controller('api/internal/system/plugins') +export class PluginsController { + private readonly logger = new Logger(PluginsController.name); + + constructor( + private readonly pluginManager: PluginManagerService, + private readonly configService: ConfigService, + ) {} + + /** + * Webhook endpoint triggered by the NPM Registry (e.g., Verdaccio) + * when a new version of a piece is published. + */ + @Post('webhook') + async handleNpmWebhook( + @Headers('x-npm-signature') signature: string, + @Body() payloadRaw: WebhookPayloadDto, + ) { + const payload = payloadRaw as unknown as WebhookPayload; + this.logger.log('Received NPM publish webhook event'); + + const webhookSecret = this.configService.get('NPM_WEBHOOK_SECRET'); + if (webhookSecret && signature) { + // Validate HMAC signature to ensure request originated from our private registry + const hmac = crypto.createHmac('sha256', webhookSecret); + const digest = + 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex'); + + if (signature !== digest) { + this.logger.warn( + 'Invalid NPM webhook signature detected. Dropping payload.', + ); + throw new UnauthorizedException('Invalid webhook signature'); + } + } else if (webhookSecret && !signature) { + throw new UnauthorizedException('Missing webhook signature'); + } + + // Example Verdaccio payload parsing: + // Extract the package name and version from the webhook payload. + // Ensure we only process @soopa scopes or allowed packages. + const packageName = payload?.name || payload?.package?.name; + const version = payload?.version || payload?.package?.version || 'latest'; + + if (!packageName) { + return { status: 'ignored', reason: 'No package name found in payload' }; + } + + if (!packageName.startsWith('@soopa/')) { + return { + status: 'ignored', + reason: 'Only @soopa packages are hot-loaded', + }; + } + + try { + this.logger.log( + `Triggering background install for ${packageName}@${version}...`, + ); + + // Fire and forget (or await) the download + // The service will handle downloading and updating internal state. + await this.pluginManager.installPiece(packageName, version); + + return { + status: 'success', + message: `Successfully installed ${packageName}@${version}`, + }; + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logger.error( + `Failed to install plugin via webhook: ${errorMessage}`, + ); + return { status: 'error', message: errorMessage }; + } + } +} diff --git a/apps/api/src/modules/plugins/plugins.module.ts b/apps/api/src/modules/plugins/plugins.module.ts new file mode 100644 index 00000000..0a232f18 --- /dev/null +++ b/apps/api/src/modules/plugins/plugins.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PluginsController } from './plugins.controller.js'; +import { PiecesModule } from '@soopa/piece-registry'; + +@Module({ + imports: [PiecesModule], // Brings in PluginManagerService + controllers: [PluginsController], +}) +export class PluginsModule {} diff --git a/packages/pieces/platform/quickbooks/package.json b/packages/pieces/platform/quickbooks/package.json index 5896e3c3..d684ec64 100644 --- a/packages/pieces/platform/quickbooks/package.json +++ b/packages/pieces/platform/quickbooks/package.json @@ -23,7 +23,6 @@ "vitest": "^4.0.18" }, "dependencies": { - "@soopa/credentials": "workspace:*", "@soopa/piece-framework": "workspace:*", "dayjs": "^1.11.19" } diff --git a/packages/pieces/platform/registry/package.json b/packages/pieces/platform/registry/package.json index 033d757e..8ef6921a 100644 --- a/packages/pieces/platform/registry/package.json +++ b/packages/pieces/platform/registry/package.json @@ -14,20 +14,25 @@ }, "scripts": { "dev": "tsc --watch --preserveWatchOutput", - "build": "tsc" + "build": "tsc", + "test": "vitest run", + "test:cov": "vitest run --coverage" }, "dependencies": { "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@soopa/cache": "workspace:*", - "@soopa/credentials": "workspace:*", "@soopa/database": "workspace:*", "@soopa/piece-framework": "workspace:*", - "drizzle-orm": "^0.45.1" + "@soopa/queue": "workspace:*", + "drizzle-orm": "^0.45.1", + "live-plugin-manager": "^1.1.0", + "piscina": "^5.1.4" }, "devDependencies": { "@nestjs/testing": "^11.1.11", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^2.1.9" }, "peerDependencies": { "@nestjs/common": "^11.0.1" diff --git a/packages/pieces/platform/registry/src/index.ts b/packages/pieces/platform/registry/src/index.ts index a9a5a786..964120da 100644 --- a/packages/pieces/platform/registry/src/index.ts +++ b/packages/pieces/platform/registry/src/index.ts @@ -3,3 +3,5 @@ export * from './pieces/piece-loader.service.js'; export * from './pieces/pieces.module.js'; export * from './metadata/metadata-discovery.service.js'; export * from './metadata/metadata.module.js'; +export * from './pieces/plugin-manager.service.js'; +export * from './pieces/execution-worker.service.js'; diff --git a/packages/pieces/platform/registry/src/pieces/execution-worker.service.spec.ts b/packages/pieces/platform/registry/src/pieces/execution-worker.service.spec.ts new file mode 100644 index 00000000..3562be8d --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/execution-worker.service.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ExecutionWorkerService } from './execution-worker.service.js'; + +// Mock Piscina +vi.mock('piscina', () => { + return { + default: vi.fn().mockImplementation(() => { + return { + run: vi.fn() + }; + }) + }; +}); + +describe('ExecutionWorkerService', () => { + let service: ExecutionWorkerService; + + beforeEach(() => { + vi.useFakeTimers(); + service = new ExecutionWorkerService(); + // Manually trigger onModuleInit to construct the pool + service.onModuleInit(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('executeTrigger', () => { + it('should successfully execute a payload via Piscina', async () => { + const mockRun = vi.spyOn((service as any).pool, 'run').mockResolvedValue('success'); + + const result = await service.executeTrigger('/path/script.js', 'myTrigger', { data: 123 }); + + expect(result).toBe('success'); + expect(mockRun).toHaveBeenCalledWith( + { scriptPath: '/path/script.js', triggerName: 'myTrigger', payload: { data: 123 } }, + { signal: expect.any(AbortSignal) } + ); + }); + + it('should properly abort and throw an error if the Piscina task takes longer than 30s', async () => { + // Simulate a Piscina run that hangs indefinitely until aborted + const mockRun = vi.spyOn((service as any).pool, 'run').mockImplementation((task, options: any) => { + return new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => { + // Reject on next tick via microtask queue to avoid unhandled rejection bubbling + // from EventTarget listener, while bypassing fake timers deadlock. + queueMicrotask(() => reject(new Error('AbortError'))); + }); + }); + }); + + // Start the execution without awaiting it yet. + // Attach a dummy catch immediately so Node doesn't log an Unhandled Rejection before we expect() it. + const executePromise = service.executeTrigger('/path/hang.js', 'badTrigger', {}); + executePromise.catch(() => {}); + + // Advance timers by 30 seconds to trigger the setTimeout AbortController + await vi.advanceTimersByTimeAsync(30000); + + await expect(executePromise).rejects.toThrow('AbortError'); + expect(mockRun).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/pieces/platform/registry/src/pieces/execution-worker.service.ts b/packages/pieces/platform/registry/src/pieces/execution-worker.service.ts new file mode 100644 index 00000000..7631f8b0 --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/execution-worker.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import Piscina from 'piscina'; +import * as path from 'path'; +import * as url from 'url'; + +/** + * Manages execution of dynamically downloaded piece code inside Node.js Worker Threads + * using a high-performance pre-warmed worker pool. + */ +@Injectable() +export class ExecutionWorkerService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ExecutionWorkerService.name); + private pool!: any; + + onModuleInit() { + this.logger.log('Initializing Piscina Worker Pool...'); + + // Resolve the path to the compiled worker script. + // Piscina requires a physical file path to spawn threads. + const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + const workerScript = path.join(__dirname, 'piece-execution.worker.js'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment + this.pool = new (Piscina as any)({ + filename: workerScript, + minThreads: 2, // Pre-warm 2 threads immediately for zero-latency boots + maxThreads: 8, // Scale up to 8 threads under load + idleTimeout: 60000, // Terminate scaled threads after 1 min of inactivity + }); + } + + async onModuleDestroy() { + this.logger.log('Draining Piscina Worker Pool...'); + await this.pool.destroy(); + } + + /** + * Executes a trigger function from a piece safely in a background worker thread. + * @param scriptPath The physical path to the installed plugin bundle on disk + * @param triggerName The name of the trigger to execute + * @param payload The webhook payload data + */ + async executeTrigger(scriptPath: string, triggerName: string, payload: any): Promise { + this.logger.debug(`Dispatching ${triggerName} to Worker Pool`); + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => { + this.logger.warn(`Execution timeout reached for ${scriptPath}. Aborting worker thread.`); + abortController.abort(); + }, 30000); // 30-second strict execution limit + + try { + // Execute the function in an available worker thread. + // This is near-instant because the V8 Isolates are already booted. + const result = await this.pool.run( + { scriptPath, triggerName, payload }, + { signal: abortController.signal } + ); + return result; + } catch (error: any) { + this.logger.error(`Worker execution failed for ${scriptPath}: ${error.message}`); + throw error; + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts b/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts new file mode 100644 index 00000000..44a2f4c1 --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mocked } from 'vitest'; +import { MigrationWorkerService } from './migration-worker.service.js'; +import type { DrizzleDb } from '@soopa/database'; +import { QueueName } from '@soopa/queue'; +import type { IQueueService } from '@soopa/queue'; +import * as migrator from 'drizzle-orm/node-postgres/migrator'; + +vi.mock('drizzle-orm/node-postgres/migrator', () => ({ + migrate: vi.fn().mockResolvedValue(undefined) +})); + +describe('MigrationWorkerService', () => { + let service: MigrationWorkerService; + let globalDb: Mocked; + let queueService: Mocked; + + beforeEach(() => { + globalDb = {} as unknown as Mocked; + + queueService = { + send: vi.fn(), + consume: vi.fn(), + } as unknown as Mocked; + + service = new MigrationWorkerService(globalDb, queueService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('runBackgroundMigrations', () => { + it('should Fan-out to multiple queue messages if tenantId is missing', async () => { + // Mock the internal getActiveTenants helper + const getActiveTenantsSpy = vi.spyOn(service as any, 'getActiveTenants').mockResolvedValue([ + { id: 't1' }, + { id: 't2' } + ]); + + await service.runBackgroundMigrations({ + pluginLocation: '/tmp/plugin', + pieceName: '@soopa/piece-migrate' + }); + + expect(getActiveTenantsSpy).toHaveBeenCalledWith('@soopa/piece-migrate'); + + // It should have fanned out into 2 explicit queue messages + expect(queueService.send).toHaveBeenCalledTimes(2); + expect(queueService.send).toHaveBeenNthCalledWith(1, QueueName.TenantProvisionQueue, { + pluginLocation: '/tmp/plugin', + pieceName: '@soopa/piece-migrate', + tenantId: 't1' + }); + expect(queueService.send).toHaveBeenNthCalledWith(2, QueueName.TenantProvisionQueue, { + pluginLocation: '/tmp/plugin', + pieceName: '@soopa/piece-migrate', + tenantId: 't2' + }); + + // Should not run migrations itself in Fan-Out mode + expect(migrator.migrate).not.toHaveBeenCalled(); + }); + + it('should execute single-tenant migration if tenantId is provided', async () => { + const getActiveTenantsSpy = vi.spyOn(service as any, 'getActiveTenants'); + const getTenantDbSpy = vi.spyOn(service as any, 'getTenantDbConnection').mockResolvedValue({} as any); + + await service.runBackgroundMigrations({ + pluginLocation: '/tmp/plugin', + pieceName: '@soopa/piece-migrate', + tenantId: 'tenant-555' + }); + + // Should bypass fan-out + expect(getActiveTenantsSpy).not.toHaveBeenCalled(); + expect(queueService.send).not.toHaveBeenCalled(); + + // Should resolve the specific tenant DB and run migration + expect(getTenantDbSpy).toHaveBeenCalledWith('tenant-555'); + expect(migrator.migrate).toHaveBeenCalledWith(expect.any(Object), { migrationsFolder: '/tmp/plugin/drizzle/migrations' }); + }); + + it('should throw an error if single-tenant migration fails to trigger Dead-Letter Queue', async () => { + vi.spyOn(service as any, 'getTenantDbConnection').mockResolvedValue({} as any); + (migrator.migrate as unknown as ReturnType).mockRejectedValueOnce(new Error('DB Timeout')); + + await expect(service.runBackgroundMigrations({ + pluginLocation: '/tmp/plugin', + pieceName: '@soopa/piece-migrate', + tenantId: 'tenant-999' + })).rejects.toThrow('DB Timeout'); + }); + }); +}); diff --git a/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts new file mode 100644 index 00000000..538530a0 --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger, Inject, OnModuleInit } from '@nestjs/common'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { DATABASE_CONNECTION } from '@soopa/database'; +import type { DrizzleDb } from '@soopa/database'; +import { QUEUE_SERVICE, QueueName } from '@soopa/queue'; +import type { IQueueService, PluginMigrationEvent } from '@soopa/queue'; + +/** + * Handles Just-In-Time provisioning of dynamically downloaded domain tables. + * Iterates through active tenant databases and runs the Drizzle migrator. + */ +@Injectable() +export class MigrationWorkerService implements OnModuleInit { + private readonly logger = new Logger(MigrationWorkerService.name); + + constructor( + @Inject(DATABASE_CONNECTION) private readonly globalDb: DrizzleDb, + @Inject(QUEUE_SERVICE) private readonly queueService: IQueueService + ) {} + + onModuleInit() { + this.logger.log('Registering SQS Queue Consumer for Plugin Migrations...'); + this.queueService.consume( + QueueName.TenantProvisionQueue, + async (rawMsg: unknown) => { + const event = rawMsg as PluginMigrationEvent; + // The queue might be used for other tenant provision events, so check if it's ours + if (event.pluginLocation && event.pieceName) { + await this.runBackgroundMigrations(event); + } + } + ); + } + + /** + * Executes Drizzle migrations for a dynamically loaded piece using SQS Fan-Out. + */ + async runBackgroundMigrations(event: PluginMigrationEvent) { + if (!event.tenantId) { + // --- FAN-OUT MODE --- + this.logger.log(`[Fan-Out] Starting fan-out for ${event.pieceName} from ${event.pluginLocation}`); + const activeTenants = await this.getActiveTenants(event.pieceName); + + this.logger.log(`[Fan-Out] Found ${activeTenants.length} active tenants. Dispatching single-tenant SQS messages...`); + + // Dispatch 1 SQS message per tenant. This perfectly parallelizes migrations + // across all available workers and prevents a single tenant's failure from retrying the entire batch. + for (const tenant of activeTenants) { + await this.queueService.send(QueueName.TenantProvisionQueue, { + ...event, + tenantId: tenant.id + }); + } + this.logger.log(`[Fan-Out] Fan-out complete for ${event.pieceName}.`); + return; + } + + // --- WORKER MODE (Single Tenant) --- + this.logger.log(`[Worker] Executing migration for tenant ${event.tenantId} (Piece: ${event.pieceName})`); + const migrationsFolder = `${event.pluginLocation}/drizzle/migrations`; + + try { + const tenantDb = await this.getTenantDbConnection(event.tenantId); + await migrate(tenantDb, { migrationsFolder }); + this.logger.debug(`[Worker] Successfully migrated tenant: ${event.tenantId}`); + } catch (error: any) { + this.logger.error(`[Worker] Failed to migrate tenant ${event.tenantId}: ${error.message}`); + // Throwing allows SQS to automatically dead-letter queue this specific tenant's job + // without affecting the migrations of other tenants. + throw error; + } + } + + // Mocks for demonstration purposes + private async getActiveTenants(pieceName: string) { + // e.g., SELECT * FROM tenant_connections WHERE piece_id = $1 + return [ + { id: 'tenant_1', dbConnectionUrl: 'postgres://localhost/tenant1' }, + { id: 'tenant_2', dbConnectionUrl: 'postgres://localhost/tenant2' } + ]; + } + + private async getTenantDbConnection(connectionUrl: string): Promise { + // In production, this would resolve from your TenantDatabaseManager connection pool + // Returning globalDb here just to satisfy the mock signature + return this.globalDb; + } +} diff --git a/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts new file mode 100644 index 00000000..8ba64d68 --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts @@ -0,0 +1,53 @@ +import * as path from 'node:path'; + +/** + * Enterprise Piece Execution Worker + * + * This file is the entrypoint for the Piscina Worker Pool. + * It executes untrusted third-party integration code inside an isolated Node.js Worker Thread + * to prevent the main event loop from blocking (DoS protection). + */ + +interface ExecutionData { + scriptPath: string; + triggerName: string; + payload: any; +} + +export default async function runIntegrationPiece(data: ExecutionData): Promise { + const { scriptPath, triggerName, payload } = data; + + try { + // --------------------------------------------------------- + // ENTERPRISE SECURITY: Path Traversal Validation + // --------------------------------------------------------- + // Ensure the requested script path physically resides within the allowed + // plugin sandbox directory or the workspace fallback. + const resolvedPath = path.resolve(scriptPath); + const isLocalWorkspace = resolvedPath.includes('/packages/pieces/platform/') || resolvedPath.includes('/packages/pieces/application/'); + const isGlobalPlugins = resolvedPath.startsWith('/tmp/soopa-plugins/') || resolvedPath.startsWith('/tmp/plugin'); + + if (!isLocalWorkspace && !isGlobalPlugins) { + throw new Error(`SECURITY ALERT: Sandbox violation detected. Path traversal attempted: ${resolvedPath}`); + } + + // Dynamically require the managed bundle from the disk + // Using __non_webpack_require__ or dynamic import if using a bundler + // eslint-disable-next-line @typescript-eslint/no-var-requires + const piece = require(resolvedPath); + + // Locate the trigger (handling both default export and named export variations) + const trigger = piece.default?.triggers?.[triggerName] || piece.triggers?.[triggerName]; + + if (!trigger) { + throw new Error(`Trigger ${triggerName} not found in piece ${scriptPath}`); + } + + // Execute the piece logic with the payload + const result = await trigger.run({ payload }); + return result; + } catch (error: any) { + // Piscina automatically forwards errors back to the main thread + throw new Error(`Integration Error: ${error.message}`); + } +} diff --git a/packages/pieces/platform/registry/src/pieces/piece-loader.service.spec.ts b/packages/pieces/platform/registry/src/pieces/piece-loader.service.spec.ts new file mode 100644 index 00000000..9f5abd5a --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/piece-loader.service.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mocked } from 'vitest'; +import { PieceLoaderService } from './piece-loader.service.js'; +import { PluginManagerService } from './plugin-manager.service.js'; +import type { DrizzleDb } from '@soopa/database'; + +describe('PieceLoaderService', () => { + let service: PieceLoaderService; + let pluginManagerService: Mocked; + let mockDb: Mocked; + + beforeEach(() => { + pluginManagerService = { + ensurePiece: vi.fn(), + requirePiece: vi.fn(), + } as unknown as Mocked; + + const mockSelect = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([ + { name: 'slack', packageName: '@soopa/piece-slack', enabled: true } + ]) + }; + + mockDb = { + select: vi.fn().mockReturnValue(mockSelect) + } as unknown as Mocked; + + service = new PieceLoaderService(null, pluginManagerService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should successfully load pieces via pluginManager during Startup Sync', async () => { + pluginManagerService.ensurePiece.mockResolvedValueOnce(undefined); + pluginManagerService.requirePiece.mockReturnValueOnce({ + slackPiece: { + name: 'slack', + displayName: 'Slack', + description: 'desc', + logoUrl: 'url', + actions: {}, + triggers: {}, + auth: {}, + categories: [] + } + }); + + const result = await service.loadEnabledPieces(mockDb); + + expect(pluginManagerService.ensurePiece).toHaveBeenCalledWith('@soopa/piece-slack', 'latest'); + expect(pluginManagerService.requirePiece).toHaveBeenCalledWith('@soopa/piece-slack'); + expect(result.length).toBe(1); + expect(result[0].name).toBe('slack'); + }); + + it('should fallback to local workspace resolution if ensurePiece throws', async () => { + pluginManagerService.ensurePiece.mockRejectedValueOnce(new Error('404 Not Found')); + + // We expect the catch block to run, which tries local import. + // Since @soopa/piece-slack isn't actually in our local node_modules for the test environment, + // it will throw a module resolution error and fail to extract. + // We just want to ensure it caught the error and didn't crash the loader. + const result = await service.loadEnabledPieces(mockDb); + + expect(pluginManagerService.ensurePiece).toHaveBeenCalledWith('@soopa/piece-slack', 'latest'); + // It should have caught the 404 and returned 0 pieces instead of blowing up the boot process + expect(result.length).toBe(0); + }); +}); diff --git a/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts b/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts index 29316b26..27493b85 100644 --- a/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts +++ b/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, Inject, Optional } from '@nestjs/common'; import { eq } from 'drizzle-orm'; import type { Piece } from '@soopa/piece-framework'; import { pieces, type DrizzleDb } from '@soopa/database'; +import { PluginManagerService } from './plugin-manager.service.js'; /** * Injection token for the host application's `import.meta.url`. @@ -29,6 +30,7 @@ export class PieceLoaderService { constructor( @Optional() @Inject(PIECE_LOADER_ANCHOR_URL) private readonly anchorUrl: string | null, + private readonly pluginManager: PluginManagerService, ) {} async loadEnabledPieces(db: DrizzleDb): Promise { @@ -46,31 +48,34 @@ export class PieceLoaderService { for (const row of rows) { try { - // Resolve through the host application's module graph so pnpm strict - // linking doesn't hide pieces that are installed in apps/api but - // not declared as explicit deps of @soopa/engine. - // - // Anchor precedence: - // 1. PIECE_LOADER_ANCHOR_URL token (set by host via PiecesModule.forRoot) - // — most reliable: always the host's actual file path on disk. - // 2. import.meta.url of this file inside the engine package - // — fallback: walks up to the monorepo root's node_modules. - // 3. Bare specifier (pnpm strict-mode fallback, may fail) - let resolvedPath = row.packageName; + let mod: Record; + try { - const { createRequire } = await import('node:module'); - const { fileURLToPath, pathToFileURL } = await import('node:url'); - const anchor = this.anchorUrl ?? import.meta.url; - const anchorFile = anchor.startsWith('file://') - ? anchor - : pathToFileURL(anchor).href; - const hostRequire = createRequire(fileURLToPath(anchorFile)); - resolvedPath = pathToFileURL(hostRequire.resolve(row.packageName)).href; - } catch { - // Non-fatal: fall through to bare-specifier import below. + // --- ENTERPRISE STARTUP SYNCHRONIZATION --- + // Ensure the plugin is downloaded locally to /opt/nexiom/plugins + await this.pluginManager.ensurePiece(row.packageName, 'latest'); + + // Dynamically load the piece from the plugin manager's disk cache + mod = this.pluginManager.requirePiece(row.packageName) as Record; + } catch (downloadErr: any) { + this.logger.warn(`Startup Sync failed for ${row.packageName} (${downloadErr.message}). Falling back to local workspace resolution...`); + + // --- LOCAL DEVELOPMENT FALLBACK --- + // Allows `pnpm dev` to load unpublished pieces directly from the monorepo node_modules + let resolvedPath = row.packageName; + try { + const { createRequire } = await import('node:module'); + const { fileURLToPath, pathToFileURL } = await import('node:url'); + const anchor = this.anchorUrl ?? import.meta.url; + const anchorFile = anchor.startsWith('file://') ? anchor : pathToFileURL(anchor).href; + const hostRequire = createRequire(fileURLToPath(anchorFile)); + resolvedPath = pathToFileURL(hostRequire.resolve(row.packageName)).href; + } catch { + // Non-fatal: fall through to bare-specifier import below. + } + + mod = (await import(resolvedPath)) as Record; } - - const mod = (await import(resolvedPath)) as Record; const piece = this.extractPiece(mod, row.name); if (piece) { loaded.push(piece); diff --git a/packages/pieces/platform/registry/src/pieces/pieces.module.ts b/packages/pieces/platform/registry/src/pieces/pieces.module.ts index 574610c5..7d6a9abe 100644 --- a/packages/pieces/platform/registry/src/pieces/pieces.module.ts +++ b/packages/pieces/platform/registry/src/pieces/pieces.module.ts @@ -4,6 +4,9 @@ import { PIECES, } from './piece-registry.service.js'; import { PieceLoaderService, PIECE_LOADER_ANCHOR_URL } from './piece-loader.service.js'; +import { PluginManagerService } from './plugin-manager.service.js'; +import { ExecutionWorkerService } from './execution-worker.service.js'; +import { MigrationWorkerService } from './migration-worker.service.js'; import { DATABASE_CONNECTION } from '@soopa/database'; import type { DrizzleDb } from '@soopa/database'; import type { Piece } from '@soopa/piece-framework'; @@ -19,6 +22,9 @@ const SHARED_PROVIDERS = [ inject: [DATABASE_CONNECTION, PieceLoaderService], }, PieceRegistryService, + PluginManagerService, + ExecutionWorkerService, + MigrationWorkerService, ]; /** @@ -43,7 +49,7 @@ export class PiecesModule { { provide: PIECE_LOADER_ANCHOR_URL, useValue: options.anchorUrl }, ...SHARED_PROVIDERS, ], - exports: [PieceRegistryService, PieceLoaderService, PIECES], + exports: [PieceRegistryService, PieceLoaderService, PluginManagerService, ExecutionWorkerService, MigrationWorkerService, PIECES], }; } diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts new file mode 100644 index 00000000..c1c2fdfb --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Mocked } from 'vitest'; +import { PluginManagerService } from './plugin-manager.service.js'; +import { QueueName } from '@soopa/queue'; +import type { IQueueService } from '@soopa/queue'; + +// Mock live-plugin-manager so we don't actually hit the network or filesystem +vi.mock('live-plugin-manager', () => { + return { + PluginManager: vi.fn().mockImplementation(() => { + return { + getInfo: vi.fn(), + install: vi.fn(), + require: vi.fn(), + }; + }) + }; +}); + +describe('PluginManagerService', () => { + let service: PluginManagerService; + let queueService: Mocked; + + beforeEach(() => { + queueService = { + send: vi.fn(), + consume: vi.fn(), + } as unknown as Mocked; + + service = new PluginManagerService(queueService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('ensurePiece', () => { + it('should skip download if the piece is already installed locally', async () => { + const mockGetInfo = vi.spyOn((service as any).manager, 'getInfo').mockReturnValue({ version: '1.0.0', location: '/tmp' }); + const mockInstall = vi.spyOn((service as any).manager, 'install'); + + const result = await service.ensurePiece('@soopa/piece-test', 'latest'); + + expect(result).toEqual({ version: '1.0.0', location: '/tmp' }); + expect(mockGetInfo).toHaveBeenCalledWith('@soopa/piece-test'); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + it('should install piece if it is not installed locally', async () => { + const mockGetInfo = vi.spyOn((service as any).manager, 'getInfo').mockReturnValue(undefined); + const mockInstall = vi.spyOn((service as any).manager, 'install').mockResolvedValue({ version: '1.2.0', location: '/tmp/new' }); + + const result = await service.ensurePiece('@soopa/piece-new', '1.2.0'); + + expect(result).toEqual({ version: '1.2.0', location: '/tmp/new' }); + expect(mockGetInfo).toHaveBeenCalledWith('@soopa/piece-new'); + expect(mockInstall).toHaveBeenCalledWith('@soopa/piece-new', '1.2.0'); + }); + }); + + describe('installPiece', () => { + it('should install piece and immediately dispatch a migration event to SQS', async () => { + const mockInstall = vi.spyOn((service as any).manager, 'install').mockResolvedValue({ version: '2.0.0', location: '/tmp/plugin' }); + + const result = await service.installPiece('@soopa/piece-migrate', '2.0.0'); + + expect(result).toEqual({ version: '2.0.0', location: '/tmp/plugin' }); + expect(mockInstall).toHaveBeenCalledWith('@soopa/piece-migrate', '2.0.0'); + expect(queueService.send).toHaveBeenCalledWith(QueueName.TenantProvisionQueue, { + pluginLocation: '/tmp/plugin', + pieceName: '@soopa/piece-migrate' + }); + }); + + it('should throw an error and NOT dispatch event if installation fails', async () => { + const mockInstall = vi.spyOn((service as any).manager, 'install').mockRejectedValue(new Error('NPM is down')); + + await expect(service.installPiece('@soopa/piece-migrate', 'latest')).rejects.toThrow('NPM is down'); + expect(queueService.send).not.toHaveBeenCalled(); + }); + }); + + describe('requirePiece', () => { + it('should call manager.require', () => { + const mockRequire = vi.spyOn((service as any).manager, 'require').mockReturnValue({ default: {} }); + const result = service.requirePiece('@soopa/piece-local'); + expect(result).toEqual({ default: {} }); + expect(mockRequire).toHaveBeenCalledWith('@soopa/piece-local'); + }); + }); +}); diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts new file mode 100644 index 00000000..5c52663c --- /dev/null +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts @@ -0,0 +1,84 @@ +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { PluginManager } from 'live-plugin-manager'; +import * as path from 'path'; +import * as os from 'os'; +import { QUEUE_SERVICE, QueueName } from '@soopa/queue'; +import type { IQueueService, PluginMigrationEvent } from '@soopa/queue'; + +@Injectable() +export class PluginManagerService { + private readonly logger = new Logger(PluginManagerService.name); + private manager: PluginManager; + private readonly pluginsPath: string; + + constructor( + @Inject(QUEUE_SERVICE) private readonly queueService: IQueueService + ) { + // Default to a local .plugins directory for development, or /opt/soopa/plugins in prod + this.pluginsPath = process.env.PLUGINS_PATH || path.join(os.tmpdir(), 'soopa-plugins'); + this.logger.log(`Initializing Live Plugin Manager at: ${this.pluginsPath}`); + this.manager = new PluginManager({ + pluginsPath: this.pluginsPath, + npmRegistryUrl: process.env.NPM_REGISTRY_URL || 'https://registry.npmjs.org/' + }); + } + + /** + * Downloads and installs a piece from the NPM registry. + * @param packageName The name of the piece (e.g. '@soopa/piece-revenova') + * @param version The specific version to install, or 'latest' + */ + async installPiece(packageName: string, version: string = 'latest'): Promise { + this.logger.log(`Downloading piece: ${packageName}@${version}...`); + try { + const pluginInfo = await this.manager.install(packageName, version); + this.logger.log(`Successfully installed ${packageName}@${pluginInfo.version} to ${pluginInfo.location}`); + + // Dispatch migration job to SQS queue for guaranteed execution + const migrationEvent: PluginMigrationEvent = { + pluginLocation: pluginInfo.location, + pieceName: packageName + }; + + await this.queueService.send(QueueName.TenantProvisionQueue, migrationEvent); + this.logger.log(`Dispatched migration job for ${packageName} to SQS`); + + return pluginInfo; + } catch (error) { + this.logger.error(`Failed to install piece ${packageName}@${version}`, error); + throw error; + } + } + + /** + * Retrieves information about an installed piece, including its physical disk location. + */ + getPieceInfo(packageName: string) { + return this.manager.getInfo(packageName); + } + + /** + * Ensures a piece is installed on disk without re-triggering a migration if it already exists. + * Useful for Startup Synchronization. + */ + async ensurePiece(packageName: string, version: string = 'latest'): Promise { + const existing = this.getPieceInfo(packageName); + if (existing) { + this.logger.debug(`Piece ${packageName} already exists on disk. Skipping download.`); + return existing; + } + + this.logger.log(`Startup Sync: Downloading missing piece ${packageName}...`); + const pluginInfo = await this.manager.install(packageName, version); + this.logger.log(`Startup Sync: Installed ${packageName} to ${pluginInfo.location}`); + return pluginInfo; + } + + /** + * Dynamically requires the piece code (for trusted internal execution). + * Note: In a true zero-downtime architecture, this is passed to a Worker Thread instead. + */ + requirePiece(packageName: string) { + return this.manager.require(packageName); + } +} diff --git a/packages/pieces/platform/registry/vitest.config.mts b/packages/pieces/platform/registry/vitest.config.mts new file mode 100644 index 00000000..3314af40 --- /dev/null +++ b/packages/pieces/platform/registry/vitest.config.mts @@ -0,0 +1,82 @@ +import { defineConfig } from 'vitest/config'; +import swc from 'unplugin-swc'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + root: './', + alias: { + '@src': path.resolve(__dirname, 'src'), + }, + server: { + deps: { + external: ['@nexiom/auth', '@nexiom/identity'], + }, + }, + env: { + SYSTEM_TENANT_ID: '00000000-0000-0000-0000-000000000000', + OWNER_ROLE_ID: 'owner', + ADMIN_ROLE_ID: 'admin', + MEMBER_ROLE_ID: 'member', + }, + coverage: { + provider: 'v8', + exclude: [ + 'node_modules/**', + 'dist/**', + '**/*.d.ts', + 'drizzle.config.ts', + 'src/main.ts', + 'src/**/*.module.ts', + 'src/**/*.dto.ts', + 'src/**/*.entity.ts', + 'src/**/*.interface.ts', + 'src/**/*.abstract.ts', + 'src/**/*.decorator.ts', + 'src/**/*.mock.ts', + 'src/scripts/**', + 'src/modules/ai/**', + 'src/pieces/piece-execution.worker.ts', + 'src/db/reset-e2e.ts', + 'src/db/db-cli.ts', + 'src/db/schema.ts', + 'src/db/db.provider.ts', + 'test/**', + '**/*.spec.ts', + '**/*.e2e-spec.ts', + 'src/**/index.ts', + '**/*.config.*', + ], + reporter: ['text', 'json', 'html'], + thresholds: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, + }, + }, + plugins: [ + // Essential for NestJS DI to work correctly + swc.vite({ + module: { type: 'es6' }, + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + tsx: false, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: true, + }, + }, + }), + ], +}); diff --git a/packages/pieces/platform/salesforce/package.json b/packages/pieces/platform/salesforce/package.json index 7446bfbc..c6ccfee4 100644 --- a/packages/pieces/platform/salesforce/package.json +++ b/packages/pieces/platform/salesforce/package.json @@ -28,7 +28,6 @@ "vitest": "^4.0.18" }, "dependencies": { - "@soopa/credentials": "workspace:*", "@soopa/piece-framework": "workspace:*", "dayjs": "^1.11.19" } diff --git a/packages/queue/src/events/plugin-migration.event.ts b/packages/queue/src/events/plugin-migration.event.ts new file mode 100644 index 00000000..15804f28 --- /dev/null +++ b/packages/queue/src/events/plugin-migration.event.ts @@ -0,0 +1,5 @@ +export interface PluginMigrationEvent { + pluginLocation: string; + pieceName: string; + tenantId?: string; +} diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index cfa345ad..825fbb7e 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -12,3 +12,4 @@ export type { } from "./queue.module.js"; export { createQueueModuleOptions } from "./queue.options.js"; export type { ProvisionDatabaseEvent } from "./events/provision-database.event.js"; +export type { PluginMigrationEvent } from "./events/plugin-migration.event.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6a55ef2..ff5d2705 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1372,9 +1372,6 @@ importers: packages/pieces/platform/quickbooks: dependencies: - '@soopa/credentials': - specifier: workspace:* - version: link:../../../credentials '@soopa/piece-framework': specifier: workspace:* version: link:../framework @@ -1400,18 +1397,24 @@ importers: '@soopa/cache': specifier: workspace:* version: link:../../../cache - '@soopa/credentials': - specifier: workspace:* - version: link:../../../credentials '@soopa/database': specifier: workspace:* version: link:../../../database '@soopa/piece-framework': specifier: workspace:* version: link:../framework + '@soopa/queue': + specifier: workspace:* + version: link:../../../queue drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) + live-plugin-manager: + specifier: ^1.1.0 + version: 1.1.0 + piscina: + specifier: ^5.1.4 + version: 5.1.4 devDependencies: '@nestjs/testing': specifier: ^11.1.11 @@ -1419,12 +1422,12 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.5.0)(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@25.5.0)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) packages/pieces/platform/salesforce: dependencies: - '@soopa/credentials': - specifier: workspace:* - version: link:../../../credentials '@soopa/piece-framework': specifier: workspace:* version: link:../framework @@ -4973,6 +4976,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -5069,6 +5075,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -5099,6 +5108,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/lockfile@1.0.4': + resolution: {integrity: sha512-Q8oFIHJHr+htLrTXN2FuZfg+WXVHQRwU/hC2GpUu+Q8e3FUM9EDkS2pE3R2AO1ZGu56f479ybdMCNF1DAu8cAQ==} + '@types/lodash.isequal@4.5.8': resolution: {integrity: sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==} @@ -5120,6 +5132,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -5167,6 +5182,9 @@ packages: '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -5194,6 +5212,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/tar@6.1.13': + resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -5203,6 +5224,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/url-join@4.0.3': + resolution: {integrity: sha512-3l1qMm3wqO0iyC5gkADzT95UVW7C/XXcdvUcShOideKF0ddgVRErEQQJXBd2kvQm+aSgqhBGHGB38TgMeT57Ww==} + '@types/uuid@11.0.0': resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. @@ -5691,6 +5715,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.11: resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} @@ -5830,6 +5858,10 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} @@ -6083,6 +6115,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -6409,6 +6445,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + data-urls@6.0.1: resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} engines: {node: '>=20'} @@ -6519,6 +6559,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -6865,6 +6909,11 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -7100,6 +7149,14 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fetch-blob@4.0.0: + resolution: {integrity: sha512-nPmnhRmpNMjYWnp9EBMGs6z5lq9RXed5W1vuZcECrsDVQInM8AMQSooVb3X183Aole60adzjWbH9qlRFWzDDTA==} + engines: {node: '>=16.7'} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -7220,6 +7277,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -7271,6 +7332,10 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -7333,6 +7398,10 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -7592,6 +7661,10 @@ packages: resolution: {integrity: sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==} engines: {node: '>=12.22.0'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -8087,6 +8160,9 @@ packages: resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} engines: {node: '>=20.0.0'} + live-plugin-manager@1.1.0: + resolution: {integrity: sha512-2rPHP2H5EDtGzsOYbMaaKA/XgEBKEmiTHNaPIu7xn8Qygv2MAT8D3WobVy3dftwWcCUG2kFukb4UtNWsd8gi6Q==} + load-esm@1.0.3: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} @@ -8115,6 +8191,9 @@ packages: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lockfile@1.0.4: + resolution: {integrity: sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==} + lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} @@ -8188,10 +8267,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} - engines: {node: 20 || >=22} - lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -8199,6 +8274,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lucide-react@0.562.0: resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==} peerDependencies: @@ -8452,6 +8531,18 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -8460,10 +8551,19 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mock-json-schema@1.1.2: resolution: {integrity: sha512-3IyduYlhfzPy+nFN8wxUjloUi1hM7l8lN5LITuauUNMQltynJIOfLf/DADwTAp2d6kvSBtWojly1EuxX5B0WkA==} @@ -8567,6 +8667,10 @@ packages: '@nestjs/swagger': optional: true + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + next@16.1.1: resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==} engines: {node: '>=20.9.0'} @@ -8594,6 +8698,11 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -8601,6 +8710,10 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-fetch-commonjs@3.3.2: + resolution: {integrity: sha512-VBlAiynj3VMLrotgwOS3OyECFxas5y7ltLcK4t41lMUZeaK15Ym4QRkqN0EQKAFL42q9i21EPKjzLUPfltR72A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-machine-id@1.1.12: resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} @@ -8795,6 +8908,14 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -8986,6 +9107,10 @@ packages: piscina@4.9.2: resolution: {integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==} + piscina@5.1.4: + resolution: {integrity: sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==} + engines: {node: '>=20.x'} + pkg-dir@7.0.0: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} @@ -9315,6 +9440,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -9960,9 +10089,21 @@ packages: resolution: {integrity: sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==} engines: {node: '>=8.0.0'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + sockjs@0.3.24: resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -10259,6 +10400,11 @@ packages: tar-stream@3.1.8: resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar 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 + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -10648,6 +10794,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -10901,6 +11050,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -11076,6 +11229,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -15682,6 +15838,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -15809,6 +15967,10 @@ snapshots: '@types/express-serve-static-core': 5.1.0 '@types/serve-static': 2.2.0 + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 25.5.0 + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -15840,6 +16002,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 25.0.5 + '@types/lockfile@1.0.4': {} + '@types/lodash.isequal@4.5.8': dependencies: '@types/lodash': 4.17.24 @@ -15858,6 +16022,11 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 25.5.0 + form-data: 4.0.5 + '@types/node@12.20.55': {} '@types/node@20.19.28': @@ -15913,6 +16082,8 @@ snapshots: '@types/retry@0.12.2': {} + '@types/semver@7.7.1': {} + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -15955,6 +16126,11 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/tar@6.1.13': + dependencies: + '@types/node': 25.5.0 + minipass: 4.2.8 + '@types/trusted-types@2.0.7': optional: true @@ -15962,6 +16138,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/url-join@4.0.3': {} + '@types/uuid@11.0.0': dependencies: uuid: 13.0.0 @@ -16808,6 +16986,10 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + ast-v8-to-istanbul@0.3.11: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -16949,6 +17131,8 @@ snapshots: baseline-browser-mapping@2.9.19: {} + basic-ftp@5.3.1: {} + batch@0.6.1: {} bath-es5@3.0.3: {} @@ -17220,6 +17404,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@2.0.0: {} + chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} @@ -17545,6 +17731,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@6.0.2: {} + data-urls@6.0.1: dependencies: whatwg-mimetype: 5.0.0 @@ -17635,6 +17823,12 @@ snapshots: defu@6.1.4: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -17950,6 +18144,14 @@ snapshots: escape-string-regexp@4.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -18269,6 +18471,15 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fetch-blob@4.0.0: + dependencies: + node-domexception: 1.0.0 + fflate@0.8.2: {} figures@3.2.0: @@ -18424,6 +18635,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -18471,6 +18686,10 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + fs-monkey@1.1.0: {} fsevents@2.3.2: @@ -18532,6 +18751,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -18835,6 +19062,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -19322,6 +19551,28 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + live-plugin-manager@1.1.0: + dependencies: + '@types/debug': 4.1.13 + '@types/fs-extra': 9.0.13 + '@types/lockfile': 1.0.4 + '@types/node-fetch': 2.6.13 + '@types/semver': 7.7.1 + '@types/tar': 6.1.13 + '@types/url-join': 4.0.3 + debug: 4.4.3 + fetch-blob: 4.0.0 + formdata-polyfill: 4.0.10 + fs-extra: 10.1.0 + lockfile: 1.0.4 + node-fetch-commonjs: 3.3.2 + proxy-agent: 6.5.0 + semver: 7.7.4 + tar: 6.2.1 + url-join: 4.0.1 + transitivePeerDependencies: + - supports-color + load-esm@1.0.3: {} load-tsconfig@0.2.5: {} @@ -19346,6 +19597,10 @@ snapshots: dependencies: p-locate: 6.0.0 + lockfile@1.0.4: + dependencies: + signal-exit: 3.0.7 + lodash-es@4.17.23: {} lodash.debounce@4.0.8: {} @@ -19401,14 +19656,14 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.4: {} - lru-cache@11.2.6: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + lucide-react@0.562.0(react@19.2.3): dependencies: react: 19.2.3 @@ -19772,14 +20027,29 @@ snapshots: minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@4.2.8: {} + + minipass@5.0.0: {} + minipass@7.1.2: {} minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + mkdirp@0.5.6: dependencies: minimist: 1.2.8 + mkdirp@1.0.4: {} + mock-json-schema@1.1.2: dependencies: lodash: 4.17.21 @@ -19996,6 +20266,8 @@ snapshots: rxjs: 7.8.2 zod: 4.3.5 + netmask@2.1.1: {} + next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.3): dependencies: '@next/env': 16.1.1 @@ -20029,6 +20301,8 @@ snapshots: node-addon-api@7.1.1: optional: true + node-domexception@1.0.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.17.21 @@ -20040,6 +20314,11 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-fetch-commonjs@3.3.2: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + node-machine-id@1.1.12: {} node-releases@2.0.27: {} @@ -20303,6 +20582,24 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + package-json-from-dist@1.0.1: {} package-manager-detector@0.2.11: @@ -20361,8 +20658,8 @@ snapshots: path-scurry@2.0.1: dependencies: - lru-cache: 11.2.4 - minipass: 7.1.2 + lru-cache: 11.2.6 + minipass: 7.1.3 path-scurry@2.0.2: dependencies: @@ -20508,6 +20805,10 @@ snapshots: optionalDependencies: '@napi-rs/nice': 1.1.1 + piscina@5.1.4: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + pkg-dir@7.0.0: dependencies: find-up: 6.3.0 @@ -20809,6 +21110,19 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + proxy-from-env@1.1.0: {} prr@1.0.1: @@ -21547,12 +21861,27 @@ snapshots: slugify@1.6.8: {} + smart-buffer@4.2.0: {} + sockjs@0.3.24: dependencies: faye-websocket: 0.11.4 uuid: 8.3.2 websocket-driver: 0.7.4 + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -21903,6 +22232,15 @@ snapshots: - bare-buffer - react-native-b4a + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + teex@1.0.1: dependencies: streamx: 2.23.0 @@ -22338,6 +22676,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@4.0.1: {} + use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 @@ -22856,6 +23196,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-streams-polyfill@3.3.3: {} + webidl-conversions@8.0.1: {} webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.103.0(@swc/core@1.15.11)): @@ -23074,6 +23416,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@1.10.2: {} yaml@2.8.2: {} From 2f9f931ee2ac2c66f5801b2a4f9c6bd183b6ab8a Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:18:34 +0000 Subject: [PATCH 2/5] fix: apply CodeRabbit auto-fixes Fixed 10 file(s) based on 15 unresolved review comments. Co-authored-by: CodeRabbit --- .../plugins/plugins.controller.spec.ts | 46 ++- .../src/modules/plugins/plugins.controller.ts | 67 +++-- .../api/src/modules/plugins/plugins.module.ts | 2 +- .../pieces/platform/registry/package.json | 4 +- .../src/pieces/migration-worker.service.ts | 39 ++- .../src/pieces/piece-execution.worker.ts | 40 ++- .../src/pieces/piece-loader.service.ts | 47 +-- .../src/pieces/plugin-manager.service.ts | 28 +- .../platform/registry/vitest.config.mts | 16 +- pnpm-lock.yaml | 278 +++++++++++++++++- 10 files changed, 466 insertions(+), 101 deletions(-) diff --git a/apps/api/src/modules/plugins/plugins.controller.spec.ts b/apps/api/src/modules/plugins/plugins.controller.spec.ts index a29aa5b5..f0a90ab1 100644 --- a/apps/api/src/modules/plugins/plugins.controller.spec.ts +++ b/apps/api/src/modules/plugins/plugins.controller.spec.ts @@ -45,10 +45,10 @@ describe('PluginsController', () => { vi.clearAllMocks(); }); - // Helper to generate valid HMAC signature - const generateValidSignature = (payload: any): string => { + // Helper to generate valid HMAC signature from raw body string + const generateValidSignature = (rawBody: string): string => { const hmac = crypto.createHmac('sha256', TEST_SECRET); - return 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex'); + return 'sha256=' + hmac.update(rawBody).digest('hex'); }; it('should be defined', () => { @@ -56,6 +56,21 @@ describe('PluginsController', () => { }); describe('handleNpmWebhook', () => { + it('should throw UnauthorizedException when NPM_WEBHOOK_SECRET is not configured', async () => { + // Override configService to return undefined for NPM_WEBHOOK_SECRET + configService.get = vi.fn().mockReturnValue(undefined); + + const payload = { + name: '@soopa/piece-slack', + version: '1.0.0', + } as WebhookPayloadDto; + + await expect(controller.handleNpmWebhook('sha256=somesignature', payload)).rejects.toThrow( + UnauthorizedException, + ); + expect(installPieceMock).not.toHaveBeenCalled(); + }); + it('should throw UnauthorizedException if signature is missing but secret is configured', async () => { const payload = { name: '@soopa/piece-slack', @@ -83,7 +98,8 @@ describe('PluginsController', () => { it('should ignore payload without a package name gracefully', async () => { const payload = { version: '1.0.0' } as WebhookPayloadDto; - const signature = generateValidSignature(payload); + const rawBody = JSON.stringify(payload); + const signature = generateValidSignature(rawBody); const result = await controller.handleNpmWebhook(signature, payload); @@ -99,7 +115,8 @@ describe('PluginsController', () => { name: '@other/piece-slack', version: '1.0.0', } as WebhookPayloadDto; - const signature = generateValidSignature(payload); + const rawBody = JSON.stringify(payload); + const signature = generateValidSignature(rawBody); const result = await controller.handleNpmWebhook(signature, payload); @@ -110,12 +127,13 @@ describe('PluginsController', () => { expect(installPieceMock).not.toHaveBeenCalled(); }); - it('should successfully install a valid piece and return success', async () => { + it('should successfully queue a valid piece installation and return accepted', async () => { const payload = { name: '@soopa/piece-slack', version: '1.2.3', } as unknown as WebhookPayloadDto; - const signature = generateValidSignature(payload); + const rawBody = JSON.stringify(payload); + const signature = generateValidSignature(rawBody); pluginManagerService.installPiece.mockResolvedValueOnce({ location: '/tmp/plugin', @@ -125,8 +143,8 @@ describe('PluginsController', () => { const result = await controller.handleNpmWebhook(signature, payload); expect(result).toEqual({ - status: 'success', - message: 'Successfully installed @soopa/piece-slack@1.2.3', + status: 'accepted', + message: 'Installation queued for @soopa/piece-slack@1.2.3', }); expect(installPieceMock).toHaveBeenCalledWith( '@soopa/piece-slack', @@ -134,18 +152,22 @@ describe('PluginsController', () => { ); }); - it('should return error status if pluginManager fails to install', async () => { + it('should return accepted status even if pluginManager fails (fire-and-forget)', async () => { const payload = { name: '@soopa/piece-slack', version: '1.2.3', } as unknown as WebhookPayloadDto; - const signature = generateValidSignature(payload); + const rawBody = JSON.stringify(payload); + const signature = generateValidSignature(rawBody); installPieceMock.mockRejectedValueOnce(new Error('Network Failure')); const result = await controller.handleNpmWebhook(signature, payload); - expect(result).toEqual({ status: 'error', message: 'Network Failure' }); + expect(result).toEqual({ + status: 'accepted', + message: 'Installation queued for @soopa/piece-slack@1.2.3' + }); expect(installPieceMock).toHaveBeenCalledWith( '@soopa/piece-slack', '1.2.3', diff --git a/apps/api/src/modules/plugins/plugins.controller.ts b/apps/api/src/modules/plugins/plugins.controller.ts index 4cd817f5..b9e30381 100644 --- a/apps/api/src/modules/plugins/plugins.controller.ts +++ b/apps/api/src/modules/plugins/plugins.controller.ts @@ -50,20 +50,43 @@ export class PluginsController { this.logger.log('Received NPM publish webhook event'); const webhookSecret = this.configService.get('NPM_WEBHOOK_SECRET'); - if (webhookSecret && signature) { - // Validate HMAC signature to ensure request originated from our private registry - const hmac = crypto.createHmac('sha256', webhookSecret); - const digest = - 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex'); - if (signature !== digest) { + // Signature validation is mandatory - fail if secret is not configured + if (!webhookSecret) { + this.logger.error('NPM_WEBHOOK_SECRET is not configured. Rejecting webhook request.'); + throw new UnauthorizedException('Webhook authentication not configured'); + } + + if (!signature) { + throw new UnauthorizedException('Missing webhook signature'); + } + + // Validate HMAC signature to ensure request originated from our private registry + const hmac = crypto.createHmac('sha256', webhookSecret); + const digest = + 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex'); + + // Use constant-time comparison to prevent timing attacks + try { + const signatureBuffer = Buffer.from(signature, 'utf8'); + const digestBuffer = Buffer.from(digest, 'utf8'); + + // Fail early if lengths don't match + if (signatureBuffer.length !== digestBuffer.length) { + throw new Error('Signature length mismatch'); + } + + if (!crypto.timingSafeEqual(signatureBuffer, digestBuffer)) { this.logger.warn( 'Invalid NPM webhook signature detected. Dropping payload.', ); throw new UnauthorizedException('Invalid webhook signature'); } - } else if (webhookSecret && !signature) { - throw new UnauthorizedException('Missing webhook signature'); + } catch (error) { + this.logger.warn( + 'Invalid NPM webhook signature detected. Dropping payload.', + ); + throw new UnauthorizedException('Invalid webhook signature'); } // Example Verdaccio payload parsing: @@ -83,26 +106,24 @@ export class PluginsController { }; } - try { - this.logger.log( - `Triggering background install for ${packageName}@${version}...`, - ); - - // Fire and forget (or await) the download - // The service will handle downloading and updating internal state. - await this.pluginManager.installPiece(packageName, version); + this.logger.log( + `Triggering background install for ${packageName}@${version}...`, + ); - return { - status: 'success', - message: `Successfully installed ${packageName}@${version}`, - }; - } catch (error: unknown) { + // Fire-and-forget: acknowledge immediately and process installation asynchronously + // This prevents webhook timeout issues and allows the registry to receive quick confirmation + this.pluginManager.installPiece(packageName, version).catch((error: unknown) => { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error( `Failed to install plugin via webhook: ${errorMessage}`, ); - return { status: 'error', message: errorMessage }; - } + // Log for monitoring/alerting - external webhook retries or queue-based retry can be added later + }); + + return { + status: 'accepted', + message: `Installation queued for ${packageName}@${version}`, + }; } } diff --git a/apps/api/src/modules/plugins/plugins.module.ts b/apps/api/src/modules/plugins/plugins.module.ts index 0a232f18..b868a040 100644 --- a/apps/api/src/modules/plugins/plugins.module.ts +++ b/apps/api/src/modules/plugins/plugins.module.ts @@ -3,7 +3,7 @@ import { PluginsController } from './plugins.controller.js'; import { PiecesModule } from '@soopa/piece-registry'; @Module({ - imports: [PiecesModule], // Brings in PluginManagerService + imports: [PiecesModule.forRoot()], // Brings in PluginManagerService controllers: [PluginsController], }) export class PluginsModule {} diff --git a/packages/pieces/platform/registry/package.json b/packages/pieces/platform/registry/package.json index 8ef6921a..0ea140c1 100644 --- a/packages/pieces/platform/registry/package.json +++ b/packages/pieces/platform/registry/package.json @@ -25,14 +25,14 @@ "@soopa/database": "workspace:*", "@soopa/piece-framework": "workspace:*", "@soopa/queue": "workspace:*", - "drizzle-orm": "^0.45.1", + "drizzle-orm": "^0.45.2", "live-plugin-manager": "^1.1.0", "piscina": "^5.1.4" }, "devDependencies": { "@nestjs/testing": "^11.1.11", "typescript": "^5.9.3", - "vitest": "^2.1.9" + "vitest": "^4.1.0" }, "peerDependencies": { "@nestjs/common": "^11.0.1" diff --git a/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts index 538530a0..56dcf166 100644 --- a/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts +++ b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts @@ -25,13 +25,32 @@ export class MigrationWorkerService implements OnModuleInit { async (rawMsg: unknown) => { const event = rawMsg as PluginMigrationEvent; // The queue might be used for other tenant provision events, so check if it's ours - if (event.pluginLocation && event.pieceName) { + if (this.isPluginMigrationEvent(event)) { await this.runBackgroundMigrations(event); + } else { + // Non-plugin messages should not be silently dropped + // Log and let the message be requeued for other consumers + this.logger.warn( + 'Received non-plugin migration event on TenantProvisionQueue. Message should be handled by dedicated consumer.', + ); + throw new Error('Not a plugin migration event - requeue for appropriate handler'); } } ); } + /** + * Type guard to verify if an event is a plugin migration event. + */ + private isPluginMigrationEvent(event: any): event is PluginMigrationEvent { + return ( + typeof event === 'object' && + event !== null && + typeof event.pluginLocation === 'string' && + typeof event.pieceName === 'string' + ); + } + /** * Executes Drizzle migrations for a dynamically loaded piece using SQS Fan-Out. */ @@ -71,18 +90,20 @@ export class MigrationWorkerService implements OnModuleInit { } } - // Mocks for demonstration purposes + // Stub helpers - MUST BE IMPLEMENTED BEFORE PRODUCTION USE private async getActiveTenants(pieceName: string) { + // TODO: Implement real tenant resolution logic // e.g., SELECT * FROM tenant_connections WHERE piece_id = $1 - return [ - { id: 'tenant_1', dbConnectionUrl: 'postgres://localhost/tenant1' }, - { id: 'tenant_2', dbConnectionUrl: 'postgres://localhost/tenant2' } - ]; + throw new Error( + 'getActiveTenants is not implemented. Real tenant resolution must be provided before running plugin migrations in production.', + ); } private async getTenantDbConnection(connectionUrl: string): Promise { - // In production, this would resolve from your TenantDatabaseManager connection pool - // Returning globalDb here just to satisfy the mock signature - return this.globalDb; + // TODO: Implement real tenant database connection management + // In production, this should resolve from your TenantDatabaseManager connection pool + throw new Error( + 'getTenantDbConnection is not implemented. Real tenant connection management must be provided before running plugin migrations in production.', + ); } } diff --git a/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts index 8ba64d68..3c04a27d 100644 --- a/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts +++ b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts @@ -1,4 +1,6 @@ import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; /** * Enterprise Piece Execution Worker @@ -23,18 +25,40 @@ export default async function runIntegrationPiece(data: ExecutionData): Promise< // --------------------------------------------------------- // Ensure the requested script path physically resides within the allowed // plugin sandbox directory or the workspace fallback. - const resolvedPath = path.resolve(scriptPath); - const isLocalWorkspace = resolvedPath.includes('/packages/pieces/platform/') || resolvedPath.includes('/packages/pieces/application/'); - const isGlobalPlugins = resolvedPath.startsWith('/tmp/soopa-plugins/') || resolvedPath.startsWith('/tmp/plugin'); - - if (!isLocalWorkspace && !isGlobalPlugins) { + const resolvedPath = fs.realpathSync(path.resolve(scriptPath)); + + // Define and canonicalize allowed roots + const pluginsPath = process.env.PLUGINS_PATH || path.join(process.cwd(), '.plugins'); + const allowedRoots = [ + fs.realpathSync(pluginsPath), + // Workspace package roots for local development + ...[ + path.join(process.cwd(), 'packages/pieces/platform'), + path.join(process.cwd(), 'packages/pieces/application'), + ].filter(p => { + try { + return fs.existsSync(p) ? fs.realpathSync(p) : null; + } catch { + return null; + } + }).filter(Boolean) as string[], + ]; + + // Validate that resolvedPath has one of the allowed roots as a strict prefix + const isAllowed = allowedRoots.some(root => { + const relative = path.relative(root, resolvedPath); + // Path is safe if relative path doesn't start with '..' (escaping parent) and isn't absolute + return relative && !relative.startsWith('..') && !path.isAbsolute(relative); + }); + + if (!isAllowed) { throw new Error(`SECURITY ALERT: Sandbox violation detected. Path traversal attempted: ${resolvedPath}`); } // Dynamically require the managed bundle from the disk - // Using __non_webpack_require__ or dynamic import if using a bundler - // eslint-disable-next-line @typescript-eslint/no-var-requires - const piece = require(resolvedPath); + // Create a scoped require for ESM compatibility + const scopedRequire = createRequire(import.meta.url); + const piece = scopedRequire(resolvedPath); // Locate the trigger (handling both default export and named export variations) const trigger = piece.default?.triggers?.[triggerName] || piece.triggers?.[triggerName]; diff --git a/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts b/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts index 27493b85..454eb622 100644 --- a/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts +++ b/packages/pieces/platform/registry/src/pieces/piece-loader.service.ts @@ -27,11 +27,15 @@ export const PIECE_LOADER_ANCHOR_URL = 'PIECE_LOADER_ANCHOR_URL'; @Injectable() export class PieceLoaderService { private readonly logger = new Logger(PieceLoaderService.name); + private readonly isDev: boolean; constructor( @Optional() @Inject(PIECE_LOADER_ANCHOR_URL) private readonly anchorUrl: string | null, private readonly pluginManager: PluginManagerService, - ) {} + ) { + // Check if we're in development mode + this.isDev = process.env.NODE_ENV === 'development' || process.env.DEV_MODE === 'true'; + } async loadEnabledPieces(db: DrizzleDb): Promise { const rows = await db.select({ @@ -54,27 +58,34 @@ export class PieceLoaderService { // --- ENTERPRISE STARTUP SYNCHRONIZATION --- // Ensure the plugin is downloaded locally to /opt/nexiom/plugins await this.pluginManager.ensurePiece(row.packageName, 'latest'); - + // Dynamically load the piece from the plugin manager's disk cache mod = this.pluginManager.requirePiece(row.packageName) as Record; } catch (downloadErr: any) { - this.logger.warn(`Startup Sync failed for ${row.packageName} (${downloadErr.message}). Falling back to local workspace resolution...`); - - // --- LOCAL DEVELOPMENT FALLBACK --- - // Allows `pnpm dev` to load unpublished pieces directly from the monorepo node_modules - let resolvedPath = row.packageName; - try { - const { createRequire } = await import('node:module'); - const { fileURLToPath, pathToFileURL } = await import('node:url'); - const anchor = this.anchorUrl ?? import.meta.url; - const anchorFile = anchor.startsWith('file://') ? anchor : pathToFileURL(anchor).href; - const hostRequire = createRequire(fileURLToPath(anchorFile)); - resolvedPath = pathToFileURL(hostRequire.resolve(row.packageName)).href; - } catch { - // Non-fatal: fall through to bare-specifier import below. + // Only attempt local workspace resolution in development mode + if (this.isDev) { + this.logger.warn(`Startup Sync failed for ${row.packageName} (${downloadErr.message}). Falling back to local workspace resolution...`); + + // --- LOCAL DEVELOPMENT FALLBACK --- + // Allows `pnpm dev` to load unpublished pieces directly from the monorepo node_modules + let resolvedPath = row.packageName; + try { + const { createRequire } = await import('node:module'); + const { fileURLToPath, pathToFileURL } = await import('node:url'); + const anchor = this.anchorUrl ?? import.meta.url; + const anchorFile = anchor.startsWith('file://') ? anchor : pathToFileURL(anchor).href; + const hostRequire = createRequire(fileURLToPath(anchorFile)); + resolvedPath = pathToFileURL(hostRequire.resolve(row.packageName)).href; + } catch { + // Non-fatal: fall through to bare-specifier import below. + } + + mod = (await import(resolvedPath)) as Record; + } else { + // In production, re-throw the original error + this.logger.error(`Failed to load piece ${row.packageName} in production: ${downloadErr.message}`); + throw downloadErr; } - - mod = (await import(resolvedPath)) as Record; } const piece = this.extractPiece(mod, row.name); if (piece) { diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts index 5c52663c..58287f9f 100644 --- a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; import { PluginManager } from 'live-plugin-manager'; import * as path from 'path'; import * as os from 'os'; +import * as fs from 'fs'; import { QUEUE_SERVICE, QueueName } from '@soopa/queue'; import type { IQueueService, PluginMigrationEvent } from '@soopa/queue'; @@ -10,14 +11,35 @@ export class PluginManagerService { private readonly logger = new Logger(PluginManagerService.name); private manager: PluginManager; private readonly pluginsPath: string; + public static readonly PLUGINS_PATH = (() => { + const isDev = process.env.NODE_ENV === 'development' || process.env.DEV_MODE === 'true'; + const appDataDir = process.env.APP_DATA_DIR || (isDev ? process.cwd() : path.join(os.homedir(), '.soopa')); + return process.env.PLUGINS_PATH || (isDev ? path.join(os.tmpdir(), 'soopa-plugins') : path.join(appDataDir, 'plugins')); + })(); constructor( @Inject(QUEUE_SERVICE) private readonly queueService: IQueueService ) { - // Default to a local .plugins directory for development, or /opt/soopa/plugins in prod - this.pluginsPath = process.env.PLUGINS_PATH || path.join(os.tmpdir(), 'soopa-plugins'); + // Determine plugins path based on environment + const isDev = process.env.NODE_ENV === 'development' || process.env.DEV_MODE === 'true'; + const appDataDir = process.env.APP_DATA_DIR || (isDev ? process.cwd() : path.join(os.homedir(), '.soopa')); + + // Default to app-owned persistent directory, only use tmpdir in development + this.pluginsPath = process.env.PLUGINS_PATH || + (isDev ? path.join(os.tmpdir(), 'soopa-plugins') : path.join(appDataDir, 'plugins')); + this.logger.log(`Initializing Live Plugin Manager at: ${this.pluginsPath}`); - this.manager = new PluginManager({ + + // Ensure the directory exists with restrictive permissions + if (!fs.existsSync(this.pluginsPath)) { + fs.mkdirSync(this.pluginsPath, { recursive: true, mode: 0o700 }); + this.logger.log(`Created plugins directory with restricted permissions (700): ${this.pluginsPath}`); + } else { + // Apply restrictive permissions to existing directory + fs.chmodSync(this.pluginsPath, 0o700); + } + + this.manager = new PluginManager({ pluginsPath: this.pluginsPath, npmRegistryUrl: process.env.NPM_REGISTRY_URL || 'https://registry.npmjs.org/' }); diff --git a/packages/pieces/platform/registry/vitest.config.mts b/packages/pieces/platform/registry/vitest.config.mts index 3314af40..eb6f3d02 100644 --- a/packages/pieces/platform/registry/vitest.config.mts +++ b/packages/pieces/platform/registry/vitest.config.mts @@ -16,7 +16,7 @@ export default defineConfig({ }, server: { deps: { - external: ['@nexiom/auth', '@nexiom/identity'], + external: [], }, }, env: { @@ -31,22 +31,8 @@ export default defineConfig({ 'node_modules/**', 'dist/**', '**/*.d.ts', - 'drizzle.config.ts', - 'src/main.ts', 'src/**/*.module.ts', - 'src/**/*.dto.ts', - 'src/**/*.entity.ts', - 'src/**/*.interface.ts', - 'src/**/*.abstract.ts', - 'src/**/*.decorator.ts', - 'src/**/*.mock.ts', - 'src/scripts/**', - 'src/modules/ai/**', 'src/pieces/piece-execution.worker.ts', - 'src/db/reset-e2e.ts', - 'src/db/db-cli.ts', - 'src/db/schema.ts', - 'src/db/db.provider.ts', 'test/**', '**/*.spec.ts', '**/*.e2e-spec.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff5d2705..aa577d30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,7 +480,7 @@ importers: version: 1.13.2 better-auth: specifier: ^1.4.10 - version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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@27.4.0(@noble/hashes@2.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)) + version: 1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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@27.4.0(@noble/hashes@2.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)) framer-motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1407,8 +1407,8 @@ importers: specifier: workspace:* version: link:../../../queue drizzle-orm: - specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) live-plugin-manager: specifier: ^1.1.0 version: 1.1.0 @@ -1423,8 +1423,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 vitest: - specifier: ^2.1.9 - version: 2.1.9(@types/node@25.5.0)(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@25.5.0)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) + specifier: ^4.1.0 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@25.0.5)(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@25.0.5)(typescript@6.0.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)))(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) packages/pieces/platform/salesforce: dependencies: @@ -5355,6 +5355,9 @@ packages: '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -5377,36 +5380,62 @@ packages: vite: optional: true + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -6778,6 +6807,98 @@ packages: sqlite3: optional: true + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -6868,6 +6989,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -10190,6 +10314,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -10499,6 +10626,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -11030,6 +11161,47 @@ packages: jsdom: optional: true + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -16476,6 +16648,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.3)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 @@ -16539,6 +16720,15 @@ snapshots: msw: 2.12.10(@types/node@25.5.0)(typescript@6.0.3) vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.8(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@25.5.0)(typescript@5.9.3) + vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -16547,6 +16737,10 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -16557,6 +16751,11 @@ snapshots: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -16569,12 +16768,21 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.8': {} + '@vitest/utils@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -16586,6 +16794,12 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -17139,7 +17353,7 @@ snapshots: bcryptjs@3.0.3: {} - better-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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@27.4.0(@noble/hashes@2.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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@opentelemetry/api@1.9.0)(@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)) @@ -17160,9 +17374,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@24.10.4)(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@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-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@opentelemetry/api@1.9.0)(@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.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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@27.4.0(@noble/hashes@2.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)) @@ -17178,12 +17392,12 @@ snapshots: zod: 4.3.5 optionalDependencies: drizzle-kit: 0.31.8 - drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) next: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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: 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@27.4.0(@noble/hashes@2.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-call@1.1.7(zod@4.3.5): dependencies: @@ -17949,6 +18163,13 @@ snapshots: kysely: 0.28.9 pg: 8.16.3 + drizzle-orm@0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3): + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/pg': 8.16.0 + kysely: 0.28.9 + pg: 8.16.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -18079,6 +18300,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -21966,6 +22189,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -22325,6 +22550,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + tinyspy@3.0.2: {} tldts-core@7.0.19: {} @@ -23175,6 +23402,37 @@ snapshots: - tsx - yaml + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@25.0.5)(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@25.0.5)(typescript@6.0.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)))(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.5.0 + '@vitest/coverage-v8': 2.1.9(vitest@2.1.9(@types/node@25.0.5)(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@25.0.5)(typescript@6.0.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) + happy-dom: 20.1.0 + jsdom: 27.4.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - msw + vscode-uri@3.1.0: {} w3c-xmlserializer@5.0.0: From 7bf5f7aa125d94cfde89d7ee49e130870aa08af1 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:40:00 +0000 Subject: [PATCH 3/5] fix: apply CodeRabbit auto-fixes Fixed 10 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit --- .../plugins/plugins.controller.spec.ts | 56 ++++++++++++------- .../src/modules/plugins/plugins.controller.ts | 32 +++++++---- .../pieces/platform/registry/src/index.ts | 1 + .../pieces/migration-worker.service.spec.ts | 22 +++++++- .../src/pieces/migration-worker.service.ts | 34 +++++++++-- .../src/pieces/piece-execution.worker.ts | 40 +++++++++---- .../registry/src/pieces/pieces.module.ts | 4 +- .../src/pieces/plugin-manager.service.ts | 23 +++++--- packages/queue/src/constants.ts | 2 + packages/queue/src/index.ts | 1 + 10 files changed, 158 insertions(+), 57 deletions(-) diff --git a/apps/api/src/modules/plugins/plugins.controller.spec.ts b/apps/api/src/modules/plugins/plugins.controller.spec.ts index f0a90ab1..4276059c 100644 --- a/apps/api/src/modules/plugins/plugins.controller.spec.ts +++ b/apps/api/src/modules/plugins/plugins.controller.spec.ts @@ -3,6 +3,8 @@ import { PluginsController, WebhookPayloadDto } from './plugins.controller.js'; import { PluginManagerService } from '@soopa/piece-registry'; import { ConfigService } from '@nestjs/config'; import { UnauthorizedException } from '@nestjs/common'; +import { QUEUE_SERVICE, QueueName } from '@soopa/queue'; +import type { IQueueService } from '@soopa/queue'; import * as crypto from 'crypto'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { Mocked } from 'vitest'; @@ -11,6 +13,7 @@ describe('PluginsController', () => { let controller: PluginsController; let pluginManagerService: Mocked; let configService: Mocked; + let queueService: Mocked; let installPieceMock: ReturnType; const TEST_SECRET = 'test-secret'; @@ -30,11 +33,18 @@ describe('PluginsController', () => { }), } as unknown as Mocked; + queueService = { + send: vi.fn().mockResolvedValue(undefined), + consume: vi.fn(), + stopConsuming: vi.fn().mockResolvedValue(undefined), + } as unknown as Mocked; + const module: TestingModule = await Test.createTestingModule({ controllers: [PluginsController], providers: [ { provide: PluginManagerService, useValue: pluginManagerService }, { provide: ConfigService, useValue: configService }, + { provide: QUEUE_SERVICE, useValue: queueService }, ], }).compile(); @@ -68,7 +78,7 @@ describe('PluginsController', () => { await expect(controller.handleNpmWebhook('sha256=somesignature', payload)).rejects.toThrow( UnauthorizedException, ); - expect(installPieceMock).not.toHaveBeenCalled(); + expect(queueService.send).not.toHaveBeenCalled(); }); it('should throw UnauthorizedException if signature is missing but secret is configured', async () => { @@ -80,7 +90,7 @@ describe('PluginsController', () => { await expect(controller.handleNpmWebhook('', payload)).rejects.toThrow( UnauthorizedException, ); - expect(installPieceMock).not.toHaveBeenCalled(); + expect(queueService.send).not.toHaveBeenCalled(); }); it('should throw UnauthorizedException if signature is invalid', async () => { @@ -93,7 +103,7 @@ describe('PluginsController', () => { await expect( controller.handleNpmWebhook(invalidSignature, payload), ).rejects.toThrow(UnauthorizedException); - expect(installPieceMock).not.toHaveBeenCalled(); + expect(queueService.send).not.toHaveBeenCalled(); }); it('should ignore payload without a package name gracefully', async () => { @@ -107,7 +117,7 @@ describe('PluginsController', () => { status: 'ignored', reason: 'No package name found in payload', }); - expect(installPieceMock).not.toHaveBeenCalled(); + expect(queueService.send).not.toHaveBeenCalled(); }); it('should ignore payload that does not belong to @soopa scope', async () => { @@ -124,7 +134,7 @@ describe('PluginsController', () => { status: 'ignored', reason: 'Only @soopa packages are hot-loaded', }); - expect(installPieceMock).not.toHaveBeenCalled(); + expect(queueService.send).not.toHaveBeenCalled(); }); it('should successfully queue a valid piece installation and return accepted', async () => { @@ -135,24 +145,30 @@ describe('PluginsController', () => { const rawBody = JSON.stringify(payload); const signature = generateValidSignature(rawBody); - pluginManagerService.installPiece.mockResolvedValueOnce({ - location: '/tmp/plugin', - version: '1.2.3', - }); - const result = await controller.handleNpmWebhook(signature, payload); expect(result).toEqual({ status: 'accepted', message: 'Installation queued for @soopa/piece-slack@1.2.3', }); - expect(installPieceMock).toHaveBeenCalledWith( - '@soopa/piece-slack', - '1.2.3', + + // Verify the install event was sent to the queue + expect(queueService.send).toHaveBeenCalledWith( + QueueName.PluginInstallQueue, + expect.objectContaining({ + packageName: '@soopa/piece-slack', + version: '1.2.3', + requestMetadata: expect.objectContaining({ + source: 'npm-webhook', + }), + }) ); + + // The controller should NOT directly call installPiece anymore + expect(installPieceMock).not.toHaveBeenCalled(); }); - it('should return accepted status even if pluginManager fails (fire-and-forget)', async () => { + it('should return accepted status when queue send succeeds', async () => { const payload = { name: '@soopa/piece-slack', version: '1.2.3', @@ -160,17 +176,19 @@ describe('PluginsController', () => { const rawBody = JSON.stringify(payload); const signature = generateValidSignature(rawBody); - installPieceMock.mockRejectedValueOnce(new Error('Network Failure')); - const result = await controller.handleNpmWebhook(signature, payload); expect(result).toEqual({ status: 'accepted', message: 'Installation queued for @soopa/piece-slack@1.2.3' }); - expect(installPieceMock).toHaveBeenCalledWith( - '@soopa/piece-slack', - '1.2.3', + + expect(queueService.send).toHaveBeenCalledWith( + QueueName.PluginInstallQueue, + expect.objectContaining({ + packageName: '@soopa/piece-slack', + version: '1.2.3', + }) ); }); }); diff --git a/apps/api/src/modules/plugins/plugins.controller.ts b/apps/api/src/modules/plugins/plugins.controller.ts index b9e30381..e027c93e 100644 --- a/apps/api/src/modules/plugins/plugins.controller.ts +++ b/apps/api/src/modules/plugins/plugins.controller.ts @@ -5,9 +5,12 @@ import { Headers, Logger, UnauthorizedException, + Inject, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PluginManagerService } from '@soopa/piece-registry'; +import { QUEUE_SERVICE, QueueName } from '@soopa/queue'; +import type { IQueueService, PluginInstallEvent } from '@soopa/queue'; import * as crypto from 'crypto'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod'; @@ -35,6 +38,7 @@ export class PluginsController { constructor( private readonly pluginManager: PluginManagerService, private readonly configService: ConfigService, + @Inject(QUEUE_SERVICE) private readonly queueService: IQueueService, ) {} /** @@ -107,19 +111,25 @@ export class PluginsController { } this.logger.log( - `Triggering background install for ${packageName}@${version}...`, + `Persisting durable install job for ${packageName}@${version}...`, ); - // Fire-and-forget: acknowledge immediately and process installation asynchronously - // This prevents webhook timeout issues and allows the registry to receive quick confirmation - this.pluginManager.installPiece(packageName, version).catch((error: unknown) => { - const errorMessage = - error instanceof Error ? error.message : String(error); - this.logger.error( - `Failed to install plugin via webhook: ${errorMessage}`, - ); - // Log for monitoring/alerting - external webhook retries or queue-based retry can be added later - }); + // Persist a durable install job to the queue before responding + // This ensures the install request is not lost if the process dies + const installEvent: PluginInstallEvent = { + packageName, + version, + requestMetadata: { + webhookReceivedAt: new Date().toISOString(), + source: 'npm-webhook', + }, + }; + + await this.queueService.send(QueueName.PluginInstallQueue, installEvent); + + this.logger.log( + `Installation job persisted to queue for ${packageName}@${version}`, + ); return { status: 'accepted', diff --git a/packages/pieces/platform/registry/src/index.ts b/packages/pieces/platform/registry/src/index.ts index 964120da..f563fc08 100644 --- a/packages/pieces/platform/registry/src/index.ts +++ b/packages/pieces/platform/registry/src/index.ts @@ -5,3 +5,4 @@ export * from './metadata/metadata-discovery.service.js'; export * from './metadata/metadata.module.js'; export * from './pieces/plugin-manager.service.js'; export * from './pieces/execution-worker.service.js'; +export * from './pieces/plugin-install.worker.js'; diff --git a/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts b/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts index 44a2f4c1..f4e96ab8 100644 --- a/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts +++ b/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts @@ -31,6 +31,24 @@ describe('MigrationWorkerService', () => { }); describe('runBackgroundMigrations', () => { + beforeEach(() => { + // Enable migrations for tests that need tenant handlers + process.env.ENABLE_PLUGIN_MIGRATIONS = 'true'; + }); + + afterEach(() => { + delete process.env.ENABLE_PLUGIN_MIGRATIONS; + }); + + it('should throw error if tenant handlers are not available', async () => { + delete process.env.ENABLE_PLUGIN_MIGRATIONS; + + await expect(service.runBackgroundMigrations({ + pluginLocation: '/tmp/plugin', + pieceName: '@soopa/piece-migrate' + })).rejects.toThrow('Tenant handlers not available - migrations disabled'); + }); + it('should Fan-out to multiple queue messages if tenantId is missing', async () => { // Mock the internal getActiveTenants helper const getActiveTenantsSpy = vi.spyOn(service as any, 'getActiveTenants').mockResolvedValue([ @@ -44,7 +62,7 @@ describe('MigrationWorkerService', () => { }); expect(getActiveTenantsSpy).toHaveBeenCalledWith('@soopa/piece-migrate'); - + // It should have fanned out into 2 explicit queue messages expect(queueService.send).toHaveBeenCalledTimes(2); expect(queueService.send).toHaveBeenNthCalledWith(1, QueueName.TenantProvisionQueue, { @@ -57,7 +75,7 @@ describe('MigrationWorkerService', () => { pieceName: '@soopa/piece-migrate', tenantId: 't2' }); - + // Should not run migrations itself in Fan-Out mode expect(migrator.migrate).not.toHaveBeenCalled(); }); diff --git a/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts index 56dcf166..764a499a 100644 --- a/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts +++ b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts @@ -19,6 +19,16 @@ export class MigrationWorkerService implements OnModuleInit { ) {} onModuleInit() { + // Guard against registering migration consumer when tenant handlers are not implemented + if (!this.areTenantHandlersAvailable()) { + this.logger.warn( + 'Tenant handlers (getActiveTenants, getTenantDbConnection) are not implemented. ' + + 'Plugin migration consumer will NOT be registered. ' + + 'Set ENABLE_PLUGIN_MIGRATIONS=true when implementations are ready.' + ); + return; + } + this.logger.log('Registering SQS Queue Consumer for Plugin Migrations...'); this.queueService.consume( QueueName.TenantProvisionQueue, @@ -39,6 +49,13 @@ export class MigrationWorkerService implements OnModuleInit { ); } + /** + * Checks if tenant handlers are available. Returns false if stubbed implementations exist. + */ + private areTenantHandlersAvailable(): boolean { + return process.env.ENABLE_PLUGIN_MIGRATIONS === 'true'; + } + /** * Type guard to verify if an event is a plugin migration event. */ @@ -55,13 +72,22 @@ export class MigrationWorkerService implements OnModuleInit { * Executes Drizzle migrations for a dynamically loaded piece using SQS Fan-Out. */ async runBackgroundMigrations(event: PluginMigrationEvent) { + // Guard against executing migrations when tenant handlers are not available + if (!this.areTenantHandlersAvailable()) { + this.logger.error( + `Cannot run migrations for ${event.pieceName}: tenant handlers are not implemented. ` + + 'Set ENABLE_PLUGIN_MIGRATIONS=true when implementations are ready.' + ); + throw new Error('Tenant handlers not available - migrations disabled'); + } + if (!event.tenantId) { // --- FAN-OUT MODE --- this.logger.log(`[Fan-Out] Starting fan-out for ${event.pieceName} from ${event.pluginLocation}`); const activeTenants = await this.getActiveTenants(event.pieceName); - + this.logger.log(`[Fan-Out] Found ${activeTenants.length} active tenants. Dispatching single-tenant SQS messages...`); - + // Dispatch 1 SQS message per tenant. This perfectly parallelizes migrations // across all available workers and prevents a single tenant's failure from retrying the entire batch. for (const tenant of activeTenants) { @@ -77,7 +103,7 @@ export class MigrationWorkerService implements OnModuleInit { // --- WORKER MODE (Single Tenant) --- this.logger.log(`[Worker] Executing migration for tenant ${event.tenantId} (Piece: ${event.pieceName})`); const migrationsFolder = `${event.pluginLocation}/drizzle/migrations`; - + try { const tenantDb = await this.getTenantDbConnection(event.tenantId); await migrate(tenantDb, { migrationsFolder }); @@ -91,7 +117,7 @@ export class MigrationWorkerService implements OnModuleInit { } // Stub helpers - MUST BE IMPLEMENTED BEFORE PRODUCTION USE - private async getActiveTenants(pieceName: string) { + private async getActiveTenants(pieceName: string): Promise> { // TODO: Implement real tenant resolution logic // e.g., SELECT * FROM tenant_connections WHERE piece_id = $1 throw new Error( diff --git a/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts index 3c04a27d..2acae433 100644 --- a/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts +++ b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts @@ -28,21 +28,37 @@ export default async function runIntegrationPiece(data: ExecutionData): Promise< const resolvedPath = fs.realpathSync(path.resolve(scriptPath)); // Define and canonicalize allowed roots - const pluginsPath = process.env.PLUGINS_PATH || path.join(process.cwd(), '.plugins'); - const allowedRoots = [ - fs.realpathSync(pluginsPath), - // Workspace package roots for local development - ...[ - path.join(process.cwd(), 'packages/pieces/platform'), - path.join(process.cwd(), 'packages/pieces/application'), - ].filter(p => { + // Reuse the same plugin-root logic as PluginManagerService to ensure consistency + const isDev = process.env.NODE_ENV === 'development' || process.env.DEV_MODE === 'true'; + const appDataDir = process.env.APP_DATA_DIR || (isDev ? process.cwd() : path.join(require('node:os').homedir(), '.soopa')); + const pluginsPath = process.env.PLUGINS_PATH || (isDev ? path.join(require('node:os').tmpdir(), 'soopa-plugins') : path.join(appDataDir, 'plugins')); + + const allowedRoots: string[] = []; + + // Only canonicalize pluginsPath if it exists + if (fs.existsSync(pluginsPath)) { + try { + allowedRoots.push(fs.realpathSync(pluginsPath)); + } catch { + // If realpathSync fails, skip this root + } + } + + // Workspace package roots for local development + const workspaceRoots = [ + path.join(process.cwd(), 'packages/pieces/platform'), + path.join(process.cwd(), 'packages/pieces/application'), + ]; + + for (const p of workspaceRoots) { + if (fs.existsSync(p)) { try { - return fs.existsSync(p) ? fs.realpathSync(p) : null; + allowedRoots.push(fs.realpathSync(p)); } catch { - return null; + // If realpathSync fails, skip this root } - }).filter(Boolean) as string[], - ]; + } + } // Validate that resolvedPath has one of the allowed roots as a strict prefix const isAllowed = allowedRoots.some(root => { diff --git a/packages/pieces/platform/registry/src/pieces/pieces.module.ts b/packages/pieces/platform/registry/src/pieces/pieces.module.ts index 7d6a9abe..919f4da7 100644 --- a/packages/pieces/platform/registry/src/pieces/pieces.module.ts +++ b/packages/pieces/platform/registry/src/pieces/pieces.module.ts @@ -7,6 +7,7 @@ import { PieceLoaderService, PIECE_LOADER_ANCHOR_URL } from './piece-loader.serv import { PluginManagerService } from './plugin-manager.service.js'; import { ExecutionWorkerService } from './execution-worker.service.js'; import { MigrationWorkerService } from './migration-worker.service.js'; +import { PluginInstallWorkerService } from './plugin-install.worker.js'; import { DATABASE_CONNECTION } from '@soopa/database'; import type { DrizzleDb } from '@soopa/database'; import type { Piece } from '@soopa/piece-framework'; @@ -25,6 +26,7 @@ const SHARED_PROVIDERS = [ PluginManagerService, ExecutionWorkerService, MigrationWorkerService, + PluginInstallWorkerService, ]; /** @@ -49,7 +51,7 @@ export class PiecesModule { { provide: PIECE_LOADER_ANCHOR_URL, useValue: options.anchorUrl }, ...SHARED_PROVIDERS, ], - exports: [PieceRegistryService, PieceLoaderService, PluginManagerService, ExecutionWorkerService, MigrationWorkerService, PIECES], + exports: [PieceRegistryService, PieceLoaderService, PluginManagerService, ExecutionWorkerService, MigrationWorkerService, PluginInstallWorkerService, PIECES], }; } diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts index 58287f9f..919e70a2 100644 --- a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts @@ -55,15 +55,22 @@ export class PluginManagerService { try { const pluginInfo = await this.manager.install(packageName, version); this.logger.log(`Successfully installed ${packageName}@${pluginInfo.version} to ${pluginInfo.location}`); - + // Dispatch migration job to SQS queue for guaranteed execution - const migrationEvent: PluginMigrationEvent = { - pluginLocation: pluginInfo.location, - pieceName: packageName - }; - - await this.queueService.send(QueueName.TenantProvisionQueue, migrationEvent); - this.logger.log(`Dispatched migration job for ${packageName} to SQS`); + // Only dispatch if tenant handlers are available + if (process.env.ENABLE_PLUGIN_MIGRATIONS === 'true') { + const migrationEvent: PluginMigrationEvent = { + pluginLocation: pluginInfo.location, + pieceName: packageName + }; + + await this.queueService.send(QueueName.TenantProvisionQueue, migrationEvent); + this.logger.log(`Dispatched migration job for ${packageName} to SQS`); + } else { + this.logger.debug( + `Skipping migration dispatch for ${packageName} (ENABLE_PLUGIN_MIGRATIONS not set)` + ); + } return pluginInfo; } catch (error) { diff --git a/packages/queue/src/constants.ts b/packages/queue/src/constants.ts index 353ee1d4..1642e30a 100644 --- a/packages/queue/src/constants.ts +++ b/packages/queue/src/constants.ts @@ -12,6 +12,7 @@ export enum QueueName { TenantProvisionQueue = "tenant-provision-queue", ActiveFetchQueue = "active-fetch-queue", RegistryReplicationQueue = "registry-replication-queue", + PluginInstallQueue = "plugin-install-queue", // Dead-letter queues — activated after 5 failed attempts InboundQueueDLQ = "inbound-queue-dlq", @@ -23,6 +24,7 @@ export enum QueueName { TenantProvisionQueueDLQ = "tenant-provision-queue-dlq", ActiveFetchQueueDLQ = "active-fetch-queue-dlq", RegistryReplicationQueueDLQ = "registry-replication-queue-dlq", + PluginInstallQueueDLQ = "plugin-install-queue-dlq", } /** Injection token — use to inject QueueService across the application. */ diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 825fbb7e..13e9501c 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -13,3 +13,4 @@ export type { export { createQueueModuleOptions } from "./queue.options.js"; export type { ProvisionDatabaseEvent } from "./events/provision-database.event.js"; export type { PluginMigrationEvent } from "./events/plugin-migration.event.js"; +export type { PluginInstallEvent } from "./events/plugin-install.event.js"; From 6114f67d72414f84794da5a75de19467e9d748f6 Mon Sep 17 00:00:00 2001 From: Pramod Narayana Date: Thu, 4 Jun 2026 14:46:06 +0530 Subject: [PATCH 4/5] hot reloading engine using plugin architecture code review --- .../plugins/plugins.controller.spec.ts | 13 ++-- .../src/modules/plugins/plugins.controller.ts | 6 +- .../api/src/modules/plugins/plugins.module.ts | 2 +- .../pieces/platform/registry/src/index.ts | 2 +- .../registry/src/pieces/pieces.module.ts | 4 +- .../src/pieces/plugin-manager.service.spec.ts | 64 ++++++++++++++++++- .../src/pieces/plugin-manager.service.ts | 13 +++- .../queue/src/events/plugin-install.event.ts | 8 +++ 8 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 packages/queue/src/events/plugin-install.event.ts diff --git a/apps/api/src/modules/plugins/plugins.controller.spec.ts b/apps/api/src/modules/plugins/plugins.controller.spec.ts index 4276059c..77e8b34b 100644 --- a/apps/api/src/modules/plugins/plugins.controller.spec.ts +++ b/apps/api/src/modules/plugins/plugins.controller.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-assignment */ import { Test, TestingModule } from '@nestjs/testing'; import { PluginsController, WebhookPayloadDto } from './plugins.controller.js'; import { PluginManagerService } from '@soopa/piece-registry'; @@ -75,9 +76,9 @@ describe('PluginsController', () => { version: '1.0.0', } as WebhookPayloadDto; - await expect(controller.handleNpmWebhook('sha256=somesignature', payload)).rejects.toThrow( - UnauthorizedException, - ); + await expect( + controller.handleNpmWebhook('sha256=somesignature', payload), + ).rejects.toThrow(UnauthorizedException); expect(queueService.send).not.toHaveBeenCalled(); }); @@ -161,7 +162,7 @@ describe('PluginsController', () => { requestMetadata: expect.objectContaining({ source: 'npm-webhook', }), - }) + }), ); // The controller should NOT directly call installPiece anymore @@ -180,7 +181,7 @@ describe('PluginsController', () => { expect(result).toEqual({ status: 'accepted', - message: 'Installation queued for @soopa/piece-slack@1.2.3' + message: 'Installation queued for @soopa/piece-slack@1.2.3', }); expect(queueService.send).toHaveBeenCalledWith( @@ -188,7 +189,7 @@ describe('PluginsController', () => { expect.objectContaining({ packageName: '@soopa/piece-slack', version: '1.2.3', - }) + }), ); }); }); diff --git a/apps/api/src/modules/plugins/plugins.controller.ts b/apps/api/src/modules/plugins/plugins.controller.ts index e027c93e..8f2242a8 100644 --- a/apps/api/src/modules/plugins/plugins.controller.ts +++ b/apps/api/src/modules/plugins/plugins.controller.ts @@ -57,7 +57,9 @@ export class PluginsController { // Signature validation is mandatory - fail if secret is not configured if (!webhookSecret) { - this.logger.error('NPM_WEBHOOK_SECRET is not configured. Rejecting webhook request.'); + this.logger.error( + 'NPM_WEBHOOK_SECRET is not configured. Rejecting webhook request.', + ); throw new UnauthorizedException('Webhook authentication not configured'); } @@ -86,7 +88,7 @@ export class PluginsController { ); throw new UnauthorizedException('Invalid webhook signature'); } - } catch (error) { + } catch (_error) { this.logger.warn( 'Invalid NPM webhook signature detected. Dropping payload.', ); diff --git a/apps/api/src/modules/plugins/plugins.module.ts b/apps/api/src/modules/plugins/plugins.module.ts index b868a040..5a220a37 100644 --- a/apps/api/src/modules/plugins/plugins.module.ts +++ b/apps/api/src/modules/plugins/plugins.module.ts @@ -3,7 +3,7 @@ import { PluginsController } from './plugins.controller.js'; import { PiecesModule } from '@soopa/piece-registry'; @Module({ - imports: [PiecesModule.forRoot()], // Brings in PluginManagerService + imports: [PiecesModule.forRoot({ anchorUrl: import.meta.url })], // Brings in PluginManagerService controllers: [PluginsController], }) export class PluginsModule {} diff --git a/packages/pieces/platform/registry/src/index.ts b/packages/pieces/platform/registry/src/index.ts index f563fc08..0e6c2a41 100644 --- a/packages/pieces/platform/registry/src/index.ts +++ b/packages/pieces/platform/registry/src/index.ts @@ -5,4 +5,4 @@ export * from './metadata/metadata-discovery.service.js'; export * from './metadata/metadata.module.js'; export * from './pieces/plugin-manager.service.js'; export * from './pieces/execution-worker.service.js'; -export * from './pieces/plugin-install.worker.js'; +export * from './pieces/migration-worker.service.js'; diff --git a/packages/pieces/platform/registry/src/pieces/pieces.module.ts b/packages/pieces/platform/registry/src/pieces/pieces.module.ts index 919f4da7..7d6a9abe 100644 --- a/packages/pieces/platform/registry/src/pieces/pieces.module.ts +++ b/packages/pieces/platform/registry/src/pieces/pieces.module.ts @@ -7,7 +7,6 @@ import { PieceLoaderService, PIECE_LOADER_ANCHOR_URL } from './piece-loader.serv import { PluginManagerService } from './plugin-manager.service.js'; import { ExecutionWorkerService } from './execution-worker.service.js'; import { MigrationWorkerService } from './migration-worker.service.js'; -import { PluginInstallWorkerService } from './plugin-install.worker.js'; import { DATABASE_CONNECTION } from '@soopa/database'; import type { DrizzleDb } from '@soopa/database'; import type { Piece } from '@soopa/piece-framework'; @@ -26,7 +25,6 @@ const SHARED_PROVIDERS = [ PluginManagerService, ExecutionWorkerService, MigrationWorkerService, - PluginInstallWorkerService, ]; /** @@ -51,7 +49,7 @@ export class PiecesModule { { provide: PIECE_LOADER_ANCHOR_URL, useValue: options.anchorUrl }, ...SHARED_PROVIDERS, ], - exports: [PieceRegistryService, PieceLoaderService, PluginManagerService, ExecutionWorkerService, MigrationWorkerService, PluginInstallWorkerService, PIECES], + exports: [PieceRegistryService, PieceLoaderService, PluginManagerService, ExecutionWorkerService, MigrationWorkerService, PIECES], }; } diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts index c1c2fdfb..46d5de71 100644 --- a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts @@ -3,6 +3,17 @@ import type { Mocked } from 'vitest'; import { PluginManagerService } from './plugin-manager.service.js'; import { QueueName } from '@soopa/queue'; import type { IQueueService } from '@soopa/queue'; +import * as fs from 'fs'; + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + mkdirSync: vi.fn(), + chmodSync: vi.fn(), + }; +}); // Mock live-plugin-manager so we don't actually hit the network or filesystem vi.mock('live-plugin-manager', () => { @@ -11,6 +22,7 @@ vi.mock('live-plugin-manager', () => { return { getInfo: vi.fn(), install: vi.fn(), + uninstall: vi.fn(), require: vi.fn(), }; }) @@ -30,6 +42,26 @@ describe('PluginManagerService', () => { service = new PluginManagerService(queueService); }); + describe('constructor', () => { + it('should create plugins directory with 0o700 permissions if it does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + new PluginManagerService(queueService); + + expect(fs.existsSync).toHaveBeenCalled(); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true, mode: 0o700 }); + }); + + it('should chmod existing directory to 0o700 if it already exists', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(true); + + new PluginManagerService(queueService); + + expect(fs.existsSync).toHaveBeenCalled(); + expect(fs.chmodSync).toHaveBeenCalledWith(expect.any(String), 0o700); + }); + }); + afterEach(() => { vi.clearAllMocks(); }); @@ -48,18 +80,19 @@ describe('PluginManagerService', () => { it('should install piece if it is not installed locally', async () => { const mockGetInfo = vi.spyOn((service as any).manager, 'getInfo').mockReturnValue(undefined); - const mockInstall = vi.spyOn((service as any).manager, 'install').mockResolvedValue({ version: '1.2.0', location: '/tmp/new' }); + const mockInstallPiece = vi.spyOn(service, 'installPiece').mockResolvedValue({ version: '1.2.0', location: '/tmp/new' }); const result = await service.ensurePiece('@soopa/piece-new', '1.2.0'); expect(result).toEqual({ version: '1.2.0', location: '/tmp/new' }); expect(mockGetInfo).toHaveBeenCalledWith('@soopa/piece-new'); - expect(mockInstall).toHaveBeenCalledWith('@soopa/piece-new', '1.2.0'); + expect(mockInstallPiece).toHaveBeenCalledWith('@soopa/piece-new', '1.2.0'); }); }); describe('installPiece', () => { it('should install piece and immediately dispatch a migration event to SQS', async () => { + process.env.ENABLE_PLUGIN_MIGRATIONS = 'true'; const mockInstall = vi.spyOn((service as any).manager, 'install').mockResolvedValue({ version: '2.0.0', location: '/tmp/plugin' }); const result = await service.installPiece('@soopa/piece-migrate', '2.0.0'); @@ -70,6 +103,18 @@ describe('PluginManagerService', () => { pluginLocation: '/tmp/plugin', pieceName: '@soopa/piece-migrate' }); + delete process.env.ENABLE_PLUGIN_MIGRATIONS; + }); + + it('should skip migration dispatch if ENABLE_PLUGIN_MIGRATIONS is not true', async () => { + delete process.env.ENABLE_PLUGIN_MIGRATIONS; + const mockInstall = vi.spyOn((service as any).manager, 'install').mockResolvedValue({ version: '2.0.0', location: '/tmp/plugin' }); + + const result = await service.installPiece('@soopa/piece-migrate', '2.0.0'); + + expect(result).toEqual({ version: '2.0.0', location: '/tmp/plugin' }); + expect(mockInstall).toHaveBeenCalledWith('@soopa/piece-migrate', '2.0.0'); + expect(queueService.send).not.toHaveBeenCalled(); }); it('should throw an error and NOT dispatch event if installation fails', async () => { @@ -78,6 +123,21 @@ describe('PluginManagerService', () => { await expect(service.installPiece('@soopa/piece-migrate', 'latest')).rejects.toThrow('NPM is down'); expect(queueService.send).not.toHaveBeenCalled(); }); + + it('should rollback installation if SQS dispatch fails', async () => { + process.env.ENABLE_PLUGIN_MIGRATIONS = 'true'; + const mockInstall = vi.spyOn((service as any).manager, 'install').mockResolvedValue({ version: '2.0.0', location: '/tmp/plugin' }); + const mockUninstall = vi.spyOn((service as any).manager, 'uninstall').mockResolvedValue(undefined); + + queueService.send.mockRejectedValue(new Error('SQS is down')); + + await expect(service.installPiece('@soopa/piece-migrate', '2.0.0')).rejects.toThrow('SQS is down'); + + expect(mockInstall).toHaveBeenCalledWith('@soopa/piece-migrate', '2.0.0'); + expect(mockUninstall).toHaveBeenCalledWith('@soopa/piece-migrate'); + + delete process.env.ENABLE_PLUGIN_MIGRATIONS; + }); }); describe('requirePiece', () => { diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts index 919e70a2..521bfcfa 100644 --- a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts @@ -64,8 +64,14 @@ export class PluginManagerService { pieceName: packageName }; - await this.queueService.send(QueueName.TenantProvisionQueue, migrationEvent); - this.logger.log(`Dispatched migration job for ${packageName} to SQS`); + try { + await this.queueService.send(QueueName.TenantProvisionQueue, migrationEvent); + this.logger.log(`Dispatched migration job for ${packageName} to SQS`); + } catch (queueError) { + this.logger.error(`Failed to dispatch migration job for ${packageName}. Rolling back installation...`, queueError); + await this.manager.uninstall(packageName); + throw queueError; + } } else { this.logger.debug( `Skipping migration dispatch for ${packageName} (ENABLE_PLUGIN_MIGRATIONS not set)` @@ -98,7 +104,8 @@ export class PluginManagerService { } this.logger.log(`Startup Sync: Downloading missing piece ${packageName}...`); - const pluginInfo = await this.manager.install(packageName, version); + // Delegate to installPiece so that migrations and publishing flow are triggered + const pluginInfo = await this.installPiece(packageName, version); this.logger.log(`Startup Sync: Installed ${packageName} to ${pluginInfo.location}`); return pluginInfo; } diff --git a/packages/queue/src/events/plugin-install.event.ts b/packages/queue/src/events/plugin-install.event.ts new file mode 100644 index 00000000..b58d4a30 --- /dev/null +++ b/packages/queue/src/events/plugin-install.event.ts @@ -0,0 +1,8 @@ +export interface PluginInstallEvent { + packageName: string; + version: string; + requestMetadata?: { + webhookReceivedAt: string; + source: string; + }; +} From 8b8f3f17ebb1cbbd39f9298ec2df657afdfbd28a Mon Sep 17 00:00:00 2001 From: Pramod Narayana Date: Thu, 4 Jun 2026 15:35:16 +0530 Subject: [PATCH 5/5] hot reloading engine using plugin architecture code review --- .../plugins/plugins.controller.spec.ts | 51 +++-- .../src/modules/plugins/plugins.controller.ts | 5 +- .../pieces/platform/registry/package.json | 5 +- .../pieces/migration-worker.service.spec.ts | 21 +- .../src/pieces/migration-worker.service.ts | 14 +- .../src/pieces/piece-execution.worker.ts | 5 +- .../src/pieces/plugin-manager.service.spec.ts | 49 +++-- .../src/pieces/plugin-manager.service.ts | 42 ++-- pnpm-lock.yaml | 179 ++---------------- scripts/init-localstack.sh | 1 + 10 files changed, 140 insertions(+), 232 deletions(-) diff --git a/apps/api/src/modules/plugins/plugins.controller.spec.ts b/apps/api/src/modules/plugins/plugins.controller.spec.ts index 77e8b34b..285b542e 100644 --- a/apps/api/src/modules/plugins/plugins.controller.spec.ts +++ b/apps/api/src/modules/plugins/plugins.controller.spec.ts @@ -56,8 +56,8 @@ describe('PluginsController', () => { vi.clearAllMocks(); }); - // Helper to generate valid HMAC signature from raw body string - const generateValidSignature = (rawBody: string): string => { + // Helper to generate valid HMAC signature from raw body buffer + const generateValidSignature = (rawBody: Buffer): string => { const hmac = crypto.createHmac('sha256', TEST_SECRET); return 'sha256=' + hmac.update(rawBody).digest('hex'); }; @@ -76,8 +76,10 @@ describe('PluginsController', () => { version: '1.0.0', } as WebhookPayloadDto; + const rawBody = Buffer.from(JSON.stringify(payload)); + await expect( - controller.handleNpmWebhook('sha256=somesignature', payload), + controller.handleNpmWebhook('sha256=somesignature', payload, rawBody), ).rejects.toThrow(UnauthorizedException); expect(queueService.send).not.toHaveBeenCalled(); }); @@ -88,9 +90,11 @@ describe('PluginsController', () => { version: '1.0.0', } as WebhookPayloadDto; - await expect(controller.handleNpmWebhook('', payload)).rejects.toThrow( - UnauthorizedException, - ); + const rawBody = Buffer.from(JSON.stringify(payload)); + + await expect( + controller.handleNpmWebhook('', payload, rawBody), + ).rejects.toThrow(UnauthorizedException); expect(queueService.send).not.toHaveBeenCalled(); }); @@ -100,19 +104,24 @@ describe('PluginsController', () => { version: '1.0.0', } as WebhookPayloadDto; const invalidSignature = 'sha256=invalidhash12345'; + const rawBody = Buffer.from(JSON.stringify(payload)); await expect( - controller.handleNpmWebhook(invalidSignature, payload), + controller.handleNpmWebhook(invalidSignature, payload, rawBody), ).rejects.toThrow(UnauthorizedException); expect(queueService.send).not.toHaveBeenCalled(); }); it('should ignore payload without a package name gracefully', async () => { const payload = { version: '1.0.0' } as WebhookPayloadDto; - const rawBody = JSON.stringify(payload); + const rawBody = Buffer.from(JSON.stringify(payload)); const signature = generateValidSignature(rawBody); - const result = await controller.handleNpmWebhook(signature, payload); + const result = await controller.handleNpmWebhook( + signature, + payload, + rawBody, + ); expect(result).toEqual({ status: 'ignored', @@ -126,10 +135,14 @@ describe('PluginsController', () => { name: '@other/piece-slack', version: '1.0.0', } as WebhookPayloadDto; - const rawBody = JSON.stringify(payload); + const rawBody = Buffer.from(JSON.stringify(payload)); const signature = generateValidSignature(rawBody); - const result = await controller.handleNpmWebhook(signature, payload); + const result = await controller.handleNpmWebhook( + signature, + payload, + rawBody, + ); expect(result).toEqual({ status: 'ignored', @@ -143,10 +156,14 @@ describe('PluginsController', () => { name: '@soopa/piece-slack', version: '1.2.3', } as unknown as WebhookPayloadDto; - const rawBody = JSON.stringify(payload); + const rawBody = Buffer.from(JSON.stringify(payload)); const signature = generateValidSignature(rawBody); - const result = await controller.handleNpmWebhook(signature, payload); + const result = await controller.handleNpmWebhook( + signature, + payload, + rawBody, + ); expect(result).toEqual({ status: 'accepted', @@ -174,10 +191,14 @@ describe('PluginsController', () => { name: '@soopa/piece-slack', version: '1.2.3', } as unknown as WebhookPayloadDto; - const rawBody = JSON.stringify(payload); + const rawBody = Buffer.from(JSON.stringify(payload)); const signature = generateValidSignature(rawBody); - const result = await controller.handleNpmWebhook(signature, payload); + const result = await controller.handleNpmWebhook( + signature, + payload, + rawBody, + ); expect(result).toEqual({ status: 'accepted', diff --git a/apps/api/src/modules/plugins/plugins.controller.ts b/apps/api/src/modules/plugins/plugins.controller.ts index 8f2242a8..75f0f4d1 100644 --- a/apps/api/src/modules/plugins/plugins.controller.ts +++ b/apps/api/src/modules/plugins/plugins.controller.ts @@ -6,6 +6,7 @@ import { Logger, UnauthorizedException, Inject, + RawBody, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PluginManagerService } from '@soopa/piece-registry'; @@ -49,6 +50,7 @@ export class PluginsController { async handleNpmWebhook( @Headers('x-npm-signature') signature: string, @Body() payloadRaw: WebhookPayloadDto, + @RawBody() rawBody: Buffer, ) { const payload = payloadRaw as unknown as WebhookPayload; this.logger.log('Received NPM publish webhook event'); @@ -69,8 +71,7 @@ export class PluginsController { // Validate HMAC signature to ensure request originated from our private registry const hmac = crypto.createHmac('sha256', webhookSecret); - const digest = - 'sha256=' + hmac.update(JSON.stringify(payload)).digest('hex'); + const digest = 'sha256=' + hmac.update(rawBody).digest('hex'); // Use constant-time comparison to prevent timing attacks try { diff --git a/packages/pieces/platform/registry/package.json b/packages/pieces/platform/registry/package.json index 0ea140c1..42122754 100644 --- a/packages/pieces/platform/registry/package.json +++ b/packages/pieces/platform/registry/package.json @@ -23,16 +23,17 @@ "@nestjs/config": "^4.0.2", "@soopa/cache": "workspace:*", "@soopa/database": "workspace:*", + "@soopa/credentials": "workspace:*", "@soopa/piece-framework": "workspace:*", "@soopa/queue": "workspace:*", - "drizzle-orm": "^0.45.2", + "drizzle-orm": "^0.45.1", "live-plugin-manager": "^1.1.0", "piscina": "^5.1.4" }, "devDependencies": { "@nestjs/testing": "^11.1.11", "typescript": "^5.9.3", - "vitest": "^4.1.0" + "vitest": "^2.1.9" }, "peerDependencies": { "@nestjs/common": "^11.0.1" diff --git a/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts b/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts index f4e96ab8..112f58b6 100644 --- a/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts +++ b/packages/pieces/platform/registry/src/pieces/migration-worker.service.spec.ts @@ -31,22 +31,31 @@ describe('MigrationWorkerService', () => { }); describe('runBackgroundMigrations', () => { + let originalEnablePluginMigrations: string | undefined; + beforeEach(() => { + originalEnablePluginMigrations = process.env.ENABLE_PLUGIN_MIGRATIONS; // Enable migrations for tests that need tenant handlers process.env.ENABLE_PLUGIN_MIGRATIONS = 'true'; }); afterEach(() => { - delete process.env.ENABLE_PLUGIN_MIGRATIONS; + if (originalEnablePluginMigrations === undefined) { + delete process.env.ENABLE_PLUGIN_MIGRATIONS; + } else { + process.env.ENABLE_PLUGIN_MIGRATIONS = originalEnablePluginMigrations; + } }); - it('should throw error if tenant handlers are not available', async () => { + it('should abort gracefully if tenant handlers are not available', async () => { delete process.env.ENABLE_PLUGIN_MIGRATIONS; - await expect(service.runBackgroundMigrations({ + await service.runBackgroundMigrations({ pluginLocation: '/tmp/plugin', pieceName: '@soopa/piece-migrate' - })).rejects.toThrow('Tenant handlers not available - migrations disabled'); + }); + + expect(queueService.send).not.toHaveBeenCalled(); }); it('should Fan-out to multiple queue messages if tenantId is missing', async () => { @@ -55,6 +64,7 @@ describe('MigrationWorkerService', () => { { id: 't1' }, { id: 't2' } ]); + vi.spyOn(service as any, 'getTenantDbConnection').mockResolvedValue({} as any); await service.runBackgroundMigrations({ pluginLocation: '/tmp/plugin', @@ -81,7 +91,7 @@ describe('MigrationWorkerService', () => { }); it('should execute single-tenant migration if tenantId is provided', async () => { - const getActiveTenantsSpy = vi.spyOn(service as any, 'getActiveTenants'); + const getActiveTenantsSpy = vi.spyOn(service as any, 'getActiveTenants').mockResolvedValue([]); const getTenantDbSpy = vi.spyOn(service as any, 'getTenantDbConnection').mockResolvedValue({} as any); await service.runBackgroundMigrations({ @@ -100,6 +110,7 @@ describe('MigrationWorkerService', () => { }); it('should throw an error if single-tenant migration fails to trigger Dead-Letter Queue', async () => { + vi.spyOn(service as any, 'getActiveTenants').mockResolvedValue([]); vi.spyOn(service as any, 'getTenantDbConnection').mockResolvedValue({} as any); (migrator.migrate as unknown as ReturnType).mockRejectedValueOnce(new Error('DB Timeout')); diff --git a/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts index 764a499a..753e86b8 100644 --- a/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts +++ b/packages/pieces/platform/registry/src/pieces/migration-worker.service.ts @@ -53,6 +53,13 @@ export class MigrationWorkerService implements OnModuleInit { * Checks if tenant handlers are available. Returns false if stubbed implementations exist. */ private areTenantHandlersAvailable(): boolean { + const isGetActiveTenantsStubbed = this.getActiveTenants.toString().includes('is not implemented'); + const isGetTenantDbConnectionStubbed = this.getTenantDbConnection.toString().includes('is not implemented'); + + if (isGetActiveTenantsStubbed || isGetTenantDbConnectionStubbed) { + return false; + } + return process.env.ENABLE_PLUGIN_MIGRATIONS === 'true'; } @@ -64,7 +71,8 @@ export class MigrationWorkerService implements OnModuleInit { typeof event === 'object' && event !== null && typeof event.pluginLocation === 'string' && - typeof event.pieceName === 'string' + typeof event.pieceName === 'string' && + (typeof event.tenantId === 'string' || typeof event.tenantId === 'undefined') ); } @@ -74,11 +82,11 @@ export class MigrationWorkerService implements OnModuleInit { async runBackgroundMigrations(event: PluginMigrationEvent) { // Guard against executing migrations when tenant handlers are not available if (!this.areTenantHandlersAvailable()) { - this.logger.error( + this.logger.debug( `Cannot run migrations for ${event.pieceName}: tenant handlers are not implemented. ` + 'Set ENABLE_PLUGIN_MIGRATIONS=true when implementations are ready.' ); - throw new Error('Tenant handlers not available - migrations disabled'); + return; } if (!event.tenantId) { diff --git a/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts index 2acae433..f0cfb2f9 100644 --- a/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts +++ b/packages/pieces/platform/registry/src/pieces/piece-execution.worker.ts @@ -1,5 +1,6 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import { createRequire } from 'node:module'; /** @@ -30,8 +31,8 @@ export default async function runIntegrationPiece(data: ExecutionData): Promise< // Define and canonicalize allowed roots // Reuse the same plugin-root logic as PluginManagerService to ensure consistency const isDev = process.env.NODE_ENV === 'development' || process.env.DEV_MODE === 'true'; - const appDataDir = process.env.APP_DATA_DIR || (isDev ? process.cwd() : path.join(require('node:os').homedir(), '.soopa')); - const pluginsPath = process.env.PLUGINS_PATH || (isDev ? path.join(require('node:os').tmpdir(), 'soopa-plugins') : path.join(appDataDir, 'plugins')); + const appDataDir = process.env.APP_DATA_DIR || (isDev ? process.cwd() : path.join(os.homedir(), '.soopa')); + const pluginsPath = process.env.PLUGINS_PATH || (isDev ? path.join(os.tmpdir(), 'soopa-plugins') : path.join(appDataDir, 'plugins')); const allowedRoots: string[] = []; diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts index 46d5de71..7a7ba1f8 100644 --- a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.spec.ts @@ -9,9 +9,11 @@ vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - existsSync: vi.fn(), - mkdirSync: vi.fn(), - chmodSync: vi.fn(), + promises: { + access: vi.fn(), + mkdir: vi.fn(), + chmod: vi.fn(), + } }; }); @@ -42,23 +44,25 @@ describe('PluginManagerService', () => { service = new PluginManagerService(queueService); }); - describe('constructor', () => { - it('should create plugins directory with 0o700 permissions if it does not exist', () => { - vi.mocked(fs.existsSync).mockReturnValueOnce(false); + describe('onModuleInit', () => { + it('should create plugins directory with 0o700 permissions if it does not exist', async () => { + const error = new Error('ENOENT') as any; + error.code = 'ENOENT'; + vi.mocked(fs.promises.access).mockRejectedValueOnce(error); - new PluginManagerService(queueService); + await service.onModuleInit(); - expect(fs.existsSync).toHaveBeenCalled(); - expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true, mode: 0o700 }); + expect(fs.promises.access).toHaveBeenCalled(); + expect(fs.promises.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true, mode: 0o700 }); }); - it('should chmod existing directory to 0o700 if it already exists', () => { - vi.mocked(fs.existsSync).mockReturnValueOnce(true); + it('should chmod existing directory to 0o700 if it already exists', async () => { + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); - new PluginManagerService(queueService); + await service.onModuleInit(); - expect(fs.existsSync).toHaveBeenCalled(); - expect(fs.chmodSync).toHaveBeenCalledWith(expect.any(String), 0o700); + expect(fs.promises.access).toHaveBeenCalled(); + expect(fs.promises.chmod).toHaveBeenCalledWith(expect.any(String), 0o700); }); }); @@ -91,6 +95,20 @@ describe('PluginManagerService', () => { }); describe('installPiece', () => { + let originalEnablePluginMigrations: string | undefined; + + beforeEach(() => { + originalEnablePluginMigrations = process.env.ENABLE_PLUGIN_MIGRATIONS; + }); + + afterEach(() => { + if (originalEnablePluginMigrations === undefined) { + delete process.env.ENABLE_PLUGIN_MIGRATIONS; + } else { + process.env.ENABLE_PLUGIN_MIGRATIONS = originalEnablePluginMigrations; + } + }); + it('should install piece and immediately dispatch a migration event to SQS', async () => { process.env.ENABLE_PLUGIN_MIGRATIONS = 'true'; const mockInstall = vi.spyOn((service as any).manager, 'install').mockResolvedValue({ version: '2.0.0', location: '/tmp/plugin' }); @@ -103,7 +121,6 @@ describe('PluginManagerService', () => { pluginLocation: '/tmp/plugin', pieceName: '@soopa/piece-migrate' }); - delete process.env.ENABLE_PLUGIN_MIGRATIONS; }); it('should skip migration dispatch if ENABLE_PLUGIN_MIGRATIONS is not true', async () => { @@ -135,8 +152,6 @@ describe('PluginManagerService', () => { expect(mockInstall).toHaveBeenCalledWith('@soopa/piece-migrate', '2.0.0'); expect(mockUninstall).toHaveBeenCalledWith('@soopa/piece-migrate'); - - delete process.env.ENABLE_PLUGIN_MIGRATIONS; }); }); diff --git a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts index 521bfcfa..d999f5e0 100644 --- a/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts +++ b/packages/pieces/platform/registry/src/pieces/plugin-manager.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, Inject } from '@nestjs/common'; +import { Injectable, Logger, Inject, OnModuleInit } from '@nestjs/common'; import { PluginManager } from 'live-plugin-manager'; import * as path from 'path'; import * as os from 'os'; @@ -7,7 +7,7 @@ import { QUEUE_SERVICE, QueueName } from '@soopa/queue'; import type { IQueueService, PluginMigrationEvent } from '@soopa/queue'; @Injectable() -export class PluginManagerService { +export class PluginManagerService implements OnModuleInit { private readonly logger = new Logger(PluginManagerService.name); private manager: PluginManager; private readonly pluginsPath: string; @@ -20,31 +20,35 @@ export class PluginManagerService { constructor( @Inject(QUEUE_SERVICE) private readonly queueService: IQueueService ) { - // Determine plugins path based on environment - const isDev = process.env.NODE_ENV === 'development' || process.env.DEV_MODE === 'true'; - const appDataDir = process.env.APP_DATA_DIR || (isDev ? process.cwd() : path.join(os.homedir(), '.soopa')); - - // Default to app-owned persistent directory, only use tmpdir in development - this.pluginsPath = process.env.PLUGINS_PATH || - (isDev ? path.join(os.tmpdir(), 'soopa-plugins') : path.join(appDataDir, 'plugins')); - + this.pluginsPath = PluginManagerService.PLUGINS_PATH; this.logger.log(`Initializing Live Plugin Manager at: ${this.pluginsPath}`); - // Ensure the directory exists with restrictive permissions - if (!fs.existsSync(this.pluginsPath)) { - fs.mkdirSync(this.pluginsPath, { recursive: true, mode: 0o700 }); - this.logger.log(`Created plugins directory with restricted permissions (700): ${this.pluginsPath}`); - } else { - // Apply restrictive permissions to existing directory - fs.chmodSync(this.pluginsPath, 0o700); - } - this.manager = new PluginManager({ pluginsPath: this.pluginsPath, npmRegistryUrl: process.env.NPM_REGISTRY_URL || 'https://registry.npmjs.org/' }); } + async onModuleInit() { + try { + try { + await fs.promises.access(this.pluginsPath); + // Apply restrictive permissions to existing directory + await fs.promises.chmod(this.pluginsPath, 0o700); + } catch (err: any) { + if (err.code === 'ENOENT') { + await fs.promises.mkdir(this.pluginsPath, { recursive: true, mode: 0o700 }); + this.logger.log(`Created plugins directory with restricted permissions (700): ${this.pluginsPath}`); + } else { + throw err; + } + } + } catch (error) { + this.logger.error(`Failed to initialize plugins directory at ${this.pluginsPath}`, error); + throw error; + } + } + /** * Downloads and installs a piece from the NPM registry. * @param packageName The name of the piece (e.g. '@soopa/piece-revenova') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa577d30..686dd550 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,7 +126,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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@opentelemetry/api@1.9.0)(@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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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 @@ -694,7 +694,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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@opentelemetry/api@1.9.0)(@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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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 @@ -1182,7 +1182,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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@opentelemetry/api@1.9.0)(@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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) @@ -1397,6 +1397,9 @@ importers: '@soopa/cache': specifier: workspace:* version: link:../../../cache + '@soopa/credentials': + specifier: workspace:* + version: link:../../../credentials '@soopa/database': specifier: workspace:* version: link:../../../database @@ -1407,8 +1410,8 @@ importers: specifier: workspace:* version: link:../../../queue drizzle-orm: - specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) + specifier: ^0.45.1 + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3) live-plugin-manager: specifier: ^1.1.0 version: 1.1.0 @@ -1423,8 +1426,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 vitest: - specifier: ^4.1.0 - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@25.0.5)(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@25.0.5)(typescript@6.0.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)))(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.5.0)(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@25.5.0)(typescript@5.9.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1) packages/pieces/platform/salesforce: dependencies: @@ -5355,9 +5358,6 @@ packages: '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@2.1.9': resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: @@ -5380,62 +5380,36 @@ packages: vite: optional: true - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - '@vitest/pretty-format@2.1.9': resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@2.1.9': resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@2.1.9': resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - '@vitest/spy@2.1.9': resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -6989,9 +6963,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -10314,9 +10285,6 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -10626,10 +10594,6 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -11161,47 +11125,6 @@ packages: jsdom: optional: true - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -16648,15 +16571,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/expect@4.1.8': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - chai: 6.2.2 - tinyrainbow: 3.1.0 - '@vitest/mocker@2.1.9(msw@2.12.10(@types/node@22.19.3)(typescript@5.9.3))(vite@5.4.21(@types/node@22.19.3)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 @@ -16720,15 +16634,6 @@ snapshots: msw: 2.12.10(@types/node@25.5.0)(typescript@6.0.3) vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.1.8(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 4.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.12.10(@types/node@25.5.0)(typescript@5.9.3) - vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -16737,10 +16642,6 @@ snapshots: dependencies: tinyrainbow: 3.0.3 - '@vitest/pretty-format@4.1.8': - dependencies: - tinyrainbow: 3.1.0 - '@vitest/runner@2.1.9': dependencies: '@vitest/utils': 2.1.9 @@ -16751,11 +16652,6 @@ snapshots: '@vitest/utils': 4.0.18 pathe: 2.0.3 - '@vitest/runner@4.1.8': - dependencies: - '@vitest/utils': 4.1.8 - pathe: 2.0.3 - '@vitest/snapshot@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -16768,21 +16664,12 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/snapshot@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 - magic-string: 0.30.21 - pathe: 2.0.3 - '@vitest/spy@2.1.9': dependencies: tinyspy: 3.0.2 '@vitest/spy@4.0.18': {} - '@vitest/spy@4.1.8': {} - '@vitest/utils@2.1.9': dependencies: '@vitest/pretty-format': 2.1.9 @@ -16794,12 +16681,6 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 - '@vitest/utils@4.1.8': - dependencies: - '@vitest/pretty-format': 4.1.8 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -17353,7 +17234,7 @@ snapshots: bcryptjs@3.0.3: {} - better-auth@1.4.10(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@opentelemetry/api@1.9.0)(@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(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@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)) @@ -18169,6 +18050,7 @@ snapshots: '@types/pg': 8.16.0 kysely: 0.28.9 pg: 8.16.3 + optional: true dunder-proto@1.0.1: dependencies: @@ -18300,8 +18182,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -22189,8 +22069,6 @@ snapshots: std-env@3.10.0: {} - std-env@4.1.0: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -22550,8 +22428,6 @@ snapshots: tinyrainbow@3.0.3: {} - tinyrainbow@3.1.0: {} - tinyspy@3.0.2: {} tldts-core@7.0.19: {} @@ -23402,37 +23278,6 @@ snapshots: - tsx - yaml - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@25.0.5)(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@25.0.5)(typescript@6.0.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)))(happy-dom@20.1.0)(jsdom@27.4.0(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(msw@2.12.10(@types/node@25.5.0)(typescript@5.9.3))(vite@7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.5.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 25.5.0 - '@vitest/coverage-v8': 2.1.9(vitest@2.1.9(@types/node@25.0.5)(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@25.0.5)(typescript@6.0.3))(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.44.1)) - happy-dom: 20.1.0 - jsdom: 27.4.0(@noble/hashes@2.0.1) - transitivePeerDependencies: - - msw - vscode-uri@3.1.0: {} w3c-xmlserializer@5.0.0: diff --git a/scripts/init-localstack.sh b/scripts/init-localstack.sh index 6150c728..7754b980 100755 --- a/scripts/init-localstack.sh +++ b/scripts/init-localstack.sh @@ -20,6 +20,7 @@ QUEUES=( "active-fetch-queue" "ai-copilot-queue" "registry-replication-queue" + "plugin-install-queue" ) # Step 1: Create all DLQs first (no redrive policy); idempotent — ignore