From 967c1311b948d013dbcf84c2943f9879257f43d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 17:13:25 +0000 Subject: [PATCH 01/33] feat(admin-scripts): add implementation plan and package skeleton Add comprehensive plan for Admin Script Runner service aligned with next branch architecture (command pattern, repository factories, Prisma schemas, multi-database support). Includes initial package structure for @friggframework/admin-scripts. --- PLAN-admin-script-runner.md | 813 ++++++++++++++++++++++++++++ packages/admin-scripts/index.js | 83 +++ packages/admin-scripts/package.json | 49 ++ 3 files changed, 945 insertions(+) create mode 100644 PLAN-admin-script-runner.md create mode 100644 packages/admin-scripts/index.js create mode 100644 packages/admin-scripts/package.json diff --git a/PLAN-admin-script-runner.md b/PLAN-admin-script-runner.md new file mode 100644 index 000000000..7dcc6d305 --- /dev/null +++ b/PLAN-admin-script-runner.md @@ -0,0 +1,813 @@ +# Admin Script Runner Service - Implementation Plan + +> **Status**: Planning (Updated for `next` branch architecture) +> **Target Branch**: `next` +> **Feature Branch**: `claude/add-admin-script-runner-*` + +--- + +## Executive Summary + +The Admin Script Runner enables Frigg adopters to write and execute scripts in a hosted environment with access to VPC/KMS-secured database connections. This is a **high-risk, high-value** feature requiring careful security controls. + +### Core Use Cases +1. **Healing Scripts** - Fix broken integrations (e.g., Attio config corruption) +2. **Recurring Maintenance** - Webhook refreshers (e.g., Zoho channel expiry) +3. **Built-in Utilities** - OAuth refresh, DB cleanup, log rotation + +--- + +## CRITICAL: `next` Branch Architecture Alignment + +The `next` branch has a **fundamentally different architecture** from `main`: + +| Aspect | `main` Branch | `next` Branch | +|--------|---------------|---------------| +| ORM | Mongoose | Prisma | +| Data Access | Direct Model calls | Command Pattern | +| DB Support | MongoDB only | MongoDB, PostgreSQL, DocumentDB | +| Repository | None | Interface + Factory Pattern | +| Encryption | Basic | Field-level KMS/AES encryption | + +**This plan follows `next` branch patterns:** +- `createAdminScriptCommands()` factory (like `createIntegrationCommands()`) +- Repository interfaces with factory pattern +- Prisma schema definitions +- Encryption schema registry integration + +--- + +## Architecture Decision Records + +### ADR-1: Follow Command Pattern from `next` Branch + +**Decision**: Create `createAdminScriptCommands()` following the existing command factory pattern. + +**Rationale**: +- Consistent with `createIntegrationCommands()`, `createUserCommands()`, etc. +- Database-agnostic via repository factories +- Standardized error handling (returns `{ error, reason, code }` objects) + +**Implementation**: +```javascript +// packages/core/application/commands/admin-script-commands.js +function createAdminScriptCommands() { + const scriptExecutionRepository = createScriptExecutionRepository(); + const adminApiKeyRepository = createAdminApiKeyRepository(); + + return { + async executeScript({ scriptName, params, adminKeyId }) { + try { + // Create execution record, run script, update status + } catch (error) { + return mapErrorToResponse(error); + } + }, + async findExecutionById(executionId) { ... }, + async findExecutionsByScriptName(scriptName) { ... }, + async validateAdminApiKey(rawKey) { ... }, + // ... more commands + }; +} +``` + +### ADR-2: Repository Factory Pattern + +**Decision**: Create repository interfaces and factories for `AdminApiKey` and `ScriptExecution`. + +**Rationale**: +- Support MongoDB, PostgreSQL, DocumentDB +- Consistent with existing repository pattern in `next` +- Testable via mock repositories + +**Structure**: +``` +packages/core/admin-scripts/repositories/ +├── admin-api-key-repository-interface.js +├── admin-api-key-repository-factory.js +├── admin-api-key-repository-mongo.js +├── admin-api-key-repository-postgres.js +├── admin-api-key-repository-documentdb.js +├── script-execution-repository-interface.js +├── script-execution-repository-factory.js +├── script-execution-repository-mongo.js +├── script-execution-repository-postgres.js +└── script-execution-repository-documentdb.js +``` + +### ADR-3: Integrate with Existing Commands + +**Decision**: Extend `createFriggCommands()` to include admin script commands when configured. + +**Rationale**: +- Single unified command interface +- Scripts can use existing commands (user, entity, credential, integration) +- Consistent developer experience + +**Implementation**: +```javascript +// Extended createFriggCommands in application/index.js +function createFriggCommands({ integrationClass, enableAdminScripts = false }) { + const commands = { + ...createIntegrationCommands({ integrationClass }), + ...createUserCommands(), + ...createEntityCommands(), + ...createCredentialCommands(), + }; + + if (enableAdminScripts) { + Object.assign(commands, createAdminScriptCommands()); + } + + return commands; +} +``` + +### ADR-4: Separate Package with Core Integration + +**Decision**: Create `@friggframework/admin-scripts` package that integrates with `@friggframework/core`. + +**Rationale**: +- Domain models (AdminApiKey, ScriptExecution) go in `core` (like other models) +- Application logic (ScriptRunner, FriggCommands for scripts) in separate package +- Allows opt-in installation + +--- + +## Domain Model (Prisma Schema) + +### Prisma Schema Additions + +```prisma +// Add to packages/core/prisma-mongodb/schema.prisma + +enum ScriptExecutionStatus { + PENDING + RUNNING + COMPLETED + FAILED + TIMEOUT + CANCELLED +} + +enum ScriptTrigger { + MANUAL + SCHEDULED + QUEUE + WEBHOOK +} + +model AdminApiKey { + id String @id @default(auto()) @map("_id") @db.ObjectId + keyHash String @unique // bcrypt hashed + keyLast4 String // Last 4 chars for display + name String // Human-readable name + scopes String[] // ['scripts:execute', 'scripts:read'] + expiresAt DateTime? + createdBy String? // User/admin who created + lastUsedAt DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([keyHash]) + @@index([isActive]) +} + +model ScriptExecution { + id String @id @default(auto()) @map("_id") @db.ObjectId + scriptName String + scriptVersion String? + status ScriptExecutionStatus @default(PENDING) + trigger ScriptTrigger + input Json? + output Json? + logs Json[] // [{level, message, data, timestamp}] + metricsStartTime DateTime? + metricsEndTime DateTime? + metricsDurationMs Int? + errorName String? + errorMessage String? + errorStack String? + auditApiKeyName String? + auditApiKeyLast4 String? + auditIpAddress String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([scriptName, createdAt(sort: Desc)]) + @@index([status]) +} +``` + +### PostgreSQL Schema (Equivalent) + +```prisma +// Add to packages/core/prisma-postgresql/schema.prisma + +enum ScriptExecutionStatus { + PENDING + RUNNING + COMPLETED + FAILED + TIMEOUT + CANCELLED +} + +enum ScriptTrigger { + MANUAL + SCHEDULED + QUEUE + WEBHOOK +} + +model AdminApiKey { + id Int @id @default(autoincrement()) + keyHash String @unique + keyLast4 String + name String + scopes String[] + expiresAt DateTime? + createdBy String? + lastUsedAt DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([keyHash]) + @@index([isActive]) +} + +model ScriptExecution { + id Int @id @default(autoincrement()) + scriptName String + scriptVersion String? + status ScriptExecutionStatus @default(PENDING) + trigger ScriptTrigger + input Json? + output Json? + logs Json[] + metricsStartTime DateTime? + metricsEndTime DateTime? + metricsDurationMs Int? + errorName String? + errorMessage String? + errorStack String? + auditApiKeyName String? + auditApiKeyLast4 String? + auditIpAddress String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([scriptName, createdAt(sort: Desc)]) + @@index([status]) +} +``` + +--- + +## Repository Interfaces + +### AdminApiKeyRepositoryInterface + +```javascript +// packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js +class AdminApiKeyRepositoryInterface { + async createApiKey({ name, scopes, expiresAt, createdBy }) { } + async findApiKeyByHash(keyHash) { } + async findApiKeyById(id) { } + async findActiveApiKeys() { } + async updateApiKeyLastUsed(id) { } + async deactivateApiKey(id) { } + async deleteApiKey(id) { } +} +``` + +### ScriptExecutionRepositoryInterface + +```javascript +// packages/core/admin-scripts/repositories/script-execution-repository-interface.js +class ScriptExecutionRepositoryInterface { + async createExecution({ scriptName, scriptVersion, trigger, input, audit }) { } + async findExecutionById(id) { } + async findExecutionsByScriptName(scriptName, options = {}) { } + async findExecutionsByStatus(status, options = {}) { } + async updateExecutionStatus(id, status) { } + async updateExecutionOutput(id, output) { } + async updateExecutionError(id, error) { } + async updateExecutionMetrics(id, metrics) { } + async appendExecutionLog(id, logEntry) { } + async deleteExecutionsOlderThan(date) { } +} +``` + +--- + +## Command Pattern Implementation + +### createAdminScriptCommands() + +```javascript +// packages/core/application/commands/admin-script-commands.js +const { createAdminApiKeyRepository } = require('../../admin-scripts/repositories/admin-api-key-repository-factory'); +const { createScriptExecutionRepository } = require('../../admin-scripts/repositories/script-execution-repository-factory'); +const bcrypt = require('bcryptjs'); + +const ERROR_CODE_MAP = { + INVALID_API_KEY: 401, + EXPIRED_API_KEY: 401, + SCRIPT_NOT_FOUND: 404, + EXECUTION_NOT_FOUND: 404, + UNAUTHORIZED_SCOPE: 403, +}; + +function mapErrorToResponse(error) { + const status = ERROR_CODE_MAP[error?.code] || 500; + return { error: status, reason: error?.message, code: error?.code }; +} + +function createAdminScriptCommands() { + const apiKeyRepository = createAdminApiKeyRepository(); + const executionRepository = createScriptExecutionRepository(); + + return { + // API Key Management + async createAdminApiKey({ name, scopes, expiresAt, createdBy }) { + try { + const rawKey = require('uuid').v4(); + const keyHash = await bcrypt.hash(rawKey, 10); + const keyLast4 = rawKey.slice(-4); + + const record = await apiKeyRepository.createApiKey({ + keyHash, keyLast4, name, scopes, expiresAt, createdBy + }); + + return { + id: record.id, + rawKey, // Only returned once! + name: record.name, + keyLast4: record.keyLast4, + scopes: record.scopes, + expiresAt: record.expiresAt, + }; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + async validateAdminApiKey(rawKey) { + try { + // Find all active keys and compare hashes + const activeKeys = await apiKeyRepository.findActiveApiKeys(); + for (const key of activeKeys) { + const isMatch = await bcrypt.compare(rawKey, key.keyHash); + if (isMatch) { + if (key.expiresAt && new Date(key.expiresAt) < new Date()) { + const error = new Error('API key has expired'); + error.code = 'EXPIRED_API_KEY'; + return mapErrorToResponse(error); + } + await apiKeyRepository.updateApiKeyLastUsed(key.id); + return { valid: true, apiKey: key }; + } + } + const error = new Error('Invalid API key'); + error.code = 'INVALID_API_KEY'; + return mapErrorToResponse(error); + } catch (error) { + return mapErrorToResponse(error); + } + }, + + // Execution Management + async createScriptExecution({ scriptName, scriptVersion, trigger, input, audit }) { + try { + return await executionRepository.createExecution({ + scriptName, scriptVersion, trigger, input, audit + }); + } catch (error) { + return mapErrorToResponse(error); + } + }, + + async updateScriptExecutionStatus(executionId, status) { + try { + return await executionRepository.updateExecutionStatus(executionId, status); + } catch (error) { + return mapErrorToResponse(error); + } + }, + + async findScriptExecutionById(executionId) { + try { + const execution = await executionRepository.findExecutionById(executionId); + if (!execution) { + const error = new Error('Execution not found'); + error.code = 'EXECUTION_NOT_FOUND'; + return mapErrorToResponse(error); + } + return execution; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + async findScriptExecutionsByName(scriptName, options = {}) { + try { + return await executionRepository.findExecutionsByScriptName(scriptName, options); + } catch (error) { + return []; + } + }, + + async appendScriptExecutionLog(executionId, logEntry) { + try { + return await executionRepository.appendExecutionLog(executionId, logEntry); + } catch (error) { + return mapErrorToResponse(error); + } + }, + + async completeScriptExecution(executionId, { status, output, error, metrics }) { + try { + if (status) await executionRepository.updateExecutionStatus(executionId, status); + if (output) await executionRepository.updateExecutionOutput(executionId, output); + if (error) await executionRepository.updateExecutionError(executionId, error); + if (metrics) await executionRepository.updateExecutionMetrics(executionId, metrics); + return { success: true }; + } catch (err) { + return mapErrorToResponse(err); + } + }, + }; +} + +module.exports = { createAdminScriptCommands }; +``` + +--- + +## Security Requirements (Phased) + +### Phase 1: MVP - BLOCKING Requirements + +| Requirement | Implementation | Priority | +|------------|----------------|----------| +| Admin API Key Auth | `AdminApiKey` model with bcrypt hash | P0 | +| Execution Timeout | Reuse `TimeoutCatcher` (5-15 min max) | P0 | +| Basic Audit Log | `ScriptExecution` model stores all runs | P0 | +| Input Validation | JSON Schema validation on params | P0 | +| Result Size Limits | Max 1MB output, truncate if exceeded | P0 | + +### Phase 2: Production Hardening + +| Requirement | Implementation | Priority | +|------------|----------------|----------| +| Rate Limiting | Per-key: 10/min, Global: 100/min | P1 | +| Tenant Scoping | Query interceptor for tenant filter | P1 | +| Credential Proxy | Scripts request by name, not raw values | P1 | +| Dry-Run Mode | Preview affected records before execution | P1 | + +### Phase 3: Enterprise Features + +| Requirement | Implementation | Priority | +|------------|----------------|----------| +| Approval Workflow | Two-admin approval for production scripts | P2 | +| VM Sandbox | `isolated-vm` for untrusted scripts | P2 | +| Rollback | Pre-execution snapshots, auto-revert on error | P2 | + +--- + +## Package Structure (Updated for `next`) + +``` +packages/core/ # Models & Repositories in core +├── admin-scripts/ +│ ├── repositories/ +│ │ ├── admin-api-key-repository-interface.js +│ │ ├── admin-api-key-repository-factory.js +│ │ ├── admin-api-key-repository-mongo.js +│ │ ├── admin-api-key-repository-postgres.js +│ │ ├── admin-api-key-repository-documentdb.js +│ │ ├── script-execution-repository-interface.js +│ │ ├── script-execution-repository-factory.js +│ │ ├── script-execution-repository-mongo.js +│ │ ├── script-execution-repository-postgres.js +│ │ └── script-execution-repository-documentdb.js +│ └── index.js +├── application/ +│ └── commands/ +│ └── admin-script-commands.js # Command factory +├── prisma-mongodb/ +│ └── schema.prisma # Add AdminApiKey, ScriptExecution +└── prisma-postgresql/ + └── schema.prisma # Add AdminApiKey, ScriptExecution + +packages/admin-scripts/ # Application logic & builtins +├── package.json +├── index.js +├── src/ +│ ├── application/ +│ │ ├── script-factory.js # Script registration +│ │ ├── script-context.js # Execution context +│ │ ├── script-runner.js # Orchestrates execution +│ │ └── admin-frigg-commands.js # Helper API for scripts +│ ├── infrastructure/ +│ │ ├── create-admin-script-router.js +│ │ ├── create-script-handler.js +│ │ ├── script-queue-worker.js +│ │ └── admin-auth-middleware.js +│ ├── adapters/ +│ │ ├── eventbridge-scheduler.js # Phase 2 +│ │ └── local-cron-scheduler.js # Dev/test +│ └── builtins/ +│ ├── oauth-token-refresh.js +│ ├── integration-health-check.js +│ └── index.js +├── test/ +│ ├── script-factory.test.js +│ ├── script-runner.test.js +│ ├── admin-script-commands.test.js +│ └── integration/ +│ └── execute-script.test.js +└── types/ + └── index.d.ts +``` + +--- + +## FriggCommands for Scripts (AdminFriggCommands) + +Scripts receive an enhanced `frigg` object that wraps existing commands: + +```javascript +// packages/admin-scripts/src/application/admin-frigg-commands.js +const { createFriggCommands } = require('@friggframework/core'); + +class AdminFriggCommands { + constructor(params) { + this.executionId = params.executionId; + this.integrationClass = params.integrationClass; + this.logs = []; + + // Get existing Frigg commands + this.commands = createFriggCommands({ + integrationClass: this.integrationClass, + enableAdminScripts: true, + }); + } + + // Integration Access (uses existing commands) + async listIntegrations(filter = {}) { + // Uses findIntegrationsByUserId or custom query + return this.commands.findIntegrationsByUserId(filter.userId); + } + + async getIntegration(id) { + const result = await this.commands.loadIntegrationContextById(id); + return result.error ? null : result.context; + } + + async instantiate(integrationId) { + const result = await this.commands.loadIntegrationContextById(integrationId); + if (result.error) { + throw new Error(result.reason); + } + return result.context; + } + + // Entity Access + async listEntities(filter = {}) { + if (filter.userId) { + return this.commands.findEntitiesByUserId(filter.userId); + } + return this.commands.findEntity(filter); + } + + // Logging + log(level, message, data = {}) { + const entry = { + level, + message, + data, + timestamp: new Date().toISOString(), + }; + this.logs.push(entry); + + // Also append to execution record + this.commands.appendScriptExecutionLog(this.executionId, entry); + } + + // Execution info + getExecutionId() { + return this.executionId; + } + + getLogs() { + return this.logs; + } +} + +module.exports = { AdminFriggCommands }; +``` + +--- + +## Implementation Phases (Updated) + +### Phase 1: MVP (Manual Execution Only) + +**Scope**: +1. Add Prisma schema for `AdminApiKey`, `ScriptExecution` +2. Create repository interfaces and factories (MongoDB, PostgreSQL, DocumentDB) +3. Implement `createAdminScriptCommands()` command factory +4. Build `ScriptFactory` for registration/loading +5. Build `AdminFriggCommands` helper API +6. Create admin router with `/execute` endpoint +7. Lambda handler with TimeoutCatcher +8. 2 built-in scripts (oauth-refresh, health-check) + +**Deliverables**: +1. Prisma schema additions + migrations +2. Repository implementations for all 3 DBs +3. Command factory +4. `@friggframework/admin-scripts` package +5. Test suite + +**NOT in Phase 1**: +- Scheduling/cron +- Queue-based async execution +- Approval workflows +- Sandboxing + +### Phase 2: Scheduling & Async + +**Scope**: +- EventBridge Scheduler integration (AWS SDK v3) +- SQS queue worker for async execution +- Schedule management endpoints +- Zoho webhook refresher script + +### Phase 3: Production Hardening + +**Scope**: +- Rate limiting middleware +- Tenant isolation +- Dry-run mode +- Approval workflow + +### Phase 4: Enterprise & Advanced + +**Scope**: +- VM sandbox +- Rollback mechanism +- Step Functions for long-running scripts + +--- + +## Example Scripts (Updated for Command Pattern) + +### Healing Script (Attio Config Fix) + +```javascript +class AttioConfigHealingScript { + static name = 'attio-config-healing'; + static version = '1.0.0'; + static description = 'Fix broken Attio integrations with corrupted config'; + + static inputSchema = { + type: 'object', + properties: { + dryRun: { type: 'boolean', default: true }, + integrationIds: { type: 'array', items: { type: 'string' } } + } + }; + + async execute(frigg, params) { + const { dryRun = true, integrationIds } = params; + + // Use command pattern to find integrations + const allIntegrations = await frigg.listIntegrations({}); + const brokenIntegrations = allIntegrations.filter(int => + int.config?.type === 'attio' && int.status === 'ERROR' + ); + + frigg.log('info', `Found ${brokenIntegrations.length} broken Attio integrations`); + + const results = { fixed: 0, failed: 0, skipped: 0 }; + + for (const int of brokenIntegrations) { + try { + if (dryRun) { + frigg.log('info', `[DRY RUN] Would fix integration ${int.id}`); + results.skipped++; + continue; + } + + const instance = await frigg.instantiate(int.id); + + // Rebuild config from API state + const apiConfig = await instance.primary.api.getConnectionConfig(); + + // Use command to update + await frigg.commands.updateIntegrationConfig({ + integrationId: int.id, + config: { + ...int.config, + ...apiConfig, + _healedAt: new Date().toISOString() + } + }); + + frigg.log('info', `Fixed integration ${int.id}`); + results.fixed++; + } catch (error) { + frigg.log('error', `Failed to fix ${int.id}`, { error: error.message }); + results.failed++; + } + } + + return results; + } +} + +module.exports = AttioConfigHealingScript; +``` + +--- + +## Testing Strategy + +### Unit Tests +- Repository implementations (mock Prisma) +- Command factory (mock repositories) +- ScriptFactory registration/lookup +- AdminFriggCommands methods + +### Integration Tests +- Full execution flow with test database +- Router endpoints with auth +- Multi-database compatibility (MongoDB, PostgreSQL) + +### E2E Tests +- Manual trigger via API +- Error handling and timeout behavior + +--- + +## Files to Create/Modify + +### New Files in `packages/core/` +``` +packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js +packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js +packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js +packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js +packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js +packages/core/admin-scripts/repositories/script-execution-repository-interface.js +packages/core/admin-scripts/repositories/script-execution-repository-factory.js +packages/core/admin-scripts/repositories/script-execution-repository-mongo.js +packages/core/admin-scripts/repositories/script-execution-repository-postgres.js +packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js +packages/core/admin-scripts/index.js +packages/core/application/commands/admin-script-commands.js +``` + +### Modify in `packages/core/` +``` +packages/core/prisma-mongodb/schema.prisma (add AdminApiKey, ScriptExecution) +packages/core/prisma-postgresql/schema.prisma (add AdminApiKey, ScriptExecution) +packages/core/application/index.js (export createAdminScriptCommands) +``` + +### New Package `packages/admin-scripts/` +``` +packages/admin-scripts/package.json +packages/admin-scripts/index.js +packages/admin-scripts/src/application/script-factory.js +packages/admin-scripts/src/application/script-context.js +packages/admin-scripts/src/application/script-runner.js +packages/admin-scripts/src/application/admin-frigg-commands.js +packages/admin-scripts/src/infrastructure/create-admin-script-router.js +packages/admin-scripts/src/infrastructure/create-script-handler.js +packages/admin-scripts/src/infrastructure/admin-auth-middleware.js +packages/admin-scripts/src/builtins/oauth-token-refresh.js +packages/admin-scripts/src/builtins/integration-health-check.js +packages/admin-scripts/src/builtins/index.js +``` + +--- + +## Next Steps + +1. [x] Review and approve plan +2. [x] Update plan for `next` branch architecture +3. [ ] Add Prisma schema for AdminApiKey, ScriptExecution +4. [ ] Implement repository interfaces +5. [ ] Implement repository factories (MongoDB, PostgreSQL, DocumentDB) +6. [ ] Implement `createAdminScriptCommands()` command factory +7. [ ] Build ScriptFactory + AdminFriggCommands +8. [ ] Create router + handler +9. [ ] Write tests +10. [ ] Implement built-in scripts +11. [ ] Documentation diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js new file mode 100644 index 000000000..b6ad5ab5a --- /dev/null +++ b/packages/admin-scripts/index.js @@ -0,0 +1,83 @@ +/** + * @friggframework/admin-scripts + * + * Admin Script Runner for Frigg - Execute maintenance and operational scripts + * in hosted environments with VPC/KMS secured database connections. + */ + +// Domain Models +const { AdminApiKey } = require('./src/domain/admin-api-key'); +const { ScriptExecution } = require('./src/domain/script-execution'); +const { ScheduleSpec } = require('./src/domain/schedule-spec'); + +// Application Services +const { ScriptFactory } = require('./src/application/script-factory'); +const { ScriptContext } = require('./src/application/script-context'); +const { FriggCommands } = require('./src/application/frigg-commands'); +const { ScriptRunner } = require('./src/application/script-runner'); + +// Infrastructure +const { createAdminScriptRouter } = require('./src/infrastructure/admin-script-router'); +const { createScriptHandler } = require('./src/infrastructure/create-script-handler'); +const { ScriptQueueWorker } = require('./src/infrastructure/script-queue-worker'); +const { requireAdminApiKey } = require('./src/infrastructure/admin-auth-middleware'); + +// Built-in Scripts +const builtinScripts = require('./src/builtins'); + +// Factory function for creating the admin backend +function createAdminBackend(params) { + const { + scripts = [], + integrationFactory, + options = {} + } = params; + + // Merge user scripts with builtins if enabled + const allScripts = options.includeBuiltins !== false + ? [...builtinScripts, ...scripts] + : scripts; + + const scriptFactory = new ScriptFactory(allScripts); + + return { + scriptFactory, + integrationFactory, + createRouter: (routerOptions = {}) => createAdminScriptRouter({ + scriptFactory, + integrationFactory, + ...routerOptions + }), + createHandler: (handlerOptions = {}) => createScriptHandler({ + scriptFactory, + integrationFactory, + ...handlerOptions + }), + createWorker: () => new ScriptQueueWorker(scriptFactory, integrationFactory) + }; +} + +module.exports = { + // Main factory + createAdminBackend, + + // Domain + AdminApiKey, + ScriptExecution, + ScheduleSpec, + + // Application + ScriptFactory, + ScriptContext, + FriggCommands, + ScriptRunner, + + // Infrastructure + createAdminScriptRouter, + createScriptHandler, + ScriptQueueWorker, + requireAdminApiKey, + + // Built-ins + builtinScripts +}; diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json new file mode 100644 index 000000000..66965873d --- /dev/null +++ b/packages/admin-scripts/package.json @@ -0,0 +1,49 @@ +{ + "name": "@friggframework/admin-scripts", + "prettier": "@friggframework/prettier-config", + "version": "2.0.0-next.0", + "description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments", + "dependencies": { + "@friggframework/core": "^2.0.0-next.0", + "@aws-sdk/client-scheduler": "^3.588.0", + "bcryptjs": "^2.4.3", + "lodash": "4.17.21", + "mongoose": "6.11.6", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@friggframework/eslint-config": "^2.0.0-next.0", + "@friggframework/prettier-config": "^2.0.0-next.0", + "@friggframework/test": "^2.0.0-next.0", + "chai": "^4.3.6", + "eslint": "^8.22.0", + "jest": "^29.7.0", + "prettier": "^2.7.1", + "sinon": "^16.1.1" + }, + "scripts": { + "lint:fix": "prettier --write --loglevel error . && eslint . --fix", + "test": "jest --passWithNoTests" + }, + "author": "", + "license": "MIT", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/friggframework/frigg.git" + }, + "bugs": { + "url": "https://github.com/friggframework/frigg/issues" + }, + "homepage": "https://github.com/friggframework/frigg#readme", + "publishConfig": { + "access": "public" + }, + "keywords": [ + "frigg", + "admin", + "scripts", + "maintenance", + "operations" + ] +} From 71f0aae30e9aa71621b353bd027e3fb6d75bb3cf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 17:31:02 +0000 Subject: [PATCH 02/33] docs(admin-scripts): update plan with accurate codebase patterns - Add AdminScriptBase.Definition following IntegrationBase pattern (ref: packages/core/integrations/integration-base.js:57-69) - Add appDefinition.adminScripts schema update (ref: packages/devtools/infrastructure/domains/shared/types/app-definition.js) - Make integrationFactory OPTIONAL for scripts that only need DB access - Add AdminFriggCommands with repository pattern matching existing commands - Include exact file references throughout plan --- PLAN-admin-script-runner.md | 391 +++++++++++++++++++++++++++--------- 1 file changed, 297 insertions(+), 94 deletions(-) diff --git a/PLAN-admin-script-runner.md b/PLAN-admin-script-runner.md index 7dcc6d305..040ad95fc 100644 --- a/PLAN-admin-script-runner.md +++ b/PLAN-admin-script-runner.md @@ -37,100 +37,234 @@ The `next` branch has a **fundamentally different architecture** from `main`: --- -## Architecture Decision Records +## appDefinition Schema Update -### ADR-1: Follow Command Pattern from `next` Branch +**File to modify**: `/home/user/frigg/packages/devtools/infrastructure/domains/shared/types/app-definition.js` -**Decision**: Create `createAdminScriptCommands()` following the existing command factory pattern. +Add `adminScripts` to the AppDefinition typedef: -**Rationale**: -- Consistent with `createIntegrationCommands()`, `createUserCommands()`, etc. -- Database-agnostic via repository factories -- Standardized error handling (returns `{ error, reason, code }` objects) +```javascript +/** + * Complete application definition + * @typedef {Object} AppDefinition + * @property {string} name - Application name + * @property {string} stage - Deployment stage + * @property {IntegrationDefinition[]} [integrations] - Integration definitions + * @property {AdminScriptDefinition[]} [adminScripts] - Admin script definitions (NEW) + * @property {AdminConfig} [admin] - Admin configuration (NEW) + * ... + */ +``` -**Implementation**: +**Usage in backend/index.js**: ```javascript -// packages/core/application/commands/admin-script-commands.js -function createAdminScriptCommands() { - const scriptExecutionRepository = createScriptExecutionRepository(); - const adminApiKeyRepository = createAdminApiKeyRepository(); +const Definition = { + name: 'my-app', + integrations: [ + HubSpotIntegration, + SalesforceIntegration, + ], + + // NEW: Admin scripts array (OPTIONAL) + adminScripts: [ + AttioHealingScript, + ZohoWebhookRefreshScript, + ], + + // NEW: Admin configuration (OPTIONAL) + admin: { + includeBuiltinScripts: true, + }, + + database: { postgres: { enable: true } }, +}; - return { - async executeScript({ scriptName, params, adminKeyId }) { - try { - // Create execution record, run script, update status - } catch (error) { - return mapErrorToResponse(error); - } +module.exports = { Definition }; +``` + +--- + +## AdminScriptBase Definition Pattern + +**Following IntegrationBase pattern from**: `/home/user/frigg/packages/core/integrations/integration-base.js:35-100` + +```javascript +// packages/core/admin-scripts/admin-script-base.js + +const { createScriptExecutionRepository } = require('./repositories/script-execution-repository-factory'); +const { createAdminApiKeyRepository } = require('./repositories/admin-api-key-repository-factory'); + +class AdminScriptBase { + // Class-level repository instances (like IntegrationBase lines 37-44) + scriptExecutionRepository = createScriptExecutionRepository(); + adminApiKeyRepository = createAdminApiKeyRepository(); + + /** + * CHILDREN SHOULD SPECIFY A DEFINITION FOR THE SCRIPT + * Pattern matches IntegrationBase.Definition (lines 57-69) + */ + static Definition = { + name: 'Script Name', // Required: unique identifier + version: '0.0.0', // Required: semver for migrations + description: 'What this script does', // Required: human-readable + + // Script-specific properties + source: 'USER_DEFINED', // 'BUILTIN' | 'USER_DEFINED' + + inputSchema: null, // Optional: JSON Schema for params + outputSchema: null, // Optional: JSON Schema for results + + schedule: { // Optional: Phase 2 + enabled: false, + cronExpression: null, // 'cron(0 12 * * ? *)' + }, + + config: { + timeout: 300000, // Default 5 min (ms) + maxRetries: 0, + requiresIntegrationFactory: false, // Hint: does script need to instantiate integrations? + }, + + display: { // For future UI + label: 'Script Name', + description: '', + category: 'maintenance', // 'maintenance' | 'healing' | 'sync' | 'custom' }, - async findExecutionById(executionId) { ... }, - async findExecutionsByScriptName(scriptName) { ... }, - async validateAdminApiKey(rawKey) { ... }, - // ... more commands }; + + static getName() { + return this.Definition.name; + } + + static getCurrentVersion() { + return this.Definition.version; + } + + static getDefinition() { + return this.Definition; + } + + /** + * Constructor receives dependencies + * Pattern matches IntegrationBase constructor (lines 81-100) + */ + constructor(params = {}) { + this.executionId = params.executionId || null; + this.logs = []; + this._startTime = null; + + // OPTIONAL: Integration factory for scripts that need it + this.integrationFactory = params.integrationFactory || null; + + // Injected repositories (can override class-level) + if (params.scriptExecutionRepository) { + this.scriptExecutionRepository = params.scriptExecutionRepository; + } + if (params.adminApiKeyRepository) { + this.adminApiKeyRepository = params.adminApiKeyRepository; + } + } + + /** + * CHILDREN MUST IMPLEMENT THIS METHOD + * @param {AdminFriggCommands} frigg - Helper commands object + * @param {Object} params - Script parameters (validated against inputSchema) + * @returns {Promise} - Script results (validated against outputSchema) + */ + async execute(frigg, params) { + throw new Error('AdminScriptBase.execute() must be implemented by subclass'); + } + + // Logging helper + log(level, message, data = {}) { + const entry = { + level, + message, + data, + timestamp: new Date().toISOString(), + }; + this.logs.push(entry); + return entry; + } + + getLogs() { + return this.logs; + } } + +module.exports = { AdminScriptBase }; ``` -### ADR-2: Repository Factory Pattern +--- + +## Architecture Decision Records -**Decision**: Create repository interfaces and factories for `AdminApiKey` and `ScriptExecution`. +### ADR-1: Follow Definition Pattern from IntegrationBase + +**Decision**: Create `AdminScriptBase` with `static Definition` matching IntegrationBase pattern. + +**Reference**: `/home/user/frigg/packages/core/integrations/integration-base.js:57-69` **Rationale**: -- Support MongoDB, PostgreSQL, DocumentDB -- Consistent with existing repository pattern in `next` -- Testable via mock repositories +- Consistent with existing Frigg patterns +- Familiar to Frigg developers +- Supports versioning and migrations +- Enables validation at load time -**Structure**: -``` -packages/core/admin-scripts/repositories/ -├── admin-api-key-repository-interface.js -├── admin-api-key-repository-factory.js -├── admin-api-key-repository-mongo.js -├── admin-api-key-repository-postgres.js -├── admin-api-key-repository-documentdb.js -├── script-execution-repository-interface.js -├── script-execution-repository-factory.js -├── script-execution-repository-mongo.js -├── script-execution-repository-postgres.js -└── script-execution-repository-documentdb.js +### ADR-2: Repository Factory Pattern (No-Arg Constructors) + +**Decision**: Create repository factories following existing pattern. + +**Reference**: `/home/user/frigg/packages/core/integrations/repositories/integration-repository-factory.js` + +**Pattern**: +```javascript +// Factory returns instance with NO arguments +function createScriptExecutionRepository() { + const dbType = config.DB_TYPE; + switch (dbType) { + case 'mongodb': return new ScriptExecutionRepositoryMongo(); + case 'postgresql': return new ScriptExecutionRepositoryPostgres(); + case 'documentdb': return new ScriptExecutionRepositoryDocumentDB(); + default: throw new Error(`Unsupported database type: ${dbType}`); + } +} ``` -### ADR-3: Integrate with Existing Commands +### ADR-3: Optional IntegrationFactory -**Decision**: Extend `createFriggCommands()` to include admin script commands when configured. +**Decision**: `integrationFactory` is OPTIONAL for admin scripts. **Rationale**: -- Single unified command interface -- Scripts can use existing commands (user, entity, credential, integration) -- Consistent developer experience +- Many scripts only need database access (cleanup, reporting) +- Scripts that need to call external APIs require `integrationFactory` +- Fail-fast with clear error if script needs factory but none provided **Implementation**: ```javascript -// Extended createFriggCommands in application/index.js -function createFriggCommands({ integrationClass, enableAdminScripts = false }) { - const commands = { - ...createIntegrationCommands({ integrationClass }), - ...createUserCommands(), - ...createEntityCommands(), - ...createCredentialCommands(), - }; - - if (enableAdminScripts) { - Object.assign(commands, createAdminScriptCommands()); +// Scripts declare their needs via Definition.config +static Definition = { + config: { + requiresIntegrationFactory: true, // or false } +}; - return commands; +// ScriptRunner validates before execution +if (scriptClass.Definition.config.requiresIntegrationFactory && !integrationFactory) { + throw new Error(`Script "${scriptName}" requires integrationFactory`); } ``` -### ADR-4: Separate Package with Core Integration +### ADR-4: Separate adminScripts Array in appDefinition -**Decision**: Create `@friggframework/admin-scripts` package that integrates with `@friggframework/core`. +**Decision**: Add `adminScripts[]` to appDefinition schema (separate from `integrations[]`). + +**Reference**: `/home/user/frigg/packages/devtools/infrastructure/domains/shared/types/app-definition.js` **Rationale**: -- Domain models (AdminApiKey, ScriptExecution) go in `core` (like other models) -- Application logic (ScriptRunner, FriggCommands for scripts) in separate package -- Allows opt-in installation +- Clear separation of concerns +- Scripts are operational, integrations are domain +- Can be deployed independently --- @@ -536,55 +670,114 @@ packages/admin-scripts/ # Application logic & builtins --- -## FriggCommands for Scripts (AdminFriggCommands) +## AdminFriggCommands (Helper API for Scripts) + +**Reference**: Uses repository pattern from `/home/user/frigg/packages/core/application/commands/` -Scripts receive an enhanced `frigg` object that wraps existing commands: +Scripts receive a `frigg` object with database access. Integration factory is **OPTIONAL**. ```javascript -// packages/admin-scripts/src/application/admin-frigg-commands.js -const { createFriggCommands } = require('@friggframework/core'); +// packages/core/admin-scripts/admin-frigg-commands.js + +const { createIntegrationRepository } = require('../integrations/repositories/integration-repository-factory'); +const { createUserRepository } = require('../user/repositories/user-repository-factory'); +const { createModuleRepository } = require('../modules/repositories/module-repository-factory'); +const { createCredentialRepository } = require('../credential/repositories/credential-repository-factory'); +const { createScriptExecutionRepository } = require('./repositories/script-execution-repository-factory'); class AdminFriggCommands { - constructor(params) { - this.executionId = params.executionId; - this.integrationClass = params.integrationClass; + // Repositories created via factories (no args, like other commands) + integrationRepository = createIntegrationRepository(); + userRepository = createUserRepository(); + moduleRepository = createModuleRepository(); + credentialRepository = createCredentialRepository(); + scriptExecutionRepository = createScriptExecutionRepository(); + + constructor(params = {}) { + this.executionId = params.executionId || null; this.logs = []; - // Get existing Frigg commands - this.commands = createFriggCommands({ - integrationClass: this.integrationClass, - enableAdminScripts: true, - }); + // OPTIONAL: Integration factory for scripts that need to instantiate integrations + this.integrationFactory = params.integrationFactory || null; } - // Integration Access (uses existing commands) + // ==================== ALWAYS AVAILABLE (Database Access) ==================== + + // Integration queries (no instantiation) async listIntegrations(filter = {}) { - // Uses findIntegrationsByUserId or custom query - return this.commands.findIntegrationsByUserId(filter.userId); + return this.integrationRepository.findIntegrations(filter); } - async getIntegration(id) { - const result = await this.commands.loadIntegrationContextById(id); - return result.error ? null : result.context; + async findIntegrationById(id) { + return this.integrationRepository.findIntegrationById(id); } - async instantiate(integrationId) { - const result = await this.commands.loadIntegrationContextById(integrationId); - if (result.error) { - throw new Error(result.reason); - } - return result.context; + async findIntegrationsByUserId(userId) { + return this.integrationRepository.findIntegrationsByUserId(userId); + } + + async updateIntegrationConfig(integrationId, config) { + return this.integrationRepository.updateIntegrationConfig(integrationId, config); + } + + async updateIntegrationStatus(integrationId, status) { + return this.integrationRepository.updateIntegrationStatus(integrationId, status); + } + + // User queries + async listUsers(filter = {}) { + // Implement based on filter + if (filter.appUserId) return this.userRepository.findIndividualUserByAppUserId(filter.appUserId); + if (filter.username) return this.userRepository.findIndividualUserByUsername(filter.username); + return null; } - // Entity Access + async findUserById(userId) { + return this.userRepository.findIndividualUserById(userId); + } + + // Entity queries async listEntities(filter = {}) { if (filter.userId) { - return this.commands.findEntitiesByUserId(filter.userId); + return this.moduleRepository.findEntitiesByUserId(filter.userId); } - return this.commands.findEntity(filter); + return this.moduleRepository.findEntity(filter); + } + + async findEntityById(entityId) { + return this.moduleRepository.findEntityById(entityId); + } + + // Credential queries + async findCredential(filter) { + return this.credentialRepository.findCredential(filter); } - // Logging + async updateCredential(credentialId, updates) { + return this.credentialRepository.updateCredential(credentialId, updates); + } + + // ==================== REQUIRES integrationFactory ==================== + + /** + * Instantiate an integration instance (for calling external APIs) + * REQUIRES: integrationFactory in constructor + */ + async instantiate(integrationId) { + if (!this.integrationFactory) { + throw new Error( + 'instantiate() requires integrationFactory. ' + + 'Set Definition.config.requiresIntegrationFactory = true' + ); + } + return this.integrationFactory.getInstanceFromIntegrationId({ + integrationId, + _isAdminContext: true, // Bypass user ownership check + }); + } + + // ==================== LOGGING & EXECUTION ==================== + log(level, message, data = {}) { const entry = { level, @@ -594,11 +787,15 @@ class AdminFriggCommands { }; this.logs.push(entry); - // Also append to execution record - this.commands.appendScriptExecutionLog(this.executionId, entry); + // Persist to execution record if we have an executionId + if (this.executionId) { + this.scriptExecutionRepository.appendExecutionLog(this.executionId, entry) + .catch(err => console.error('Failed to persist log:', err)); + } + + return entry; } - // Execution info getExecutionId() { return this.executionId; } @@ -611,6 +808,12 @@ class AdminFriggCommands { module.exports = { AdminFriggCommands }; ``` +**Key Design Points**: +1. **Repository instances as class properties** (matches IntegrationBase pattern) +2. **No-arg repository factories** (matches existing pattern) +3. **integrationFactory is optional** - only needed for `instantiate()` +4. **Clear error message** when trying to instantiate without factory + --- ## Implementation Phases (Updated) From 3c590c3d3d531fdeaf4734b96422a78f28a94123 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 17:53:07 +0000 Subject: [PATCH 03/33] docs(admin-scripts): add execution modes, scheduling, and IaC deployment - ADR-5: Execution modes - sync (optional) vs async (default) - ADR-6: Hybrid scheduling (Definition defaults + DB/API overrides) - ADR-7: DDD/hexagonal architecture layers documented - ADR-8: SchedulerAdapter port with AWS/local implementations - ADR-9: AdminScriptBuilder following infrastructure-composer pattern - Add ScriptSchedule Prisma model for hybrid scheduling - Document deployment flow and generated serverless resources - Update package structure with adapters directory - Update Files to Create/Modify section with all new files --- PLAN-admin-script-runner.md | 854 ++++++++++++++++++++++++++++++++++-- 1 file changed, 822 insertions(+), 32 deletions(-) diff --git a/PLAN-admin-script-runner.md b/PLAN-admin-script-runner.md index 040ad95fc..58ed4b9c6 100644 --- a/PLAN-admin-script-runner.md +++ b/PLAN-admin-script-runner.md @@ -266,6 +266,679 @@ if (scriptClass.Definition.config.requiresIntegrationFactory && !integrationFact - Scripts are operational, integrations are domain - Can be deployed independently +### ADR-5: Execution Modes (Sync vs Async) + +**Decision**: One-off scripts support both synchronous and asynchronous execution. + +**Behavior**: +- **Default**: Asynchronous (queued) with execution ID returned immediately +- **Optional**: Synchronous for simple scripts (dev's responsibility for timeout) + +**Implementation**: +```javascript +// POST /admin/scripts/:scriptName/execute +{ + "params": { /* script parameters */ }, + "mode": "async" // "async" (default) | "sync" +} + +// Response for async +{ + "executionId": "exec_abc123", + "status": "PENDING", + "scriptName": "attio-healing", + "message": "Script queued for execution" +} + +// Response for sync (returns when complete) +{ + "executionId": "exec_abc123", + "status": "COMPLETED", + "scriptName": "attio-healing", + "output": { /* script result */ }, + "metrics": { "durationMs": 1234 } +} +``` + +**Rationale**: +- Async is safer (Lambda timeout protection, queue durability) +- Sync is convenient for simple scripts and debugging +- Developer chooses based on script complexity + +### ADR-6: Hybrid Scheduling Approach + +**Decision**: Script schedules can come from Definition (hardcoded) OR database/API (runtime). + +**Priority Order** (highest to lowest): +1. **Database/API override** - Runtime schedule stored in `ScriptSchedule` model +2. **Definition default** - `static Definition.schedule` in script class + +**Implementation**: +```javascript +// 1. Definition default (hardcoded in script) +class ZohoWebhookRefreshScript extends AdminScriptBase { + static Definition = { + name: 'zoho-webhook-refresh', + schedule: { + enabled: true, + cronExpression: 'cron(0 */12 * * ? *)', // Every 12 hours + } + }; +} + +// 2. Database override (via API or seed) +// POST /admin/scripts/:scriptName/schedule +{ + "enabled": true, + "cronExpression": "cron(0 6 * * ? *)", // Override to 6 AM daily + "timezone": "America/New_York" +} +``` + +**New Model**: +```prisma +model ScriptSchedule { + id String @id @default(auto()) @map("_id") @db.ObjectId + scriptName String @unique + enabled Boolean @default(false) + cronExpression String? + timezone String @default("UTC") + lastTriggeredAt DateTime? + nextTriggerAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // AWS EventBridge Rule ARN (if provisioned) + awsRuleArn String? + awsRuleName String? +} +``` + +**Rationale**: +- Definition provides sensible defaults +- API allows runtime modification without code changes +- Supports multi-tenant scenarios with different schedules + +### ADR-7: DDD/Hexagonal Architecture for Admin Scripts + +**Decision**: Follow the established DDD/Hexagonal architecture patterns from devtools. + +**Reference Files**: +- `/home/user/frigg/packages/devtools/infrastructure/domains/shared/base-builder.js` +- `/home/user/frigg/packages/devtools/infrastructure/domains/shared/providers/cloud-provider-adapter.js` +- `/home/user/frigg/packages/devtools/infrastructure/domains/integration/integration-builder.js` + +**Architecture Layers**: +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Adapter Layer (Handlers/Routers) │ +│ packages/admin-scripts/src/infrastructure/ │ +│ - admin-script-router.js │ +│ - admin-auth-middleware.js │ +│ - create-script-handler.js │ +└──────────────────────────┬──────────────────────────────────────────┘ + │ calls +┌──────────────────────────▼──────────────────────────────────────────┐ +│ Application Layer (Use Cases / Commands) │ +│ packages/admin-scripts/src/application/ │ +│ - script-runner.js (orchestrates execution) │ +│ - script-factory.js (registry & instantiation) │ +│ - admin-frigg-commands.js (helper API for scripts) │ +│ packages/core/application/commands/ │ +│ - admin-script-commands.js (API key & execution management) │ +└──────────────────────────┬──────────────────────────────────────────┘ + │ calls +┌──────────────────────────▼──────────────────────────────────────────┐ +│ Infrastructure Layer (Adapters / Repositories) │ +│ packages/core/admin-scripts/repositories/ │ +│ - script-execution-repository-*.js │ +│ - admin-api-key-repository-*.js │ +│ packages/admin-scripts/src/adapters/ │ +│ - scheduler-adapter.js (port interface) │ +│ - aws-scheduler-adapter.js (EventBridge implementation) │ +│ - local-scheduler-adapter.js (dev/test implementation) │ +└──────────────────────────┬──────────────────────────────────────────┘ + │ accesses +┌──────────────────────────▼──────────────────────────────────────────┐ +│ External Systems │ +│ - Prisma (MongoDB, PostgreSQL, DocumentDB) │ +│ - AWS EventBridge Scheduler │ +│ - SQS Queues │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### ADR-8: Scheduler Adapter Pattern (EventBridge) + +**Decision**: Create scheduler adapter following `CloudProviderAdapter` pattern. + +**Reference**: `/home/user/frigg/packages/devtools/infrastructure/domains/shared/providers/cloud-provider-adapter.js` + +**Port Interface** (Abstract): +```javascript +// packages/admin-scripts/src/adapters/scheduler-adapter.js + +/** + * Scheduler Adapter (Abstract Base Class) + * + * Port - Hexagonal Architecture + * + * Defines the contract for scheduler implementations. + * Supports AWS EventBridge, local cron, or other providers. + */ +class SchedulerAdapter { + getName() { + throw new Error('SchedulerAdapter.getName() must be implemented'); + } + + /** + * Create or update a schedule for a script + * @param {Object} config + * @param {string} config.scriptName - Script identifier + * @param {string} config.cronExpression - Cron expression + * @param {Object} [config.input] - Optional input params + * @returns {Promise} Created schedule { ruleArn, ruleName } + */ + async createSchedule(config) { + throw new Error('SchedulerAdapter.createSchedule() must be implemented'); + } + + /** + * Delete a schedule + * @param {string} scriptName - Script identifier + * @returns {Promise} + */ + async deleteSchedule(scriptName) { + throw new Error('SchedulerAdapter.deleteSchedule() must be implemented'); + } + + /** + * Enable or disable a schedule + * @param {string} scriptName - Script identifier + * @param {boolean} enabled - Whether to enable + * @returns {Promise} + */ + async setScheduleEnabled(scriptName, enabled) { + throw new Error('SchedulerAdapter.setScheduleEnabled() must be implemented'); + } + + /** + * List all schedules + * @returns {Promise} List of schedules + */ + async listSchedules() { + throw new Error('SchedulerAdapter.listSchedules() must be implemented'); + } +} + +module.exports = { SchedulerAdapter }; +``` + +**AWS Implementation**: +```javascript +// packages/admin-scripts/src/adapters/aws-scheduler-adapter.js + +const { SchedulerAdapter } = require('./scheduler-adapter'); + +// Lazy-loaded AWS SDK clients (following AWSProviderAdapter pattern) +let SchedulerClient, CreateScheduleCommand, DeleteScheduleCommand, + GetScheduleCommand, UpdateScheduleCommand, ListSchedulesCommand; + +function loadSchedulerSDK() { + if (!SchedulerClient) { + const schedulerModule = require('@aws-sdk/client-scheduler'); + SchedulerClient = schedulerModule.SchedulerClient; + CreateScheduleCommand = schedulerModule.CreateScheduleCommand; + DeleteScheduleCommand = schedulerModule.DeleteScheduleCommand; + GetScheduleCommand = schedulerModule.GetScheduleCommand; + UpdateScheduleCommand = schedulerModule.UpdateScheduleCommand; + ListSchedulesCommand = schedulerModule.ListSchedulesCommand; + } +} + +class AWSSchedulerAdapter extends SchedulerAdapter { + constructor({ region, credentials, targetLambdaArn, scheduleGroupName }) { + super(); + this.region = region || process.env.AWS_REGION || 'us-east-1'; + this.credentials = credentials; + this.targetLambdaArn = targetLambdaArn; + this.scheduleGroupName = scheduleGroupName || 'frigg-admin-scripts'; + this.scheduler = null; + } + + getSchedulerClient() { + if (!this.scheduler) { + loadSchedulerSDK(); + this.scheduler = new SchedulerClient({ + region: this.region, + ...this.credentials, + }); + } + return this.scheduler; + } + + getName() { + return 'aws-eventbridge-scheduler'; + } + + async createSchedule({ scriptName, cronExpression, timezone, input }) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + const command = new CreateScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + ScheduleExpression: cronExpression, + ScheduleExpressionTimezone: timezone || 'UTC', + FlexibleTimeWindow: { Mode: 'OFF' }, + Target: { + Arn: this.targetLambdaArn, + RoleArn: process.env.SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + scriptName, + trigger: 'SCHEDULED', + params: input || {}, + }), + }, + State: 'ENABLED', + }); + + const response = await client.send(command); + return { + ruleArn: response.ScheduleArn, + ruleName: scheduleName, + }; + } + + async deleteSchedule(scriptName) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + await client.send(new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + })); + } + + async setScheduleEnabled(scriptName, enabled) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + await client.send(new UpdateScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + State: enabled ? 'ENABLED' : 'DISABLED', + })); + } + + async listSchedules() { + const client = this.getSchedulerClient(); + + const response = await client.send(new ListSchedulesCommand({ + GroupName: this.scheduleGroupName, + })); + + return response.Schedules || []; + } +} + +module.exports = { AWSSchedulerAdapter }; +``` + +**Local Implementation** (for dev/test): +```javascript +// packages/admin-scripts/src/adapters/local-scheduler-adapter.js + +const { SchedulerAdapter } = require('./scheduler-adapter'); + +class LocalSchedulerAdapter extends SchedulerAdapter { + constructor() { + super(); + this.schedules = new Map(); + this.intervals = new Map(); + } + + getName() { + return 'local-cron'; + } + + async createSchedule({ scriptName, cronExpression, input }) { + // Store schedule (actual cron execution would use node-cron) + this.schedules.set(scriptName, { + scriptName, + cronExpression, + input, + enabled: true, + createdAt: new Date().toISOString(), + }); + return { ruleName: scriptName }; + } + + async deleteSchedule(scriptName) { + this.schedules.delete(scriptName); + if (this.intervals.has(scriptName)) { + clearInterval(this.intervals.get(scriptName)); + this.intervals.delete(scriptName); + } + } + + async setScheduleEnabled(scriptName, enabled) { + const schedule = this.schedules.get(scriptName); + if (schedule) { + schedule.enabled = enabled; + } + } + + async listSchedules() { + return Array.from(this.schedules.values()); + } +} + +module.exports = { LocalSchedulerAdapter }; +``` + +### ADR-9: AdminScriptBuilder for Infrastructure Generation + +**Decision**: Create `AdminScriptBuilder` following the existing builder pattern. + +**Reference**: +- `/home/user/frigg/packages/devtools/infrastructure/domains/shared/base-builder.js` +- `/home/user/frigg/packages/devtools/infrastructure/domains/integration/integration-builder.js` + +**Implementation**: +```javascript +// packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js + +const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder'); + +/** + * Admin Script Builder + * + * Domain Layer - Hexagonal Architecture + * + * Responsible for: + * - Creating SQS queue for admin script execution + * - Creating Lambda function for script execution + * - Creating EventBridge Scheduler resources (Phase 2) + * - Creating IAM roles for scheduler to invoke Lambda + */ +class AdminScriptBuilder extends InfrastructureBuilder { + constructor() { + super(); + this.name = 'AdminScriptBuilder'; + } + + shouldExecute(appDefinition) { + return Array.isArray(appDefinition.adminScripts) && appDefinition.adminScripts.length > 0; + } + + getDependencies() { + return []; // Can run independently + } + + validate(appDefinition) { + const result = new ValidationResult(); + + if (!appDefinition.adminScripts) { + return result; // Not an error, just no scripts + } + + if (!Array.isArray(appDefinition.adminScripts)) { + result.addError('adminScripts must be an array'); + return result; + } + + // Validate each script + appDefinition.adminScripts.forEach((script, index) => { + if (!script?.Definition?.name) { + result.addError(`Admin script at index ${index} is missing Definition or name`); + } + }); + + return result; + } + + async build(appDefinition, discoveredResources) { + console.log(`\n[${this.name}] Configuring admin scripts...`); + console.log(` Processing ${appDefinition.adminScripts.length} scripts...`); + + const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false; + const adminConfig = appDefinition.admin || {}; + + const result = { + functions: {}, + resources: {}, + environment: {}, + custom: {}, + iamStatements: [], + }; + + // Create admin script queue + this.createAdminScriptQueue(result); + + // Create Lambda function for script execution + this.createScriptExecutorFunction(result, usePrismaLayer); + + // Create API routes for script management + this.createAdminScriptRoutes(result, usePrismaLayer); + + // Phase 2: Create EventBridge Scheduler resources + if (adminConfig.enableScheduling) { + this.createSchedulerResources(appDefinition, result); + } + + // Log registered scripts + appDefinition.adminScripts.forEach(script => { + const name = script.Definition?.name || 'unknown'; + const schedule = script.Definition?.schedule; + console.log(` ✓ Registered: ${name}${schedule?.enabled ? ' (scheduled)' : ''}`); + }); + + console.log(`[${this.name}] ✅ Admin script configuration completed`); + return result; + } + + createAdminScriptQueue(result) { + result.resources.AdminScriptQueue = { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: '${self:service}-${self:provider.stage}-AdminScriptQueue', + MessageRetentionPeriod: 86400, // 1 day + VisibilityTimeout: 900, // 15 minutes (Lambda max) + RedrivePolicy: { + maxReceiveCount: 3, + deadLetterTargetArn: { + 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'], + }, + }, + }, + }; + + result.environment.ADMIN_SCRIPT_QUEUE_URL = { Ref: 'AdminScriptQueue' }; + console.log(' ✓ Created AdminScriptQueue'); + } + + createScriptExecutorFunction(result, usePrismaLayer) { + result.functions.adminScriptExecutor = { + handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/script-executor-handler.handler', + skipEsbuild: true, + ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), + timeout: 900, // 15 minutes max + memorySize: 1024, + events: [ + { + sqs: { + arn: { 'Fn::GetAtt': ['AdminScriptQueue', 'Arn'] }, + batchSize: 1, + }, + }, + ], + }; + console.log(' ✓ Created adminScriptExecutor function'); + } + + createAdminScriptRoutes(result, usePrismaLayer) { + result.functions.adminScriptRouter = { + handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/admin-script-router.handler', + skipEsbuild: true, + ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), + timeout: 30, + events: [ + // List scripts + { httpApi: { path: '/admin/scripts', method: 'GET' } }, + // Get script details + { httpApi: { path: '/admin/scripts/{scriptName}', method: 'GET' } }, + // Execute script (sync or async) + { httpApi: { path: '/admin/scripts/{scriptName}/execute', method: 'POST' } }, + // Get execution status + { httpApi: { path: '/admin/executions/{executionId}', method: 'GET' } }, + // List executions + { httpApi: { path: '/admin/executions', method: 'GET' } }, + // Schedule management (Phase 2) + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'GET' } }, + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'PUT' } }, + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'DELETE' } }, + ], + }; + console.log(' ✓ Created adminScriptRouter function'); + } + + createSchedulerResources(appDefinition, result) { + // Create IAM role for EventBridge Scheduler + result.resources.AdminScriptSchedulerRole = { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: '${self:service}-${self:provider.stage}-admin-script-scheduler', + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { Service: 'scheduler.amazonaws.com' }, + Action: 'sts:AssumeRole', + }], + }, + Policies: [{ + PolicyName: 'InvokeLambda', + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Action: 'lambda:InvokeFunction', + Resource: { 'Fn::GetAtt': ['AdminScriptExecutorLambdaFunction', 'Arn'] }, + }], + }, + }], + }, + }; + + // Create schedule group + result.resources.AdminScriptScheduleGroup = { + Type: 'AWS::Scheduler::ScheduleGroup', + Properties: { + Name: '${self:service}-${self:provider.stage}-admin-scripts', + }, + }; + + result.environment.SCHEDULER_ROLE_ARN = { 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'] }; + result.environment.SCHEDULE_GROUP_NAME = { Ref: 'AdminScriptScheduleGroup' }; + + console.log(' ✓ Created EventBridge Scheduler resources'); + } +} + +module.exports = { AdminScriptBuilder }; +``` + +### Wiring AdminScriptBuilder into Deployment + +**File to modify**: `/home/user/frigg/packages/devtools/infrastructure/infrastructure-composer.js` + +The `AdminScriptBuilder` must be registered with the `BuilderOrchestrator` to be included in the serverless template generation: + +```javascript +// packages/devtools/infrastructure/infrastructure-composer.js + +// Add import +const { AdminScriptBuilder } = require('./domains/admin-scripts/admin-script-builder'); + +// Register in orchestrator (line ~46-54) +const orchestrator = new BuilderOrchestrator([ + new VpcBuilder(), + new KmsBuilder(), + new AuroraBuilder(), + new MigrationBuilder(), + new SsmBuilder(), + new WebsocketBuilder(), + new IntegrationBuilder(), + new AdminScriptBuilder(), // NEW: Admin script infrastructure +]); +``` + +**Deployment Flow**: +``` +1. User runs `frigg deploy` or `serverless deploy` + ↓ +2. serverless.js calls composeServerlessDefinition(AppDefinition) + ↓ +3. BuilderOrchestrator validates each builder's shouldExecute() + - AdminScriptBuilder.shouldExecute() returns true if adminScripts[] exists + ↓ +4. Builders execute in dependency order + - AdminScriptBuilder has no dependencies, can run in parallel + ↓ +5. AdminScriptBuilder.build() generates: + - AdminScriptQueue (SQS) + - adminScriptExecutor (Lambda function) + - adminScriptRouter (Lambda function with HTTP routes) + - EventBridge Scheduler resources (if admin.enableScheduling = true) + ↓ +6. BuilderOrchestrator.mergeResults() combines all builder outputs + ↓ +7. Final serverless.yml includes: + - functions: { adminScriptExecutor, adminScriptRouter, ... } + - resources: { AdminScriptQueue, AdminScriptSchedulerRole, ... } + - provider.environment: { ADMIN_SCRIPT_QUEUE_URL, ... } +``` + +**Generated Serverless Resources** (when `adminScripts[]` is defined): +```yaml +# serverless.yml (generated) +functions: + adminScriptExecutor: + handler: node_modules/@friggframework/admin-scripts/src/infrastructure/script-executor-handler.handler + timeout: 900 + memorySize: 1024 + layers: + - Ref: PrismaLambdaLayer + events: + - sqs: + arn: !GetAtt AdminScriptQueue.Arn + batchSize: 1 + + adminScriptRouter: + handler: node_modules/@friggframework/admin-scripts/src/infrastructure/admin-script-router.handler + timeout: 30 + layers: + - Ref: PrismaLambdaLayer + events: + - httpApi: { path: '/admin/scripts', method: GET } + - httpApi: { path: '/admin/scripts/{scriptName}', method: GET } + - httpApi: { path: '/admin/scripts/{scriptName}/execute', method: POST } + - httpApi: { path: '/admin/executions/{executionId}', method: GET } + - httpApi: { path: '/admin/executions', method: GET } + +resources: + Resources: + AdminScriptQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: ${self:service}-${self:provider.stage}-AdminScriptQueue + MessageRetentionPeriod: 86400 + VisibilityTimeout: 900 + RedrivePolicy: + maxReceiveCount: 3 + deadLetterTargetArn: !GetAtt InternalErrorQueue.Arn +``` + --- ## Domain Model (Prisma Schema) @@ -314,6 +987,7 @@ model ScriptExecution { scriptVersion String? status ScriptExecutionStatus @default(PENDING) trigger ScriptTrigger + mode String @default("async") // "sync" | "async" input Json? output Json? logs Json[] // [{level, message, data, timestamp}] @@ -332,6 +1006,25 @@ model ScriptExecution { @@index([scriptName, createdAt(sort: Desc)]) @@index([status]) } + +model ScriptSchedule { + id String @id @default(auto()) @map("_id") @db.ObjectId + scriptName String @unique + enabled Boolean @default(false) + cronExpression String? + timezone String @default("UTC") + lastTriggeredAt DateTime? + nextTriggerAt DateTime? + + // AWS EventBridge Rule (if provisioned) + awsRuleArn String? + awsRuleName String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([enabled]) +} ``` ### PostgreSQL Schema (Equivalent) @@ -378,6 +1071,7 @@ model ScriptExecution { scriptVersion String? status ScriptExecutionStatus @default(PENDING) trigger ScriptTrigger + mode String @default("async") // "sync" | "async" input Json? output Json? logs Json[] @@ -396,6 +1090,25 @@ model ScriptExecution { @@index([scriptName, createdAt(sort: Desc)]) @@index([status]) } + +model ScriptSchedule { + id Int @id @default(autoincrement()) + scriptName String @unique + enabled Boolean @default(false) + cronExpression String? + timezone String @default("UTC") + lastTriggeredAt DateTime? + nextTriggerAt DateTime? + + // AWS EventBridge Rule (if provisioned) + awsRuleArn String? + awsRuleName String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([enabled]) +} ``` --- @@ -422,7 +1135,7 @@ class AdminApiKeyRepositoryInterface { ```javascript // packages/core/admin-scripts/repositories/script-execution-repository-interface.js class ScriptExecutionRepositoryInterface { - async createExecution({ scriptName, scriptVersion, trigger, input, audit }) { } + async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { } async findExecutionById(id) { } async findExecutionsByScriptName(scriptName, options = {}) { } async findExecutionsByStatus(status, options = {}) { } @@ -435,6 +1148,21 @@ class ScriptExecutionRepositoryInterface { } ``` +### ScriptScheduleRepositoryInterface + +```javascript +// packages/core/admin-scripts/repositories/script-schedule-repository-interface.js +class ScriptScheduleRepositoryInterface { + async createSchedule({ scriptName, enabled, cronExpression, timezone }) { } + async findScheduleByScriptName(scriptName) { } + async findEnabledSchedules() { } + async updateSchedule(scriptName, updates) { } + async updateLastTriggered(scriptName, timestamp) { } + async updateAwsRule(scriptName, { ruleArn, ruleName }) { } + async deleteSchedule(scriptName) { } +} +``` + --- ## Command Pattern Implementation @@ -627,15 +1355,27 @@ packages/core/ # Models & Repositories in core │ │ ├── script-execution-repository-factory.js │ │ ├── script-execution-repository-mongo.js │ │ ├── script-execution-repository-postgres.js -│ │ └── script-execution-repository-documentdb.js +│ │ ├── script-execution-repository-documentdb.js +│ │ ├── script-schedule-repository-interface.js # NEW: For hybrid scheduling +│ │ ├── script-schedule-repository-factory.js +│ │ ├── script-schedule-repository-mongo.js +│ │ ├── script-schedule-repository-postgres.js +│ │ └── script-schedule-repository-documentdb.js │ └── index.js ├── application/ │ └── commands/ │ └── admin-script-commands.js # Command factory ├── prisma-mongodb/ -│ └── schema.prisma # Add AdminApiKey, ScriptExecution +│ └── schema.prisma # Add AdminApiKey, ScriptExecution, ScriptSchedule └── prisma-postgresql/ - └── schema.prisma # Add AdminApiKey, ScriptExecution + └── schema.prisma # Add AdminApiKey, ScriptExecution, ScriptSchedule + +packages/devtools/ # Infrastructure builders +└── infrastructure/ + └── domains/ + └── admin-scripts/ # NEW: Admin script infrastructure + ├── admin-script-builder.js + └── admin-script-builder.test.js packages/admin-scripts/ # Application logic & builtins ├── package.json @@ -647,13 +1387,14 @@ packages/admin-scripts/ # Application logic & builtins │ │ ├── script-runner.js # Orchestrates execution │ │ └── admin-frigg-commands.js # Helper API for scripts │ ├── infrastructure/ -│ │ ├── create-admin-script-router.js -│ │ ├── create-script-handler.js -│ │ ├── script-queue-worker.js -│ │ └── admin-auth-middleware.js -│ ├── adapters/ -│ │ ├── eventbridge-scheduler.js # Phase 2 -│ │ └── local-cron-scheduler.js # Dev/test +│ │ ├── admin-script-router.js # Express router +│ │ ├── script-executor-handler.js # Lambda handler for async +│ │ ├── script-queue-worker.js # SQS worker +│ │ └── admin-auth-middleware.js # API key auth +│ ├── adapters/ # Hexagonal adapters (Phase 2) +│ │ ├── scheduler-adapter.js # Port interface (abstract) +│ │ ├── aws-scheduler-adapter.js # AWS EventBridge implementation +│ │ └── local-scheduler-adapter.js # Dev/test implementation │ └── builtins/ │ ├── oauth-token-refresh.js │ ├── integration-health-check.js @@ -662,6 +1403,9 @@ packages/admin-scripts/ # Application logic & builtins │ ├── script-factory.test.js │ ├── script-runner.test.js │ ├── admin-script-commands.test.js +│ ├── adapters/ +│ │ ├── aws-scheduler-adapter.test.js +│ │ └── local-scheduler-adapter.test.js │ └── integration/ │ └── execute-script.test.js └── types/ @@ -818,7 +1562,7 @@ module.exports = { AdminFriggCommands }; ## Implementation Phases (Updated) -### Phase 1: MVP (Manual Execution Only) +### Phase 1: MVP (Sync & Async Execution) **Scope**: 1. Add Prisma schema for `AdminApiKey`, `ScriptExecution` @@ -827,29 +1571,54 @@ module.exports = { AdminFriggCommands }; 4. Build `ScriptFactory` for registration/loading 5. Build `AdminFriggCommands` helper API 6. Create admin router with `/execute` endpoint -7. Lambda handler with TimeoutCatcher -8. 2 built-in scripts (oauth-refresh, health-check) +7. **Sync execution mode** - Direct Lambda invocation with response +8. **Async execution mode** - SQS queue + worker Lambda (default) +9. Lambda handler with TimeoutCatcher +10. 2 built-in scripts (oauth-refresh, health-check) +11. `AdminScriptBuilder` for infrastructure generation + +**Execution Modes**: +- `POST /admin/scripts/:scriptName/execute { mode: "sync" }` - Direct execution, response includes result +- `POST /admin/scripts/:scriptName/execute { mode: "async" }` - Queued, returns execution ID immediately **Deliverables**: 1. Prisma schema additions + migrations 2. Repository implementations for all 3 DBs 3. Command factory 4. `@friggframework/admin-scripts` package -5. Test suite +5. `AdminScriptBuilder` in devtools +6. Test suite -**NOT in Phase 1**: -- Scheduling/cron -- Queue-based async execution -- Approval workflows -- Sandboxing - -### Phase 2: Scheduling & Async +### Phase 2: Hybrid Scheduling **Scope**: -- EventBridge Scheduler integration (AWS SDK v3) -- SQS queue worker for async execution -- Schedule management endpoints -- Zoho webhook refresher script +1. Add `ScriptSchedule` Prisma model +2. Create `script-schedule-repository-*` implementations +3. Create `SchedulerAdapter` port interface (hexagonal) +4. Implement `AWSSchedulerAdapter` (EventBridge Scheduler) +5. Implement `LocalSchedulerAdapter` (for dev/test) +6. Schedule management API endpoints: + - `GET /admin/scripts/:scriptName/schedule` - Get schedule (Definition defaults + DB overrides) + - `PUT /admin/scripts/:scriptName/schedule` - Create/update schedule + - `DELETE /admin/scripts/:scriptName/schedule` - Remove schedule override +7. Zoho webhook refresher script (recurring) + +**Hybrid Scheduling Logic**: +```javascript +// Priority: DB override > Definition default +async function getEffectiveSchedule(scriptName, scriptClass) { + const dbSchedule = await scheduleRepository.findScheduleByScriptName(scriptName); + const definitionSchedule = scriptClass.Definition?.schedule; + + if (dbSchedule) { + return { source: 'database', ...dbSchedule }; + } + if (definitionSchedule?.enabled) { + return { source: 'definition', ...definitionSchedule }; + } + return null; +} +``` ### Phase 3: Production Hardening @@ -862,9 +1631,9 @@ module.exports = { AdminFriggCommands }; ### Phase 4: Enterprise & Advanced **Scope**: -- VM sandbox +- VM sandbox (isolated-vm) - Rollback mechanism -- Step Functions for long-running scripts +- Step Functions for long-running scripts (>15 min) --- @@ -972,17 +1741,34 @@ packages/core/admin-scripts/repositories/script-execution-repository-factory.js packages/core/admin-scripts/repositories/script-execution-repository-mongo.js packages/core/admin-scripts/repositories/script-execution-repository-postgres.js packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js +packages/core/admin-scripts/repositories/script-schedule-repository-interface.js # Phase 2 +packages/core/admin-scripts/repositories/script-schedule-repository-factory.js # Phase 2 +packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js # Phase 2 +packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js # Phase 2 +packages/core/admin-scripts/repositories/script-schedule-repository-documentdb.js # Phase 2 packages/core/admin-scripts/index.js packages/core/application/commands/admin-script-commands.js ``` ### Modify in `packages/core/` ``` -packages/core/prisma-mongodb/schema.prisma (add AdminApiKey, ScriptExecution) -packages/core/prisma-postgresql/schema.prisma (add AdminApiKey, ScriptExecution) +packages/core/prisma-mongodb/schema.prisma (add AdminApiKey, ScriptExecution, ScriptSchedule) +packages/core/prisma-postgresql/schema.prisma (add AdminApiKey, ScriptExecution, ScriptSchedule) packages/core/application/index.js (export createAdminScriptCommands) ``` +### New Files in `packages/devtools/` +``` +packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js +packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js +``` + +### Modify in `packages/devtools/` +``` +packages/devtools/infrastructure/domains/shared/builder-orchestrator.js (register AdminScriptBuilder) +packages/devtools/infrastructure/domains/shared/types/app-definition.js (add adminScripts[], admin config) +``` + ### New Package `packages/admin-scripts/` ``` packages/admin-scripts/package.json @@ -991,9 +1777,13 @@ packages/admin-scripts/src/application/script-factory.js packages/admin-scripts/src/application/script-context.js packages/admin-scripts/src/application/script-runner.js packages/admin-scripts/src/application/admin-frigg-commands.js -packages/admin-scripts/src/infrastructure/create-admin-script-router.js -packages/admin-scripts/src/infrastructure/create-script-handler.js +packages/admin-scripts/src/infrastructure/admin-script-router.js +packages/admin-scripts/src/infrastructure/script-executor-handler.js +packages/admin-scripts/src/infrastructure/script-queue-worker.js packages/admin-scripts/src/infrastructure/admin-auth-middleware.js +packages/admin-scripts/src/adapters/scheduler-adapter.js # Phase 2: Port interface +packages/admin-scripts/src/adapters/aws-scheduler-adapter.js # Phase 2: AWS implementation +packages/admin-scripts/src/adapters/local-scheduler-adapter.js # Phase 2: Dev/test packages/admin-scripts/src/builtins/oauth-token-refresh.js packages/admin-scripts/src/builtins/integration-health-check.js packages/admin-scripts/src/builtins/index.js From 1d7beb5529614feb58f5ab5556b46f1a032414d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 18:45:34 +0000 Subject: [PATCH 04/33] docs(admin-scripts): add dry-run mode and self-queuing patterns - ADR-10: Dry run via repository wrapper + HTTP interceptor - Intercepts DB writes, logs operations, returns unchanged data - Intercepts external API calls via mock axios instance - Script code unchanged between normal and dry-run modes - ADR-11: Self-queuing for long-running scripts - Scripts chunk work and re-queue via frigg.queueScript() - Tracks parentExecutionId for lineage - No Step Functions complexity - Remove VM sandbox (not needed for trusted adopter scripts) - Update package structure with dry-run files --- PLAN-admin-script-runner.md | 320 +++++++++++++++++++++++++++++++++++- 1 file changed, 313 insertions(+), 7 deletions(-) diff --git a/PLAN-admin-script-runner.md b/PLAN-admin-script-runner.md index 58ed4b9c6..e7a5697df 100644 --- a/PLAN-admin-script-runner.md +++ b/PLAN-admin-script-runner.md @@ -1385,7 +1385,9 @@ packages/admin-scripts/ # Application logic & builtins │ │ ├── script-factory.js # Script registration │ │ ├── script-context.js # Execution context │ │ ├── script-runner.js # Orchestrates execution -│ │ └── admin-frigg-commands.js # Helper API for scripts +│ │ ├── admin-frigg-commands.js # Helper API for scripts +│ │ ├── dry-run-repository-wrapper.js # Phase 3: DB write interception +│ │ └── dry-run-http-interceptor.js # Phase 3: HTTP call interception │ ├── infrastructure/ │ │ ├── admin-script-router.js # Express router │ │ ├── script-executor-handler.js # Lambda handler for async @@ -1625,15 +1627,319 @@ async function getEffectiveSchedule(scriptName, scriptClass) { **Scope**: - Rate limiting middleware - Tenant isolation -- Dry-run mode -- Approval workflow +- Dry-run mode (ADR-10) -### Phase 4: Enterprise & Advanced +### ADR-10: Dry Run Mode + +**Decision**: Implement dry run via adapter interception (repositories + HTTP client). + +**Components**: +1. **Repository Wrapper** - Intercepts DB writes, logs operations, returns unchanged data +2. **HTTP Client Interceptor** - Intercepts external API calls, logs requests, returns mock responses + +**Repository Wrapper**: +```javascript +// packages/admin-scripts/src/application/dry-run-repository-wrapper.js + +class DryRunRepositoryWrapper { + constructor(realRepository, operationLog) { + this.realRepo = realRepository; + this.log = operationLog; + } + + // READ operations pass through + async findIntegrationById(id) { + return this.realRepo.findIntegrationById(id); + } + + async listIntegrations(filter) { + return this.realRepo.listIntegrations(filter); + } + + // WRITE operations are logged, not executed + async updateIntegrationConfig(integrationId, config) { + const existing = await this.realRepo.findIntegrationById(integrationId); + this.log.push({ + operation: 'UPDATE', + model: 'Integration', + id: integrationId, + field: 'config', + before: existing?.config, + after: config, + wouldAffect: 1 + }); + return existing; // Return unchanged + } + + async deleteIntegration(integrationId) { + const existing = await this.realRepo.findIntegrationById(integrationId); + this.log.push({ + operation: 'DELETE', + model: 'Integration', + id: integrationId, + record: existing, + wouldAffect: existing ? 1 : 0 + }); + return { deleted: false, dryRun: true }; + } +} +``` + +**HTTP Client Interceptor** (for external API calls): +```javascript +// packages/admin-scripts/src/application/dry-run-http-interceptor.js + +function createDryRunAxiosInstance(operationLog) { + const sanitizeHeaders = (headers) => { + const safe = { ...headers }; + delete safe.Authorization; + delete safe['x-api-key']; + return safe; + }; + + const detectService = (baseURL) => { + if (baseURL?.includes('hubspot')) return 'HubSpot'; + if (baseURL?.includes('salesforce')) return 'Salesforce'; + if (baseURL?.includes('attio')) return 'Attio'; + if (baseURL?.includes('zoho')) return 'Zoho'; + return 'unknown'; + }; + + const mockRequest = async (config) => { + operationLog.push({ + operation: 'HTTP_REQUEST', + method: config.method?.toUpperCase() || 'GET', + url: config.url, + baseURL: config.baseURL, + data: config.data, + headers: sanitizeHeaders(config.headers || {}), + service: detectService(config.baseURL), + }); + + // Return mock response + return { + status: 200, + data: { _dryRun: true, _wouldHaveExecuted: config.url } + }; + }; + + return { + request: mockRequest, + get: (url, config = {}) => mockRequest({ ...config, method: 'GET', url }), + post: (url, data, config = {}) => mockRequest({ ...config, method: 'POST', url, data }), + put: (url, data, config = {}) => mockRequest({ ...config, method: 'PUT', url, data }), + patch: (url, data, config = {}) => mockRequest({ ...config, method: 'PATCH', url, data }), + delete: (url, config = {}) => mockRequest({ ...config, method: 'DELETE', url }), + }; +} + +module.exports = { createDryRunAxiosInstance }; +``` + +**Script Runner Integration**: +```javascript +// In script-runner.js + +async executeScript(scriptClass, params, options = {}) { + const { dryRun = false } = options; + const operationLog = []; + + // Create frigg commands with real or dry-run adapters + const frigg = dryRun + ? this.createDryRunFriggCommands(operationLog) + : this.createFriggCommands(); + + const script = new scriptClass({ executionId: this.executionId }); + const result = await script.execute(frigg, params); + + if (dryRun) { + return { + dryRun: true, + preview: { + operations: operationLog, + summary: this.summarizeOperations(operationLog), + scriptOutput: result + } + }; + } + + return result; +} + +createDryRunFriggCommands(operationLog) { + const realCommands = this.createFriggCommands(); + const dryRunHttpClient = createDryRunAxiosInstance(operationLog); + + return { + ...realCommands, + // Wrap repositories with dry-run versions + integrationRepository: new DryRunRepositoryWrapper( + realCommands.integrationRepository, operationLog + ), + // Override instantiate to inject dry-run HTTP client + instantiate: async (integrationId) => { + const instance = await realCommands.instantiate(integrationId); + // Replace HTTP clients in API modules + if (instance.primary?.api?._httpClient) { + instance.primary.api._httpClient = dryRunHttpClient; + } + if (instance.target?.api?._httpClient) { + instance.target.api._httpClient = dryRunHttpClient; + } + return instance; + } + }; +} + +summarizeOperations(log) { + const summary = { dbUpdates: 0, dbDeletes: 0, dbCreates: 0, httpRequests: 0, byModel: {}, byService: {} }; + + for (const op of log) { + if (op.operation === 'UPDATE') summary.dbUpdates += op.wouldAffect || 1; + if (op.operation === 'DELETE') summary.dbDeletes += op.wouldAffect || 1; + if (op.operation === 'CREATE') summary.dbCreates += op.wouldAffect || 1; + if (op.operation === 'HTTP_REQUEST') { + summary.httpRequests++; + summary.byService[op.service] = (summary.byService[op.service] || 0) + 1; + } + + if (op.model) { + summary.byModel[op.model] = summary.byModel[op.model] || []; + summary.byModel[op.model].push(op); + } + } + + return summary; +} +``` + +**API Usage**: +```javascript +// POST /admin/scripts/attio-healing/execute +{ + "params": { "integrationIds": ["abc"] }, + "dryRun": true +} + +// Response +{ + "dryRun": true, + "preview": { + "summary": { + "dbUpdates": 1, + "dbDeletes": 0, + "httpRequests": 2, + "byService": { "Attio": 2 } + }, + "operations": [ + { "operation": "HTTP_REQUEST", "method": "GET", "service": "Attio", + "url": "/v2/objects/people/abc" }, + { "operation": "UPDATE", "model": "Integration", "id": "abc", + "field": "config", "before": {...}, "after": {...} }, + { "operation": "HTTP_REQUEST", "method": "PATCH", "service": "Attio", + "url": "/v2/objects/people/abc", "data": {...} } + ], + "scriptOutput": { "fixed": 1, "failed": 0 } + } +} +``` + +**Rationale**: +- Script code unchanged - same script works in normal and dry-run mode +- Hexagonal pattern - adapters are swapped at infrastructure layer +- Full visibility - see before/after for DB ops, full request details for HTTP +- Safe testing - test healing scripts against production data without risk + +### ADR-11: Self-Queuing for Long-Running Scripts + +**Decision**: Scripts self-manage long-running work by chunking and re-queuing. + +**Pattern**: +```javascript +class LargeScaleHealingScript extends AdminScriptBase { + static Definition = { + name: 'large-scale-healing', + config: { + timeout: 840000, // 14 min (leave 1 min buffer) + } + }; + + async execute(frigg, params) { + const { cursor = null, batchSize = 100, processedTotal = 0 } = params; + + const integrations = await frigg.listIntegrations({ + cursor, + limit: batchSize, + filter: { status: 'ERROR' } + }); + + let processed = 0; + for (const int of integrations.data) { + await this.healIntegration(frigg, int); + processed++; + } + + const newTotal = processedTotal + processed; + + // More work? Re-queue with cursor + if (integrations.nextCursor) { + await frigg.queueScript(this.constructor.Definition.name, { + ...params, + cursor: integrations.nextCursor, + processedTotal: newTotal + }); + + return { + status: 'CONTINUING', + processedThisBatch: processed, + processedTotal: newTotal, + hasMore: true + }; + } + + return { + status: 'COMPLETED', + processedThisBatch: processed, + processedTotal: newTotal, + hasMore: false + }; + } +} +``` + +**AdminFriggCommands.queueScript()**: +```javascript +// In admin-frigg-commands.js + +async queueScript(scriptName, params) { + const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs'); + const sqs = new SQSClient({}); + + await sqs.send(new SendMessageCommand({ + QueueUrl: process.env.ADMIN_SCRIPT_QUEUE_URL, + MessageBody: JSON.stringify({ + scriptName, + trigger: 'QUEUE', // Self-queued continuation + params, + parentExecutionId: this.executionId, // Track lineage + }), + })); + + this.log('info', `Queued continuation for ${scriptName}`, { params }); +} +``` + +**Benefits**: +- No Lambda timeout issues (each batch < 15 min) +- Progress tracking via execution records +- Fault tolerance (failed batch can retry independently) +- No Step Functions complexity + +### Phase 4: Enterprise Features **Scope**: -- VM sandbox (isolated-vm) -- Rollback mechanism -- Step Functions for long-running scripts (>15 min) +- Approval workflow (two-admin sign-off for production scripts) +- Rollback mechanism (pre-execution snapshots) --- From 8d61f3f837232e095b32e0993031ccb219748cff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 18:52:28 +0000 Subject: [PATCH 05/33] docs(admin-scripts): use existing QueuerUtil for self-queuing - Replace raw SQS code with QueuerUtil.send() and batchSend() - Reference: packages/core/queues/queuer-util.js - Added queueScriptBatch() for bulk operations --- PLAN-admin-script-runner.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/PLAN-admin-script-runner.md b/PLAN-admin-script-runner.md index e7a5697df..022e2fd96 100644 --- a/PLAN-admin-script-runner.md +++ b/PLAN-admin-script-runner.md @@ -1911,24 +1911,39 @@ class LargeScaleHealingScript extends AdminScriptBase { ```javascript // In admin-frigg-commands.js -async queueScript(scriptName, params) { - const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs'); - const sqs = new SQSClient({}); +const { QueuerUtil } = require('@friggframework/core/queues'); - await sqs.send(new SendMessageCommand({ - QueueUrl: process.env.ADMIN_SCRIPT_QUEUE_URL, - MessageBody: JSON.stringify({ +async queueScript(scriptName, params) { + await QueuerUtil.send( + { scriptName, trigger: 'QUEUE', // Self-queued continuation params, parentExecutionId: this.executionId, // Track lineage - }), - })); + }, + process.env.ADMIN_SCRIPT_QUEUE_URL + ); this.log('info', `Queued continuation for ${scriptName}`, { params }); } + +// For batch operations (e.g., queuing multiple scripts) +async queueScriptBatch(entries) { + // entries = [{ scriptName, params }, ...] + const messages = entries.map(entry => ({ + scriptName: entry.scriptName, + trigger: 'QUEUE', + params: entry.params, + parentExecutionId: this.executionId, + })); + + await QueuerUtil.batchSend(messages, process.env.ADMIN_SCRIPT_QUEUE_URL); + this.log('info', `Queued ${entries.length} script continuations`); +} ``` +**Reference**: `/home/user/frigg/packages/core/queues/queuer-util.js` + **Benefits**: - No Lambda timeout issues (each batch < 15 min) - Progress tracking via execution records From dcc9eef0bdf721e5c8056c3ea99099371018bb4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 19:14:06 +0000 Subject: [PATCH 06/33] feat(admin-scripts): add repository implementations and command factory Phase 1 implementation of Admin Script Runner service: Repository Layer: - AdminApiKey repositories (MongoDB, PostgreSQL, DocumentDB) - ScriptExecution repositories (MongoDB, PostgreSQL, DocumentDB) - Factory pattern for database-agnostic creation - 70 unit tests passing Application Layer: - createAdminScriptCommands() factory with: - API key management (create, validate, list, deactivate) - Execution lifecycle (create, update, complete) - bcrypt hashing for key security - Error mapping to HTTP status codes Infrastructure Layer: - AdminScriptBuilder wired into infrastructure-composer - Generates SQS queue, Lambda functions, EventBridge scheduler - 33 unit tests passing Prisma Schema: - AdminApiKey model with scopes and expiration - ScriptExecution model with status, logs, metrics --- package-lock.json | 1149 ++++++++++++++--- packages/core/admin-scripts/index.js | 48 + ...admin-api-key-repository-interface.test.js | 109 ++ .../admin-api-key-repository-mongo.test.js | 254 ++++ ...ipt-execution-repository-interface.test.js | 187 +++ .../script-execution-repository-mongo.test.js | 429 ++++++ .../admin-api-key-repository-documentdb.js | 21 + .../admin-api-key-repository-factory.js | 51 + .../admin-api-key-repository-interface.js | 104 ++ .../admin-api-key-repository-mongo.js | 151 +++ .../admin-api-key-repository-postgres.js | 185 +++ .../script-execution-repository-documentdb.js | 21 + .../script-execution-repository-factory.js | 51 + .../script-execution-repository-interface.js | 166 +++ .../script-execution-repository-mongo.js | 258 ++++ .../script-execution-repository-postgres.js | 296 +++++ .../__tests__/admin-script-commands.test.js | 817 ++++++++++++ .../commands/admin-script-commands.js | 341 +++++ packages/core/prisma-mongodb/schema.prisma | 68 + packages/core/prisma-postgresql/schema.prisma | 66 + .../admin-scripts/admin-script-builder.js | 200 +++ .../admin-script-builder.test.js | 499 +++++++ .../domains/admin-scripts/index.js | 5 + .../domains/shared/types/app-definition.js | 21 + .../infrastructure/infrastructure-composer.js | 2 + 25 files changed, 5328 insertions(+), 171 deletions(-) create mode 100644 packages/core/admin-scripts/index.js create mode 100644 packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js create mode 100644 packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js create mode 100644 packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js create mode 100644 packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js create mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js create mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js create mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js create mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js create mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js create mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js create mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-factory.js create mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-interface.js create mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-mongo.js create mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-postgres.js create mode 100644 packages/core/application/commands/__tests__/admin-script-commands.test.js create mode 100644 packages/core/application/commands/admin-script-commands.js create mode 100644 packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js create mode 100644 packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js create mode 100644 packages/devtools/infrastructure/domains/admin-scripts/index.js diff --git a/package-lock.json b/package-lock.json index fdc9069a1..a7e71c18b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6226,6 +6226,508 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/@aws-sdk/client-scheduler": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-scheduler/-/client-scheduler-3.948.0.tgz", + "integrity": "sha512-UAMeFOGlXpF5OSIF+WDTD0oYtNWlLmkySqZWMldTFxMb3YSS3RsjQn/UvCNdjGXw9N/cHhtXDMEBpwUORN41SQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/credential-provider-node": "3.948.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/client-sso": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.948.0.tgz", + "integrity": "sha512-iWjchXy8bIAVBUsKnbfKYXRwhLgRg3EqCQ5FTr3JbR+QR75rZm4ZOYXlvHGztVTmtAZ+PQVA1Y4zO7v7N87C0A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/core": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.947.0.tgz", + "integrity": "sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.7", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.947.0.tgz", + "integrity": "sha512-VR2V6dRELmzwAsCpK4GqxUi6UW5WNhAXS9F9AzWi5jvijwJo3nH92YNJUP4quMpgFZxJHEWyXLWgPjh9u0zYOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.947.0.tgz", + "integrity": "sha512-inF09lh9SlHj63Vmr5d+LmwPXZc2IbK8lAruhOr3KLsZAIHEgHgGPXWDC2ukTEMzg0pkexQ6FOhXXad6klK4RA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.948.0.tgz", + "integrity": "sha512-Cl//Qh88e8HBL7yYkJNpF5eq76IO6rq8GsatKcfVBm7RFVxCqYEPSSBtkHdbtNwQdRQqAMXc6E/lEB/CZUDxnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/credential-provider-env": "3.947.0", + "@aws-sdk/credential-provider-http": "3.947.0", + "@aws-sdk/credential-provider-login": "3.948.0", + "@aws-sdk/credential-provider-process": "3.947.0", + "@aws-sdk/credential-provider-sso": "3.948.0", + "@aws-sdk/credential-provider-web-identity": "3.948.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.948.0.tgz", + "integrity": "sha512-ep5vRLnrRdcsP17Ef31sNN4g8Nqk/4JBydcUJuFRbGuyQtrZZrVT81UeH2xhz6d0BK6ejafDB9+ZpBjXuWT5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.947.0", + "@aws-sdk/credential-provider-http": "3.947.0", + "@aws-sdk/credential-provider-ini": "3.948.0", + "@aws-sdk/credential-provider-process": "3.947.0", + "@aws-sdk/credential-provider-sso": "3.948.0", + "@aws-sdk/credential-provider-web-identity": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.947.0.tgz", + "integrity": "sha512-WpanFbHe08SP1hAJNeDdBDVz9SGgMu/gc0XJ9u3uNpW99nKZjDpvPRAdW7WLA4K6essMjxWkguIGNOpij6Do2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.948.0.tgz", + "integrity": "sha512-gqLhX1L+zb/ZDnnYbILQqJ46j735StfWV5PbDjxRzBKS7GzsiYoaf6MyHseEopmWrez5zl5l6aWzig7UpzSeQQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.948.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/token-providers": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.948.0.tgz", + "integrity": "sha512-MvYQlXVoJyfF3/SmnNzOVEtANRAiJIObEUYYyjTqKZTmcRIVVky0tPuG26XnB8LmTYgtESwJIZJj/Eyyc9WURQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", + "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-logger": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", + "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.948.0.tgz", + "integrity": "sha512-Qa8Zj+EAqA0VlAVvxpRnpBpIWJI9KUwaioY1vkeNVwXPlNaz9y9zCKVM9iU9OZ5HXpoUg6TnhATAHXHAE8+QsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.947.0.tgz", + "integrity": "sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@smithy/core": "^3.18.7", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/nested-clients": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.948.0.tgz", + "integrity": "sha512-zcbJfBsB6h254o3NuoEkf0+UY1GpE9ioiQdENWv7odo69s8iaGBEQ4BDpsIMqcuiiUXw1uKIVNxCB1gUGYz8lw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", + "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/token-providers": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.948.0.tgz", + "integrity": "sha512-V487/kM4Teq5dcr1t5K6eoUKuqlGr9FRWL3MIMukMERJXHZvio6kox60FZ/YtciRHRI75u14YUqm2Dzddcu3+A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/types": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", + "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/util-endpoints": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", + "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", + "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.947.0.tgz", + "integrity": "sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-scheduler/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@aws-sdk/client-secrets-manager": { "version": "3.906.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.906.0.tgz", @@ -8040,6 +8542,271 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.948.0.tgz", + "integrity": "sha512-gcKO2b6eeTuZGp3Vvgr/9OxajMrD3W+FZ2FCyJox363ZgMoYJsyNid1vuZrEuAGkx0jvveLXfwiVS0UXyPkgtw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/nested-clients": "3.948.0", + "@aws-sdk/types": "3.936.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/core": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.947.0.tgz", + "integrity": "sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws-sdk/xml-builder": "3.930.0", + "@smithy/core": "^3.18.7", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/signature-v4": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", + "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-logger": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", + "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.948.0.tgz", + "integrity": "sha512-Qa8Zj+EAqA0VlAVvxpRnpBpIWJI9KUwaioY1vkeNVwXPlNaz9y9zCKVM9iU9OZ5HXpoUg6TnhATAHXHAE8+QsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.947.0.tgz", + "integrity": "sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@smithy/core": "^3.18.7", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": { + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.948.0.tgz", + "integrity": "sha512-zcbJfBsB6h254o3NuoEkf0+UY1GpE9ioiQdENWv7odo69s8iaGBEQ4BDpsIMqcuiiUXw1uKIVNxCB1gUGYz8lw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.947.0", + "@aws-sdk/middleware-host-header": "3.936.0", + "@aws-sdk/middleware-logger": "3.936.0", + "@aws-sdk/middleware-recursion-detection": "3.948.0", + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/region-config-resolver": "3.936.0", + "@aws-sdk/types": "3.936.0", + "@aws-sdk/util-endpoints": "3.936.0", + "@aws-sdk/util-user-agent-browser": "3.936.0", + "@aws-sdk/util-user-agent-node": "3.947.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/core": "^3.18.7", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/hash-node": "^4.2.5", + "@smithy/invalid-dependency": "^4.2.5", + "@smithy/middleware-content-length": "^4.2.5", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-retry": "^4.4.14", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.13", + "@smithy/util-defaults-mode-node": "^4.2.16", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", + "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/types": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", + "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-endpoints": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", + "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-endpoints": "^3.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.936.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", + "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.936.0", + "@smithy/types": "^4.9.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.947.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.947.0.tgz", + "integrity": "sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.947.0", + "@aws-sdk/types": "3.936.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/xml-builder": { + "version": "3.930.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", + "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.9.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.906.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.906.0.tgz", @@ -10561,6 +11328,10 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" }, + "node_modules/@friggframework/admin-scripts": { + "resolved": "packages/admin-scripts", + "link": true + }, "node_modules/@friggframework/core": { "resolved": "packages/core", "link": true @@ -16564,12 +17335,12 @@ "dev": true }, "node_modules/@smithy/abort-controller": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.3.tgz", - "integrity": "sha512-xWL9Mf8b7tIFuAlpjKtRPnHrR8XVrwTj5NPYO/QwZPtc0SDLsPxb56V5tzi5yspSMytISHybifez+4jlrx0vkQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", + "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -16619,16 +17390,16 @@ "license": "0BSD" }, "node_modules/@smithy/config-resolver": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.0.tgz", - "integrity": "sha512-Kkmz3Mup2PGp/HNJxhCWkLNdlajJORLSjwkcfrj0E7nu6STAEdcMR1ir5P9/xOmncx8xXfru0fbUYLlZog/cFg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", + "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.3", - "@smithy/types": "^4.8.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.3", - "@smithy/util-middleware": "^4.2.3", + "@smithy/util-endpoints": "^3.2.5", + "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" }, "engines": { @@ -16641,18 +17412,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/core": { - "version": "3.17.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.17.1.tgz", - "integrity": "sha512-V4Qc2CIb5McABYfaGiIYLTmo/vwNIK7WXI5aGveBd9UcdhbOMwcvIMxIw/DJj1S9QgOMa/7FBkarMdIC0EOTEQ==", + "version": "3.18.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.7.tgz", + "integrity": "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.3", - "@smithy/protocol-http": "^5.3.3", - "@smithy/types": "^4.8.0", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.3", - "@smithy/util-stream": "^4.5.4", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" @@ -16667,15 +17438,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.3.tgz", - "integrity": "sha512-hA1MQ/WAHly4SYltJKitEsIDVsNmXcQfYBRv2e+q04fnqtAX5qXaybxy/fhUeAMCnQIdAjaGDb04fMHQefWRhw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", + "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.3", - "@smithy/property-provider": "^4.2.3", - "@smithy/types": "^4.8.0", - "@smithy/url-parser": "^4.2.3", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" }, "engines": { @@ -16785,14 +17556,14 @@ "license": "0BSD" }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.4.tgz", - "integrity": "sha512-bwigPylvivpRLCm+YK9I5wRIYjFESSVwl8JQ1vVx/XhCw0PtCi558NwTnT2DaVCl5pYlImGuQTSwMsZ+pIavRw==", + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", + "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.3", - "@smithy/querystring-builder": "^4.2.3", - "@smithy/types": "^4.8.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" }, @@ -16827,12 +17598,12 @@ "license": "0BSD" }, "node_modules/@smithy/hash-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.3.tgz", - "integrity": "sha512-6+NOdZDbfuU6s1ISp3UOk5Rg953RJ2aBLNLLBEcamLjHAg1Po9Ha7QIB5ZWhdRUVuOUrT8BVFR+O2KIPmw027g==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", + "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -16867,12 +17638,12 @@ "license": "0BSD" }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.3.tgz", - "integrity": "sha512-Cc9W5DwDuebXEDMpOpl4iERo8I0KFjTnomK2RMdhhR87GwrSmUmwMxS4P5JdRf+LsjOdIqumcerwRgYMr/tZ9Q==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", + "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -16949,13 +17720,13 @@ "license": "0BSD" }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.3.tgz", - "integrity": "sha512-/atXLsT88GwKtfp5Jr0Ks1CSa4+lB+IgRnkNrrYP0h1wL4swHNb0YONEvTceNKNdZGJsye+W2HH8W7olbcPUeA==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", + "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.3", - "@smithy/types": "^4.8.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -16968,18 +17739,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.5.tgz", - "integrity": "sha512-SIzKVTvEudFWJbxAaq7f2GvP3jh2FHDpIFI6/VAf4FOWGFZy0vnYMPSRj8PGYI8Hjt29mvmwSRgKuO3bK4ixDw==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.14.tgz", + "integrity": "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.17.1", - "@smithy/middleware-serde": "^4.2.3", - "@smithy/node-config-provider": "^4.3.3", - "@smithy/shared-ini-file-loader": "^4.3.3", - "@smithy/types": "^4.8.0", - "@smithy/url-parser": "^4.2.3", - "@smithy/util-middleware": "^4.2.3", + "@smithy/core": "^3.18.7", + "@smithy/middleware-serde": "^4.2.6", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", + "@smithy/url-parser": "^4.2.5", + "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" }, "engines": { @@ -16992,18 +17763,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.5.tgz", - "integrity": "sha512-DCaXbQqcZ4tONMvvdz+zccDE21sLcbwWoNqzPLFlZaxt1lDtOE2tlVpRSwcTOJrjJSUThdgEYn7HrX5oLGlK9A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.3", - "@smithy/protocol-http": "^5.3.3", - "@smithy/service-error-classification": "^4.2.3", - "@smithy/smithy-client": "^4.9.1", - "@smithy/types": "^4.8.0", - "@smithy/util-middleware": "^4.2.3", - "@smithy/util-retry": "^4.2.3", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.14.tgz", + "integrity": "sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", + "@smithy/util-middleware": "^4.2.5", + "@smithy/util-retry": "^4.2.5", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" }, @@ -17017,13 +17788,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.3.tgz", - "integrity": "sha512-8g4NuUINpYccxiCXM5s1/V+uLtts8NcX4+sPEbvYQDZk4XoJfDpq5y2FQxfmUL89syoldpzNzA0R9nhzdtdKnQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", + "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.3", - "@smithy/types": "^4.8.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17036,12 +17807,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.3.tgz", - "integrity": "sha512-iGuOJkH71faPNgOj/gWuEGS6xvQashpLwWB1HjHq1lNNiVfbiJLpZVbhddPuDbx9l4Cgl0vPLq5ltRfSaHfspA==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", + "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17054,14 +17825,14 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.3.tgz", - "integrity": "sha512-NzI1eBpBSViOav8NVy1fqOlSfkLgkUjUTlohUSgAEhHaFWA3XJiLditvavIP7OpvTjDp5u2LhtlBhkBlEisMwA==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", + "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.3", - "@smithy/shared-ini-file-loader": "^4.3.3", - "@smithy/types": "^4.8.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/shared-ini-file-loader": "^4.4.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17074,15 +17845,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.3.tgz", - "integrity": "sha512-MAwltrDB0lZB/H6/2M5PIsISSwdI5yIh6DaBB9r0Flo9nx3y0dzl/qTMJPd7tJvPdsx6Ks/cwVzheGNYzXyNbQ==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", + "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.3", - "@smithy/protocol-http": "^5.3.3", - "@smithy/querystring-builder": "^4.2.3", - "@smithy/types": "^4.8.0", + "@smithy/abort-controller": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/querystring-builder": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17095,12 +17866,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/property-provider": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.3.tgz", - "integrity": "sha512-+1EZ+Y+njiefCohjlhyOcy1UNYjT+1PwGFHCxA/gYctjg3DQWAU19WigOXAco/Ql8hZokNehpzLd0/+3uCreqQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", + "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17113,12 +17884,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/protocol-http": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.3.tgz", - "integrity": "sha512-Mn7f/1aN2/jecywDcRDvWWWJF4uwg/A0XjFMJtj72DsgHTByfjRltSqcT9NyE9RTdBSN6X1RSXrhn/YWQl8xlw==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", + "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17131,12 +17902,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.3.tgz", - "integrity": "sha512-LOVCGCmwMahYUM/P0YnU/AlDQFjcu+gWbFJooC417QRB/lDJlWSn8qmPSDp+s4YVAHOgtgbNG4sR+SxF/VOcJQ==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", + "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" }, @@ -17150,12 +17921,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.3.tgz", - "integrity": "sha512-cYlSNHcTAX/wc1rpblli3aUlLMGgKZ/Oqn8hhjFASXMCXjIqeuQBei0cnq2JR8t4RtU9FpG6uyl6PxyArTiwKA==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", + "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17169,24 +17940,24 @@ "license": "0BSD" }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.3.tgz", - "integrity": "sha512-NkxsAxFWwsPsQiwFG2MzJ/T7uIR6AQNh1SzcxSUnmmIqIQMlLRQDKhc17M7IYjiuBXhrQRjQTo3CxX+DobS93g==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", + "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0" + "@smithy/types": "^4.9.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.3.3.tgz", - "integrity": "sha512-9f9Ixej0hFhroOK2TxZfUUDR13WVa8tQzhSzPDgXe5jGL3KmaM9s8XN7RQwqtEypI82q9KHnKS71CJ+q/1xLtQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", + "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17199,16 +17970,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/signature-v4": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.3.tgz", - "integrity": "sha512-CmSlUy+eEYbIEYN5N3vvQTRfqt0lJlQkaQUIf+oizu7BbDut0pozfDjBGecfcfWf7c62Yis4JIEgqQ/TCfodaA==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", + "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.3", - "@smithy/types": "^4.8.0", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.3", + "@smithy/util-middleware": "^4.2.5", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" @@ -17223,17 +17994,17 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/smithy-client": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.1.tgz", - "integrity": "sha512-Ngb95ryR5A9xqvQFT5mAmYkCwbXvoLavLFwmi7zVg/IowFPCfiqRfkOKnbc/ZRL8ZKJ4f+Tp6kSu6wjDQb8L/g==", + "version": "4.9.10", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.10.tgz", + "integrity": "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.17.1", - "@smithy/middleware-endpoint": "^4.3.5", - "@smithy/middleware-stack": "^4.2.3", - "@smithy/protocol-http": "^5.3.3", - "@smithy/types": "^4.8.0", - "@smithy/util-stream": "^4.5.4", + "@smithy/core": "^3.18.7", + "@smithy/middleware-endpoint": "^4.3.14", + "@smithy/middleware-stack": "^4.2.5", + "@smithy/protocol-http": "^5.3.5", + "@smithy/types": "^4.9.0", + "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" }, "engines": { @@ -17246,9 +18017,9 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/types": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.8.0.tgz", - "integrity": "sha512-QpELEHLO8SsQVtqP+MkEgCYTFW0pleGozfs3cZ183ZBj9z3VC1CX1/wtFMK64p+5bhtZo41SeLK1rBRtd25nHQ==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", + "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -17263,13 +18034,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/url-parser": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.3.tgz", - "integrity": "sha512-I066AigYvY3d9VlU3zG9XzZg1yT10aNqvCaBTw9EPgu5GrsEl1aUkcMvhkIXascYH1A8W0LQo3B1Kr1cJNcQEw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", + "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.3", - "@smithy/types": "^4.8.0", + "@smithy/querystring-parser": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17365,14 +18136,14 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.4.tgz", - "integrity": "sha512-qI5PJSW52rnutos8Bln8nwQZRpyoSRN6k2ajyoUHNMUzmWqHnOJCnDELJuV6m5PML0VkHI+XcXzdB+6awiqYUw==", + "version": "4.3.13", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.13.tgz", + "integrity": "sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.3", - "@smithy/smithy-client": "^4.9.1", - "@smithy/types": "^4.8.0", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17385,17 +18156,17 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.6.tgz", - "integrity": "sha512-c6M/ceBTm31YdcFpgfgQAJaw3KbaLuRKnAz91iMWFLSrgxRpYm03c3bu5cpYojNMfkV9arCUelelKA7XQT36SQ==", + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.16.tgz", + "integrity": "sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.0", - "@smithy/credential-provider-imds": "^4.2.3", - "@smithy/node-config-provider": "^4.3.3", - "@smithy/property-provider": "^4.2.3", - "@smithy/smithy-client": "^4.9.1", - "@smithy/types": "^4.8.0", + "@smithy/config-resolver": "^4.4.3", + "@smithy/credential-provider-imds": "^4.2.5", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/property-provider": "^4.2.5", + "@smithy/smithy-client": "^4.9.10", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17408,13 +18179,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.3.tgz", - "integrity": "sha512-aCfxUOVv0CzBIkU10TubdgKSx5uRvzH064kaiPEWfNIvKOtNpu642P4FP1hgOFkjQIkDObrfIDnKMKkeyrejvQ==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", + "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.3", - "@smithy/types": "^4.8.0", + "@smithy/node-config-provider": "^4.3.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17443,12 +18214,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-middleware": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.3.tgz", - "integrity": "sha512-v5ObKlSe8PWUHCqEiX2fy1gNv6goiw6E5I/PN2aXg3Fb/hse0xeaAnSpXDiWl7x6LamVKq7senB+m5LOYHUAHw==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", + "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.8.0", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17461,13 +18232,13 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-retry": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.3.tgz", - "integrity": "sha512-lLPWnakjC0q9z+OtiXk+9RPQiYPNAovt2IXD3CP4LkOnd9NpUsxOjMx1SnoUVB7Orb7fZp67cQMtTBKMFDvOGg==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", + "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.3", - "@smithy/types": "^4.8.0", + "@smithy/service-error-classification": "^4.2.5", + "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "engines": { @@ -17480,14 +18251,14 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/@smithy/util-stream": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.4.tgz", - "integrity": "sha512-+qDxSkiErejw1BAIXUFBSfM5xh3arbz1MmxlbMCKanDDZtVEQ7PSKW9FQS0Vud1eI/kYn0oCTVKyNzRlq+9MUw==", + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", + "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.4", - "@smithy/node-http-handler": "^4.4.3", - "@smithy/types": "^4.8.0", + "@smithy/fetch-http-handler": "^5.3.6", + "@smithy/node-http-handler": "^4.4.5", + "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", @@ -38906,6 +39677,42 @@ "node": ">= 10" } }, + "packages/admin-scripts": { + "name": "@friggframework/admin-scripts", + "version": "2.0.0-next.0", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-scheduler": "^3.588.0", + "@friggframework/core": "^2.0.0-next.0", + "bcryptjs": "^2.4.3", + "lodash": "4.17.21", + "mongoose": "6.11.6", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@friggframework/eslint-config": "^2.0.0-next.0", + "@friggframework/prettier-config": "^2.0.0-next.0", + "@friggframework/test": "^2.0.0-next.0", + "chai": "^4.3.6", + "eslint": "^8.22.0", + "jest": "^29.7.0", + "prettier": "^2.7.1", + "sinon": "^16.1.1" + } + }, + "packages/admin-scripts/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "packages/core": { "name": "@friggframework/core", "version": "2.0.0-next.0", diff --git a/packages/core/admin-scripts/index.js b/packages/core/admin-scripts/index.js new file mode 100644 index 000000000..8a952b3eb --- /dev/null +++ b/packages/core/admin-scripts/index.js @@ -0,0 +1,48 @@ +/** + * Admin Scripts Module + * + * Exports repository interfaces and factories for admin script management. + * Concrete implementations support MongoDB, PostgreSQL, and DocumentDB. + * + * Repository interfaces follow the Port pattern in Hexagonal Architecture: + * - Define contracts for data access + * - Enable dependency injection + * - Allow testing with mocks + * - Support multiple database implementations + */ + +// Repository Interfaces +const { AdminApiKeyRepositoryInterface } = require('./repositories/admin-api-key-repository-interface'); +const { ScriptExecutionRepositoryInterface } = require('./repositories/script-execution-repository-interface'); + +// Repository Factories +const { + createAdminApiKeyRepository, + AdminApiKeyRepositoryMongo, + AdminApiKeyRepositoryPostgres, + AdminApiKeyRepositoryDocumentDB, +} = require('./repositories/admin-api-key-repository-factory'); +const { + createScriptExecutionRepository, + ScriptExecutionRepositoryMongo, + ScriptExecutionRepositoryPostgres, + ScriptExecutionRepositoryDocumentDB, +} = require('./repositories/script-execution-repository-factory'); + +module.exports = { + // Repository Interfaces + AdminApiKeyRepositoryInterface, + ScriptExecutionRepositoryInterface, + + // Repository Factories (primary exports for use cases) + createAdminApiKeyRepository, + createScriptExecutionRepository, + + // Concrete Implementations (for testing) + AdminApiKeyRepositoryMongo, + AdminApiKeyRepositoryPostgres, + AdminApiKeyRepositoryDocumentDB, + ScriptExecutionRepositoryMongo, + ScriptExecutionRepositoryPostgres, + ScriptExecutionRepositoryDocumentDB, +}; diff --git a/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js new file mode 100644 index 000000000..e90fe8660 --- /dev/null +++ b/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js @@ -0,0 +1,109 @@ +const { AdminApiKeyRepositoryInterface } = require('../admin-api-key-repository-interface'); + +describe('AdminApiKeyRepositoryInterface', () => { + let repository; + + beforeEach(() => { + repository = new AdminApiKeyRepositoryInterface(); + }); + + describe('Interface contract', () => { + it('should throw error when createApiKey is not implemented', async () => { + await expect( + repository.createApiKey({ + name: 'test-key', + keyHash: 'hash123', + keyLast4: '1234', + scopes: ['scripts:execute'], + expiresAt: new Date(), + createdBy: 'admin@example.com', + }) + ).rejects.toThrow('Method createApiKey must be implemented by subclass'); + }); + + it('should throw error when findApiKeyByHash is not implemented', async () => { + await expect( + repository.findApiKeyByHash('hash123') + ).rejects.toThrow('Method findApiKeyByHash must be implemented by subclass'); + }); + + it('should throw error when findApiKeyById is not implemented', async () => { + await expect( + repository.findApiKeyById('key123') + ).rejects.toThrow('Method findApiKeyById must be implemented by subclass'); + }); + + it('should throw error when findActiveApiKeys is not implemented', async () => { + await expect( + repository.findActiveApiKeys() + ).rejects.toThrow('Method findActiveApiKeys must be implemented by subclass'); + }); + + it('should throw error when updateApiKeyLastUsed is not implemented', async () => { + await expect( + repository.updateApiKeyLastUsed('key123') + ).rejects.toThrow('Method updateApiKeyLastUsed must be implemented by subclass'); + }); + + it('should throw error when deactivateApiKey is not implemented', async () => { + await expect( + repository.deactivateApiKey('key123') + ).rejects.toThrow('Method deactivateApiKey must be implemented by subclass'); + }); + + it('should throw error when deleteApiKey is not implemented', async () => { + await expect( + repository.deleteApiKey('key123') + ).rejects.toThrow('Method deleteApiKey must be implemented by subclass'); + }); + }); + + describe('Method signatures', () => { + it('should accept all required parameters in createApiKey', async () => { + const params = { + name: 'test-key', + keyHash: 'hash123', + keyLast4: '1234', + scopes: ['scripts:execute', 'scripts:read'], + expiresAt: new Date('2025-12-31'), + createdBy: 'admin@example.com', + }; + + await expect(repository.createApiKey(params)).rejects.toThrow(); + }); + + it('should accept string parameter in findApiKeyByHash', async () => { + await expect( + repository.findApiKeyByHash('some-hash') + ).rejects.toThrow(); + }); + + it('should accept string parameter in findApiKeyById', async () => { + await expect( + repository.findApiKeyById('some-id') + ).rejects.toThrow(); + }); + + it('should accept no parameters in findActiveApiKeys', async () => { + await expect(repository.findActiveApiKeys()).rejects.toThrow(); + }); + + it('should accept string parameter in updateApiKeyLastUsed', async () => { + await expect( + repository.updateApiKeyLastUsed('some-id') + ).rejects.toThrow(); + }); + + it('should accept string parameter in deactivateApiKey', async () => { + await expect( + repository.deactivateApiKey('some-id') + ).rejects.toThrow(); + }); + + it('should accept string parameter in deleteApiKey', async () => { + await expect( + repository.deleteApiKey('some-id') + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js b/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js new file mode 100644 index 000000000..68b6bab25 --- /dev/null +++ b/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js @@ -0,0 +1,254 @@ +const { AdminApiKeyRepositoryMongo } = require('../admin-api-key-repository-mongo'); + +describe('AdminApiKeyRepositoryMongo', () => { + let repository; + let mockPrisma; + + beforeEach(() => { + mockPrisma = { + adminApiKey: { + create: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }; + + repository = new AdminApiKeyRepositoryMongo(); + repository.prisma = mockPrisma; + }); + + describe('createApiKey()', () => { + it('should create a new API key with all fields', async () => { + const params = { + name: 'Test Key', + keyHash: 'hash123', + keyLast4: '1234', + scopes: ['scripts:execute', 'scripts:read'], + expiresAt: new Date('2025-12-31'), + createdBy: 'admin@example.com', + }; + + const mockApiKey = { + id: '507f1f77bcf86cd799439011', + ...params, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.adminApiKey.create.mockResolvedValue(mockApiKey); + + const result = await repository.createApiKey(params); + + expect(result).toEqual(mockApiKey); + expect(mockPrisma.adminApiKey.create).toHaveBeenCalledWith({ + data: params, + }); + }); + + it('should create API key without optional fields', async () => { + const params = { + name: 'Test Key', + keyHash: 'hash123', + keyLast4: '1234', + scopes: ['scripts:execute'], + }; + + const mockApiKey = { + id: '507f1f77bcf86cd799439011', + ...params, + expiresAt: null, + createdBy: null, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.adminApiKey.create.mockResolvedValue(mockApiKey); + + const result = await repository.createApiKey(params); + + expect(result).toEqual(mockApiKey); + }); + }); + + describe('findApiKeyByHash()', () => { + it('should find API key by hash', async () => { + const keyHash = 'hash123'; + const mockApiKey = { + id: '507f1f77bcf86cd799439011', + name: 'Test Key', + keyHash, + keyLast4: '1234', + scopes: ['scripts:execute'], + isActive: true, + }; + + mockPrisma.adminApiKey.findUnique.mockResolvedValue(mockApiKey); + + const result = await repository.findApiKeyByHash(keyHash); + + expect(result).toEqual(mockApiKey); + expect(mockPrisma.adminApiKey.findUnique).toHaveBeenCalledWith({ + where: { keyHash }, + }); + }); + + it('should return null if API key not found', async () => { + mockPrisma.adminApiKey.findUnique.mockResolvedValue(null); + + const result = await repository.findApiKeyByHash('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('findApiKeyById()', () => { + it('should find API key by ID', async () => { + const id = '507f1f77bcf86cd799439011'; + const mockApiKey = { + id, + name: 'Test Key', + keyHash: 'hash123', + keyLast4: '1234', + scopes: ['scripts:execute'], + isActive: true, + }; + + mockPrisma.adminApiKey.findUnique.mockResolvedValue(mockApiKey); + + const result = await repository.findApiKeyById(id); + + expect(result).toEqual(mockApiKey); + expect(mockPrisma.adminApiKey.findUnique).toHaveBeenCalledWith({ + where: { id }, + }); + }); + + it('should return null if API key not found', async () => { + mockPrisma.adminApiKey.findUnique.mockResolvedValue(null); + + const result = await repository.findApiKeyById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('findActiveApiKeys()', () => { + it('should find all active non-expired keys', async () => { + const now = new Date(); + const mockApiKeys = [ + { + id: '507f1f77bcf86cd799439011', + name: 'Key 1', + isActive: true, + expiresAt: null, + }, + { + id: '507f1f77bcf86cd799439012', + name: 'Key 2', + isActive: true, + expiresAt: new Date(Date.now() + 86400000), // tomorrow + }, + ]; + + mockPrisma.adminApiKey.findMany.mockResolvedValue(mockApiKeys); + + const result = await repository.findActiveApiKeys(); + + expect(result).toEqual(mockApiKeys); + expect(mockPrisma.adminApiKey.findMany).toHaveBeenCalledWith({ + where: { + isActive: true, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: expect.any(Date) } }, + ], + }, + }); + }); + + it('should return empty array if no active keys', async () => { + mockPrisma.adminApiKey.findMany.mockResolvedValue([]); + + const result = await repository.findActiveApiKeys(); + + expect(result).toEqual([]); + }); + }); + + describe('updateApiKeyLastUsed()', () => { + it('should update lastUsedAt timestamp', async () => { + const id = '507f1f77bcf86cd799439011'; + const mockApiKey = { + id, + name: 'Test Key', + lastUsedAt: new Date(), + }; + + mockPrisma.adminApiKey.update.mockResolvedValue(mockApiKey); + + const result = await repository.updateApiKeyLastUsed(id); + + expect(result).toEqual(mockApiKey); + expect(mockPrisma.adminApiKey.update).toHaveBeenCalledWith({ + where: { id }, + data: { + lastUsedAt: expect.any(Date), + }, + }); + }); + }); + + describe('deactivateApiKey()', () => { + it('should set isActive to false', async () => { + const id = '507f1f77bcf86cd799439011'; + const mockApiKey = { + id, + name: 'Test Key', + isActive: false, + }; + + mockPrisma.adminApiKey.update.mockResolvedValue(mockApiKey); + + const result = await repository.deactivateApiKey(id); + + expect(result).toEqual(mockApiKey); + expect(mockPrisma.adminApiKey.update).toHaveBeenCalledWith({ + where: { id }, + data: { + isActive: false, + }, + }); + }); + }); + + describe('deleteApiKey()', () => { + it('should delete API key and return result', async () => { + const id = '507f1f77bcf86cd799439011'; + + mockPrisma.adminApiKey.delete.mockResolvedValue({}); + + const result = await repository.deleteApiKey(id); + + expect(result).toEqual({ + acknowledged: true, + deletedCount: 1, + }); + expect(mockPrisma.adminApiKey.delete).toHaveBeenCalledWith({ + where: { id }, + }); + }); + + it('should propagate error if delete fails', async () => { + const id = '507f1f77bcf86cd799439011'; + const error = new Error('Not found'); + + mockPrisma.adminApiKey.delete.mockRejectedValue(error); + + await expect(repository.deleteApiKey(id)).rejects.toThrow('Not found'); + }); + }); +}); diff --git a/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js new file mode 100644 index 000000000..a1f450638 --- /dev/null +++ b/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js @@ -0,0 +1,187 @@ +const { ScriptExecutionRepositoryInterface } = require('../script-execution-repository-interface'); + +describe('ScriptExecutionRepositoryInterface', () => { + let repository; + + beforeEach(() => { + repository = new ScriptExecutionRepositoryInterface(); + }); + + describe('Interface contract', () => { + it('should throw error when createExecution is not implemented', async () => { + await expect( + repository.createExecution({ + scriptName: 'test-script', + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param1: 'value1' }, + audit: { + apiKeyName: 'test-key', + apiKeyLast4: '1234', + ipAddress: '192.168.1.1', + }, + }) + ).rejects.toThrow('Method createExecution must be implemented by subclass'); + }); + + it('should throw error when findExecutionById is not implemented', async () => { + await expect( + repository.findExecutionById('exec123') + ).rejects.toThrow('Method findExecutionById must be implemented by subclass'); + }); + + it('should throw error when findExecutionsByScriptName is not implemented', async () => { + await expect( + repository.findExecutionsByScriptName('test-script', { limit: 10 }) + ).rejects.toThrow('Method findExecutionsByScriptName must be implemented by subclass'); + }); + + it('should throw error when findExecutionsByStatus is not implemented', async () => { + await expect( + repository.findExecutionsByStatus('PENDING', { limit: 10 }) + ).rejects.toThrow('Method findExecutionsByStatus must be implemented by subclass'); + }); + + it('should throw error when updateExecutionStatus is not implemented', async () => { + await expect( + repository.updateExecutionStatus('exec123', 'RUNNING') + ).rejects.toThrow('Method updateExecutionStatus must be implemented by subclass'); + }); + + it('should throw error when updateExecutionOutput is not implemented', async () => { + await expect( + repository.updateExecutionOutput('exec123', { result: 'success' }) + ).rejects.toThrow('Method updateExecutionOutput must be implemented by subclass'); + }); + + it('should throw error when updateExecutionError is not implemented', async () => { + await expect( + repository.updateExecutionError('exec123', { + name: 'Error', + message: 'Something went wrong', + stack: 'Error: ...', + }) + ).rejects.toThrow('Method updateExecutionError must be implemented by subclass'); + }); + + it('should throw error when updateExecutionMetrics is not implemented', async () => { + await expect( + repository.updateExecutionMetrics('exec123', { + startTime: new Date(), + endTime: new Date(), + durationMs: 1234, + }) + ).rejects.toThrow('Method updateExecutionMetrics must be implemented by subclass'); + }); + + it('should throw error when appendExecutionLog is not implemented', async () => { + await expect( + repository.appendExecutionLog('exec123', { + level: 'info', + message: 'Log message', + data: {}, + timestamp: new Date().toISOString(), + }) + ).rejects.toThrow('Method appendExecutionLog must be implemented by subclass'); + }); + + it('should throw error when deleteExecutionsOlderThan is not implemented', async () => { + await expect( + repository.deleteExecutionsOlderThan(new Date('2024-01-01')) + ).rejects.toThrow('Method deleteExecutionsOlderThan must be implemented by subclass'); + }); + }); + + describe('Method signatures', () => { + it('should accept all required parameters in createExecution', async () => { + const params = { + scriptName: 'test-script', + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param1: 'value1' }, + audit: { + apiKeyName: 'test-key', + apiKeyLast4: '1234', + ipAddress: '192.168.1.1', + }, + }; + + await expect(repository.createExecution(params)).rejects.toThrow(); + }); + + it('should accept string parameter in findExecutionById', async () => { + await expect( + repository.findExecutionById('some-id') + ).rejects.toThrow(); + }); + + it('should accept scriptName and options in findExecutionsByScriptName', async () => { + await expect( + repository.findExecutionsByScriptName('test-script', { + limit: 10, + offset: 0, + }) + ).rejects.toThrow(); + }); + + it('should accept status and options in findExecutionsByStatus', async () => { + await expect( + repository.findExecutionsByStatus('PENDING', { + limit: 10, + offset: 0, + }) + ).rejects.toThrow(); + }); + + it('should accept id and status in updateExecutionStatus', async () => { + await expect( + repository.updateExecutionStatus('exec123', 'COMPLETED') + ).rejects.toThrow(); + }); + + it('should accept id and output in updateExecutionOutput', async () => { + await expect( + repository.updateExecutionOutput('exec123', { result: 'success' }) + ).rejects.toThrow(); + }); + + it('should accept id and error in updateExecutionError', async () => { + await expect( + repository.updateExecutionError('exec123', { + name: 'Error', + message: 'Failed', + stack: 'Stack trace', + }) + ).rejects.toThrow(); + }); + + it('should accept id and metrics in updateExecutionMetrics', async () => { + await expect( + repository.updateExecutionMetrics('exec123', { + startTime: new Date(), + endTime: new Date(), + durationMs: 5000, + }) + ).rejects.toThrow(); + }); + + it('should accept id and logEntry in appendExecutionLog', async () => { + await expect( + repository.appendExecutionLog('exec123', { + level: 'info', + message: 'Test log', + data: { key: 'value' }, + timestamp: new Date().toISOString(), + }) + ).rejects.toThrow(); + }); + + it('should accept Date parameter in deleteExecutionsOlderThan', async () => { + await expect( + repository.deleteExecutionsOlderThan(new Date('2024-01-01')) + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js b/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js new file mode 100644 index 000000000..1a969227d --- /dev/null +++ b/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js @@ -0,0 +1,429 @@ +const { ScriptExecutionRepositoryMongo } = require('../script-execution-repository-mongo'); + +describe('ScriptExecutionRepositoryMongo', () => { + let repository; + let mockPrisma; + + beforeEach(() => { + mockPrisma = { + scriptExecution: { + create: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + deleteMany: jest.fn(), + }, + }; + + repository = new ScriptExecutionRepositoryMongo(); + repository.prisma = mockPrisma; + }); + + describe('createExecution()', () => { + it('should create execution with all fields', async () => { + const params = { + scriptName: 'test-script', + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param1: 'value1' }, + audit: { + apiKeyName: 'Test Key', + apiKeyLast4: '1234', + ipAddress: '192.168.1.1', + }, + }; + + const mockExecution = { + id: '507f1f77bcf86cd799439011', + scriptName: params.scriptName, + scriptVersion: params.scriptVersion, + trigger: params.trigger, + mode: params.mode, + input: params.input, + auditApiKeyName: params.audit.apiKeyName, + auditApiKeyLast4: params.audit.apiKeyLast4, + auditIpAddress: params.audit.ipAddress, + status: 'PENDING', + logs: [], + createdAt: new Date(), + }; + + mockPrisma.scriptExecution.create.mockResolvedValue(mockExecution); + + const result = await repository.createExecution(params); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.create).toHaveBeenCalledWith({ + data: { + scriptName: params.scriptName, + scriptVersion: params.scriptVersion, + trigger: params.trigger, + mode: params.mode, + input: params.input, + logs: [], + auditApiKeyName: params.audit.apiKeyName, + auditApiKeyLast4: params.audit.apiKeyLast4, + auditIpAddress: params.audit.ipAddress, + }, + }); + }); + + it('should create execution without optional fields', async () => { + const params = { + scriptName: 'test-script', + trigger: 'SCHEDULED', + }; + + const mockExecution = { + id: '507f1f77bcf86cd799439011', + scriptName: params.scriptName, + trigger: params.trigger, + mode: 'async', + status: 'PENDING', + logs: [], + createdAt: new Date(), + }; + + mockPrisma.scriptExecution.create.mockResolvedValue(mockExecution); + + const result = await repository.createExecution(params); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.create).toHaveBeenCalledWith({ + data: { + scriptName: params.scriptName, + trigger: params.trigger, + mode: 'async', + input: undefined, + logs: [], + }, + }); + }); + }); + + describe('findExecutionById()', () => { + it('should find execution by ID', async () => { + const id = '507f1f77bcf86cd799439011'; + const mockExecution = { + id, + scriptName: 'test-script', + status: 'COMPLETED', + }; + + mockPrisma.scriptExecution.findUnique.mockResolvedValue(mockExecution); + + const result = await repository.findExecutionById(id); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.findUnique).toHaveBeenCalledWith({ + where: { id }, + }); + }); + + it('should return null if execution not found', async () => { + mockPrisma.scriptExecution.findUnique.mockResolvedValue(null); + + const result = await repository.findExecutionById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('findExecutionsByScriptName()', () => { + it('should find executions by script name with default options', async () => { + const scriptName = 'test-script'; + const mockExecutions = [ + { id: '1', scriptName, status: 'COMPLETED' }, + { id: '2', scriptName, status: 'RUNNING' }, + ]; + + mockPrisma.scriptExecution.findMany.mockResolvedValue(mockExecutions); + + const result = await repository.findExecutionsByScriptName(scriptName); + + expect(result).toEqual(mockExecutions); + expect(mockPrisma.scriptExecution.findMany).toHaveBeenCalledWith({ + where: { scriptName }, + orderBy: { createdAt: 'desc' }, + take: undefined, + skip: undefined, + }); + }); + + it('should find executions with custom options', async () => { + const scriptName = 'test-script'; + const options = { + limit: 10, + offset: 5, + sortBy: 'status', + sortOrder: 'asc', + }; + const mockExecutions = [{ id: '1', scriptName, status: 'COMPLETED' }]; + + mockPrisma.scriptExecution.findMany.mockResolvedValue(mockExecutions); + + const result = await repository.findExecutionsByScriptName(scriptName, options); + + expect(result).toEqual(mockExecutions); + expect(mockPrisma.scriptExecution.findMany).toHaveBeenCalledWith({ + where: { scriptName }, + orderBy: { status: 'asc' }, + take: 10, + skip: 5, + }); + }); + }); + + describe('findExecutionsByStatus()', () => { + it('should find executions by status', async () => { + const status = 'RUNNING'; + const mockExecutions = [ + { id: '1', scriptName: 'script1', status }, + { id: '2', scriptName: 'script2', status }, + ]; + + mockPrisma.scriptExecution.findMany.mockResolvedValue(mockExecutions); + + const result = await repository.findExecutionsByStatus(status); + + expect(result).toEqual(mockExecutions); + expect(mockPrisma.scriptExecution.findMany).toHaveBeenCalledWith({ + where: { status }, + orderBy: { createdAt: 'desc' }, + take: undefined, + skip: undefined, + }); + }); + }); + + describe('updateExecutionStatus()', () => { + it('should update execution status', async () => { + const id = '507f1f77bcf86cd799439011'; + const status = 'COMPLETED'; + const mockExecution = { id, status }; + + mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); + + const result = await repository.updateExecutionStatus(id, status); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ + where: { id }, + data: { status }, + }); + }); + }); + + describe('updateExecutionOutput()', () => { + it('should update execution output', async () => { + const id = '507f1f77bcf86cd799439011'; + const output = { result: 'success', data: [1, 2, 3] }; + const mockExecution = { id, output }; + + mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); + + const result = await repository.updateExecutionOutput(id, output); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ + where: { id }, + data: { output }, + }); + }); + }); + + describe('updateExecutionError()', () => { + it('should update execution error details', async () => { + const id = '507f1f77bcf86cd799439011'; + const error = { + name: 'ValidationError', + message: 'Invalid input', + stack: 'Error: Invalid input\n at validate(...)', + }; + const mockExecution = { + id, + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + }; + + mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); + + const result = await repository.updateExecutionError(id, error); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ + where: { id }, + data: { + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + }, + }); + }); + }); + + describe('updateExecutionMetrics()', () => { + it('should update all metrics', async () => { + const id = '507f1f77bcf86cd799439011'; + const metrics = { + startTime: new Date('2025-01-01T10:00:00Z'), + endTime: new Date('2025-01-01T10:05:00Z'), + durationMs: 300000, + }; + const mockExecution = { + id, + metricsStartTime: metrics.startTime, + metricsEndTime: metrics.endTime, + metricsDurationMs: metrics.durationMs, + }; + + mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); + + const result = await repository.updateExecutionMetrics(id, metrics); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ + where: { id }, + data: { + metricsStartTime: metrics.startTime, + metricsEndTime: metrics.endTime, + metricsDurationMs: metrics.durationMs, + }, + }); + }); + + it('should update partial metrics', async () => { + const id = '507f1f77bcf86cd799439011'; + const metrics = { + startTime: new Date('2025-01-01T10:00:00Z'), + }; + const mockExecution = { + id, + metricsStartTime: metrics.startTime, + }; + + mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); + + const result = await repository.updateExecutionMetrics(id, metrics); + + expect(result).toEqual(mockExecution); + expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ + where: { id }, + data: { + metricsStartTime: metrics.startTime, + }, + }); + }); + }); + + describe('appendExecutionLog()', () => { + it('should append log entry to existing logs', async () => { + const id = '507f1f77bcf86cd799439011'; + const logEntry = { + level: 'info', + message: 'Processing started', + data: { step: 1 }, + timestamp: new Date().toISOString(), + }; + const existingExecution = { + id, + logs: [ + { level: 'debug', message: 'Initialization', timestamp: new Date().toISOString() }, + ], + }; + const updatedExecution = { + id, + logs: [...existingExecution.logs, logEntry], + }; + + mockPrisma.scriptExecution.findUnique.mockResolvedValue(existingExecution); + mockPrisma.scriptExecution.update.mockResolvedValue(updatedExecution); + + const result = await repository.appendExecutionLog(id, logEntry); + + expect(result).toEqual(updatedExecution); + expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ + where: { id }, + data: { logs: [...existingExecution.logs, logEntry] }, + }); + }); + + it('should append log entry to empty logs array', async () => { + const id = '507f1f77bcf86cd799439011'; + const logEntry = { + level: 'info', + message: 'First log', + timestamp: new Date().toISOString(), + }; + const existingExecution = { + id, + logs: [], + }; + const updatedExecution = { + id, + logs: [logEntry], + }; + + mockPrisma.scriptExecution.findUnique.mockResolvedValue(existingExecution); + mockPrisma.scriptExecution.update.mockResolvedValue(updatedExecution); + + const result = await repository.appendExecutionLog(id, logEntry); + + expect(result).toEqual(updatedExecution); + }); + + it('should throw error if execution not found', async () => { + const id = 'nonexistent'; + const logEntry = { + level: 'info', + message: 'Test', + timestamp: new Date().toISOString(), + }; + + mockPrisma.scriptExecution.findUnique.mockResolvedValue(null); + + await expect(repository.appendExecutionLog(id, logEntry)).rejects.toThrow( + `Execution ${id} not found` + ); + }); + }); + + describe('deleteExecutionsOlderThan()', () => { + it('should delete old executions and return count', async () => { + const date = new Date('2024-01-01'); + const mockResult = { count: 42 }; + + mockPrisma.scriptExecution.deleteMany.mockResolvedValue(mockResult); + + const result = await repository.deleteExecutionsOlderThan(date); + + expect(result).toEqual({ + acknowledged: true, + deletedCount: 42, + }); + expect(mockPrisma.scriptExecution.deleteMany).toHaveBeenCalledWith({ + where: { + createdAt: { + lt: date, + }, + }, + }); + }); + + it('should return zero count if no executions deleted', async () => { + const date = new Date('2024-01-01'); + const mockResult = { count: 0 }; + + mockPrisma.scriptExecution.deleteMany.mockResolvedValue(mockResult); + + const result = await repository.deleteExecutionsOlderThan(date); + + expect(result).toEqual({ + acknowledged: true, + deletedCount: 0, + }); + }); + }); +}); diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js new file mode 100644 index 000000000..cdac6761f --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js @@ -0,0 +1,21 @@ +const { + AdminApiKeyRepositoryMongo, +} = require('./admin-api-key-repository-mongo'); + +/** + * DocumentDB Admin API Key Repository Adapter + * Extends MongoDB implementation since DocumentDB uses the same Prisma client + * + * DocumentDB-specific characteristics: + * - Uses MongoDB-compatible API + * - Prisma client handles the connection + * - IDs are strings with ObjectId format + * - All operations identical to MongoDB implementation + */ +class AdminApiKeyRepositoryDocumentDB extends AdminApiKeyRepositoryMongo { + constructor() { + super(); + } +} + +module.exports = { AdminApiKeyRepositoryDocumentDB }; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js new file mode 100644 index 000000000..eac03479c --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js @@ -0,0 +1,51 @@ +const { AdminApiKeyRepositoryMongo } = require('./admin-api-key-repository-mongo'); +const { AdminApiKeyRepositoryPostgres } = require('./admin-api-key-repository-postgres'); +const { + AdminApiKeyRepositoryDocumentDB, +} = require('./admin-api-key-repository-documentdb'); +const config = require('../../database/config'); + +/** + * Admin API Key Repository Factory + * Creates the appropriate repository adapter based on database type + * + * This implements the Factory pattern for Hexagonal Architecture: + * - Reads database type from app definition (backend/index.js) + * - Returns correct adapter (MongoDB, DocumentDB, or PostgreSQL) + * - Provides clear error for unsupported databases + * + * Usage: + * ```javascript + * const repository = createAdminApiKeyRepository(); + * ``` + * + * @returns {AdminApiKeyRepositoryInterface} Configured repository adapter + * @throws {Error} If database type is not supported + */ +function createAdminApiKeyRepository() { + const dbType = config.DB_TYPE; + + switch (dbType) { + case 'mongodb': + return new AdminApiKeyRepositoryMongo(); + + case 'postgresql': + return new AdminApiKeyRepositoryPostgres(); + + case 'documentdb': + return new AdminApiKeyRepositoryDocumentDB(); + + default: + throw new Error( + `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'` + ); + } +} + +module.exports = { + createAdminApiKeyRepository, + // Export adapters for direct testing + AdminApiKeyRepositoryMongo, + AdminApiKeyRepositoryPostgres, + AdminApiKeyRepositoryDocumentDB, +}; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js new file mode 100644 index 000000000..7ef9a7545 --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js @@ -0,0 +1,104 @@ +/** + * Admin API Key Repository Interface + * Abstract base class defining the contract for admin API key persistence adapters + * + * This follows the Port in Hexagonal Architecture: + * - Domain layer depends on this abstraction + * - Concrete adapters implement this interface + * - Use cases receive repositories via dependency injection + * + * Admin API keys provide authentication for script execution and management endpoints. + * Keys are bcrypt-hashed for security and support scoping and expiration. + * + * @abstract + */ +class AdminApiKeyRepositoryInterface { + /** + * Create a new admin API key + * + * @param {Object} params - API key creation parameters + * @param {string} params.name - Human-readable name for the key + * @param {string} params.keyHash - bcrypt hash of the raw key + * @param {string} params.keyLast4 - Last 4 characters of key (for display) + * @param {string[]} params.scopes - Array of permission scopes (e.g., ['scripts:execute', 'scripts:read']) + * @param {Date} [params.expiresAt] - Optional expiration date + * @param {string} [params.createdBy] - Optional identifier of creator (user/admin) + * @returns {Promise} The created API key record + * @abstract + */ + async createApiKey({ name, keyHash, keyLast4, scopes, expiresAt, createdBy }) { + throw new Error('Method createApiKey must be implemented by subclass'); + } + + /** + * Find an API key by its bcrypt hash + * Used during authentication to validate incoming keys + * + * @param {string} keyHash - The bcrypt hash to search for + * @returns {Promise} The API key record or null if not found + * @abstract + */ + async findApiKeyByHash(keyHash) { + throw new Error('Method findApiKeyByHash must be implemented by subclass'); + } + + /** + * Find an API key by its ID + * + * @param {string|number} id - The API key ID + * @returns {Promise} The API key record or null if not found + * @abstract + */ + async findApiKeyById(id) { + throw new Error('Method findApiKeyById must be implemented by subclass'); + } + + /** + * Find all active (non-expired, non-deactivated) API keys + * Used during authentication to check all valid keys + * + * @returns {Promise} Array of active API key records + * @abstract + */ + async findActiveApiKeys() { + throw new Error('Method findActiveApiKeys must be implemented by subclass'); + } + + /** + * Update the lastUsedAt timestamp for an API key + * Called after successful authentication + * + * @param {string|number} id - The API key ID + * @returns {Promise} Updated API key record + * @abstract + */ + async updateApiKeyLastUsed(id) { + throw new Error('Method updateApiKeyLastUsed must be implemented by subclass'); + } + + /** + * Deactivate an API key (soft delete) + * Sets isActive to false, preventing further use + * + * @param {string|number} id - The API key ID + * @returns {Promise} Updated API key record + * @abstract + */ + async deactivateApiKey(id) { + throw new Error('Method deactivateApiKey must be implemented by subclass'); + } + + /** + * Delete an API key (hard delete) + * Permanently removes the key from the database + * + * @param {string|number} id - The API key ID + * @returns {Promise} Deletion result + * @abstract + */ + async deleteApiKey(id) { + throw new Error('Method deleteApiKey must be implemented by subclass'); + } +} + +module.exports = { AdminApiKeyRepositoryInterface }; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js new file mode 100644 index 000000000..3398b495e --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js @@ -0,0 +1,151 @@ +const { prisma } = require('../../database/prisma'); +const { + AdminApiKeyRepositoryInterface, +} = require('./admin-api-key-repository-interface'); + +/** + * MongoDB Admin API Key Repository Adapter + * Handles admin API key persistence using Prisma with MongoDB + * + * MongoDB-specific characteristics: + * - IDs are strings with @db.ObjectId + * - Supports bcrypt hashed keys + * - Scopes stored as String[] array + */ +class AdminApiKeyRepositoryMongo extends AdminApiKeyRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Create a new admin API key + * + * @param {Object} params - API key creation parameters + * @param {string} params.name - Human-readable name for the key + * @param {string} params.keyHash - bcrypt hash of the raw key + * @param {string} params.keyLast4 - Last 4 characters of key (for display) + * @param {string[]} params.scopes - Array of permission scopes + * @param {Date} [params.expiresAt] - Optional expiration date + * @param {string} [params.createdBy] - Optional identifier of creator + * @returns {Promise} The created API key record + */ + async createApiKey({ name, keyHash, keyLast4, scopes, expiresAt, createdBy }) { + const apiKey = await this.prisma.adminApiKey.create({ + data: { + name, + keyHash, + keyLast4, + scopes, + expiresAt, + createdBy, + }, + }); + + return apiKey; + } + + /** + * Find an API key by its bcrypt hash + * Used during authentication to validate incoming keys + * + * @param {string} keyHash - The bcrypt hash to search for + * @returns {Promise} The API key record or null if not found + */ + async findApiKeyByHash(keyHash) { + const apiKey = await this.prisma.adminApiKey.findUnique({ + where: { keyHash }, + }); + + return apiKey; + } + + /** + * Find an API key by its ID + * + * @param {string} id - The API key ID (MongoDB ObjectId as string) + * @returns {Promise} The API key record or null if not found + */ + async findApiKeyById(id) { + const apiKey = await this.prisma.adminApiKey.findUnique({ + where: { id }, + }); + + return apiKey; + } + + /** + * Find all active (non-expired, non-deactivated) API keys + * Used during authentication to check all valid keys + * + * @returns {Promise} Array of active API key records + */ + async findActiveApiKeys() { + const now = new Date(); + const apiKeys = await this.prisma.adminApiKey.findMany({ + where: { + isActive: true, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: now } }, + ], + }, + }); + + return apiKeys; + } + + /** + * Update the lastUsedAt timestamp for an API key + * Called after successful authentication + * + * @param {string} id - The API key ID + * @returns {Promise} Updated API key record + */ + async updateApiKeyLastUsed(id) { + const apiKey = await this.prisma.adminApiKey.update({ + where: { id }, + data: { + lastUsedAt: new Date(), + }, + }); + + return apiKey; + } + + /** + * Deactivate an API key (soft delete) + * Sets isActive to false, preventing further use + * + * @param {string} id - The API key ID + * @returns {Promise} Updated API key record + */ + async deactivateApiKey(id) { + const apiKey = await this.prisma.adminApiKey.update({ + where: { id }, + data: { + isActive: false, + }, + }); + + return apiKey; + } + + /** + * Delete an API key (hard delete) + * Permanently removes the key from the database + * + * @param {string} id - The API key ID + * @returns {Promise} Deletion result + */ + async deleteApiKey(id) { + await this.prisma.adminApiKey.delete({ + where: { id }, + }); + + // Return Mongoose-compatible result + return { acknowledged: true, deletedCount: 1 }; + } +} + +module.exports = { AdminApiKeyRepositoryMongo }; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js new file mode 100644 index 000000000..a86f72b64 --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js @@ -0,0 +1,185 @@ +const { prisma } = require('../../database/prisma'); +const { + AdminApiKeyRepositoryInterface, +} = require('./admin-api-key-repository-interface'); + +/** + * PostgreSQL Admin API Key Repository Adapter + * Handles admin API key persistence using Prisma with PostgreSQL + * + * PostgreSQL-specific characteristics: + * - Uses Int IDs with autoincrement + * - Requires ID conversion: String (app layer) ↔ Int (database) + * - All returned IDs are converted to strings for application layer consistency + */ +class AdminApiKeyRepositoryPostgres extends AdminApiKeyRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Convert string ID to integer for PostgreSQL queries + * @private + * @param {string|number|null|undefined} id - ID to convert + * @returns {number|null|undefined} Integer ID or null/undefined + * @throws {Error} If ID cannot be converted to integer + */ + _convertId(id) { + if (id === null || id === undefined) return id; + const parsed = parseInt(id, 10); + if (isNaN(parsed)) { + throw new Error(`Invalid ID: ${id} cannot be converted to integer`); + } + return parsed; + } + + /** + * Convert API key object IDs to strings + * @private + * @param {Object|null} apiKey - API key object from database + * @returns {Object|null} API key with string IDs + */ + _convertApiKeyIds(apiKey) { + if (!apiKey) return apiKey; + return { + ...apiKey, + id: apiKey.id?.toString(), + }; + } + + /** + * Create a new admin API key + * + * @param {Object} params - API key creation parameters + * @param {string} params.name - Human-readable name for the key + * @param {string} params.keyHash - bcrypt hash of the raw key + * @param {string} params.keyLast4 - Last 4 characters of key (for display) + * @param {string[]} params.scopes - Array of permission scopes + * @param {Date} [params.expiresAt] - Optional expiration date + * @param {string} [params.createdBy] - Optional identifier of creator + * @returns {Promise} The created API key record with string ID + */ + async createApiKey({ name, keyHash, keyLast4, scopes, expiresAt, createdBy }) { + const apiKey = await this.prisma.adminApiKey.create({ + data: { + name, + keyHash, + keyLast4, + scopes, + expiresAt, + createdBy, + }, + }); + + return this._convertApiKeyIds(apiKey); + } + + /** + * Find an API key by its bcrypt hash + * Used during authentication to validate incoming keys + * + * @param {string} keyHash - The bcrypt hash to search for + * @returns {Promise} The API key record with string ID or null if not found + */ + async findApiKeyByHash(keyHash) { + const apiKey = await this.prisma.adminApiKey.findUnique({ + where: { keyHash }, + }); + + return this._convertApiKeyIds(apiKey); + } + + /** + * Find an API key by its ID + * + * @param {string|number} id - The API key ID + * @returns {Promise} The API key record with string ID or null if not found + */ + async findApiKeyById(id) { + const intId = this._convertId(id); + const apiKey = await this.prisma.adminApiKey.findUnique({ + where: { id: intId }, + }); + + return this._convertApiKeyIds(apiKey); + } + + /** + * Find all active (non-expired, non-deactivated) API keys + * Used during authentication to check all valid keys + * + * @returns {Promise} Array of active API key records with string IDs + */ + async findActiveApiKeys() { + const now = new Date(); + const apiKeys = await this.prisma.adminApiKey.findMany({ + where: { + isActive: true, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: now } }, + ], + }, + }); + + return apiKeys.map((apiKey) => this._convertApiKeyIds(apiKey)); + } + + /** + * Update the lastUsedAt timestamp for an API key + * Called after successful authentication + * + * @param {string|number} id - The API key ID + * @returns {Promise} Updated API key record with string ID + */ + async updateApiKeyLastUsed(id) { + const intId = this._convertId(id); + const apiKey = await this.prisma.adminApiKey.update({ + where: { id: intId }, + data: { + lastUsedAt: new Date(), + }, + }); + + return this._convertApiKeyIds(apiKey); + } + + /** + * Deactivate an API key (soft delete) + * Sets isActive to false, preventing further use + * + * @param {string|number} id - The API key ID + * @returns {Promise} Updated API key record with string ID + */ + async deactivateApiKey(id) { + const intId = this._convertId(id); + const apiKey = await this.prisma.adminApiKey.update({ + where: { id: intId }, + data: { + isActive: false, + }, + }); + + return this._convertApiKeyIds(apiKey); + } + + /** + * Delete an API key (hard delete) + * Permanently removes the key from the database + * + * @param {string|number} id - The API key ID + * @returns {Promise} Deletion result + */ + async deleteApiKey(id) { + const intId = this._convertId(id); + await this.prisma.adminApiKey.delete({ + where: { id: intId }, + }); + + // Return Mongoose-compatible result + return { acknowledged: true, deletedCount: 1 }; + } +} + +module.exports = { AdminApiKeyRepositoryPostgres }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js b/packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js new file mode 100644 index 000000000..9ebe8b9bc --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js @@ -0,0 +1,21 @@ +const { + ScriptExecutionRepositoryMongo, +} = require('./script-execution-repository-mongo'); + +/** + * DocumentDB Script Execution Repository Adapter + * Extends MongoDB implementation since DocumentDB uses the same Prisma client + * + * DocumentDB-specific characteristics: + * - Uses MongoDB-compatible API + * - Prisma client handles the connection + * - IDs are strings with ObjectId format + * - All operations identical to MongoDB implementation + */ +class ScriptExecutionRepositoryDocumentDB extends ScriptExecutionRepositoryMongo { + constructor() { + super(); + } +} + +module.exports = { ScriptExecutionRepositoryDocumentDB }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-factory.js b/packages/core/admin-scripts/repositories/script-execution-repository-factory.js new file mode 100644 index 000000000..8d7fb4a24 --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-execution-repository-factory.js @@ -0,0 +1,51 @@ +const { ScriptExecutionRepositoryMongo } = require('./script-execution-repository-mongo'); +const { ScriptExecutionRepositoryPostgres } = require('./script-execution-repository-postgres'); +const { + ScriptExecutionRepositoryDocumentDB, +} = require('./script-execution-repository-documentdb'); +const config = require('../../database/config'); + +/** + * Script Execution Repository Factory + * Creates the appropriate repository adapter based on database type + * + * This implements the Factory pattern for Hexagonal Architecture: + * - Reads database type from app definition (backend/index.js) + * - Returns correct adapter (MongoDB, DocumentDB, or PostgreSQL) + * - Provides clear error for unsupported databases + * + * Usage: + * ```javascript + * const repository = createScriptExecutionRepository(); + * ``` + * + * @returns {ScriptExecutionRepositoryInterface} Configured repository adapter + * @throws {Error} If database type is not supported + */ +function createScriptExecutionRepository() { + const dbType = config.DB_TYPE; + + switch (dbType) { + case 'mongodb': + return new ScriptExecutionRepositoryMongo(); + + case 'postgresql': + return new ScriptExecutionRepositoryPostgres(); + + case 'documentdb': + return new ScriptExecutionRepositoryDocumentDB(); + + default: + throw new Error( + `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'` + ); + } +} + +module.exports = { + createScriptExecutionRepository, + // Export adapters for direct testing + ScriptExecutionRepositoryMongo, + ScriptExecutionRepositoryPostgres, + ScriptExecutionRepositoryDocumentDB, +}; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-interface.js b/packages/core/admin-scripts/repositories/script-execution-repository-interface.js new file mode 100644 index 000000000..7b07f0523 --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-execution-repository-interface.js @@ -0,0 +1,166 @@ +/** + * Script Execution Repository Interface + * Abstract base class defining the contract for script execution persistence adapters + * + * This follows the Port in Hexagonal Architecture: + * - Domain layer depends on this abstraction + * - Concrete adapters implement this interface + * - Use cases receive repositories via dependency injection + * + * Script executions track the lifecycle of admin script runs, including: + * - Input parameters and output results + * - Execution status and error details + * - Performance metrics + * - Audit trail (who triggered, when, from where) + * - Real-time logs + * + * @abstract + */ +class ScriptExecutionRepositoryInterface { + /** + * Create a new script execution record + * + * @param {Object} params - Execution creation parameters + * @param {string} params.scriptName - Name of the script being executed + * @param {string} [params.scriptVersion] - Version of the script + * @param {string} params.trigger - Trigger type ('MANUAL', 'SCHEDULED', 'QUEUE', 'WEBHOOK') + * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') + * @param {Object} [params.input] - Input parameters for the script + * @param {Object} [params.audit] - Audit information + * @param {string} [params.audit.apiKeyName] - Name of API key used + * @param {string} [params.audit.apiKeyLast4] - Last 4 chars of API key + * @param {string} [params.audit.ipAddress] - IP address of requester + * @returns {Promise} The created execution record + * @abstract + */ + async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { + throw new Error('Method createExecution must be implemented by subclass'); + } + + /** + * Find an execution by its ID + * + * @param {string|number} id - The execution ID + * @returns {Promise} The execution record or null if not found + * @abstract + */ + async findExecutionById(id) { + throw new Error('Method findExecutionById must be implemented by subclass'); + } + + /** + * Find all executions for a specific script + * + * @param {string} scriptName - The script name to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of execution records + * @abstract + */ + async findExecutionsByScriptName(scriptName, options = {}) { + throw new Error('Method findExecutionsByScriptName must be implemented by subclass'); + } + + /** + * Find all executions with a specific status + * + * @param {string} status - Status to filter by ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'TIMEOUT', 'CANCELLED') + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of execution records + * @abstract + */ + async findExecutionsByStatus(status, options = {}) { + throw new Error('Method findExecutionsByStatus must be implemented by subclass'); + } + + /** + * Update the status of an execution + * + * @param {string|number} id - The execution ID + * @param {string} status - New status value + * @returns {Promise} Updated execution record + * @abstract + */ + async updateExecutionStatus(id, status) { + throw new Error('Method updateExecutionStatus must be implemented by subclass'); + } + + /** + * Update the output result of an execution + * + * @param {string|number} id - The execution ID + * @param {Object} output - Output data from the script + * @returns {Promise} Updated execution record + * @abstract + */ + async updateExecutionOutput(id, output) { + throw new Error('Method updateExecutionOutput must be implemented by subclass'); + } + + /** + * Update the error details of a failed execution + * + * @param {string|number} id - The execution ID + * @param {Object} error - Error information + * @param {string} error.name - Error name/type + * @param {string} error.message - Error message + * @param {string} [error.stack] - Error stack trace + * @returns {Promise} Updated execution record + * @abstract + */ + async updateExecutionError(id, error) { + throw new Error('Method updateExecutionError must be implemented by subclass'); + } + + /** + * Update the performance metrics of an execution + * + * @param {string|number} id - The execution ID + * @param {Object} metrics - Performance metrics + * @param {Date} [metrics.startTime] - Execution start time + * @param {Date} [metrics.endTime] - Execution end time + * @param {number} [metrics.durationMs] - Duration in milliseconds + * @returns {Promise} Updated execution record + * @abstract + */ + async updateExecutionMetrics(id, metrics) { + throw new Error('Method updateExecutionMetrics must be implemented by subclass'); + } + + /** + * Append a log entry to an execution's log array + * + * @param {string|number} id - The execution ID + * @param {Object} logEntry - Log entry to append + * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') + * @param {string} logEntry.message - Log message + * @param {Object} [logEntry.data] - Additional log data + * @param {string} logEntry.timestamp - ISO timestamp + * @returns {Promise} Updated execution record + * @abstract + */ + async appendExecutionLog(id, logEntry) { + throw new Error('Method appendExecutionLog must be implemented by subclass'); + } + + /** + * Delete all executions older than a specific date + * Used for cleanup and retention policies + * + * @param {Date} date - Delete executions older than this date + * @returns {Promise} Deletion result with count + * @abstract + */ + async deleteExecutionsOlderThan(date) { + throw new Error('Method deleteExecutionsOlderThan must be implemented by subclass'); + } +} + +module.exports = { ScriptExecutionRepositoryInterface }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js b/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js new file mode 100644 index 000000000..64ba9f561 --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js @@ -0,0 +1,258 @@ +const { prisma } = require('../../database/prisma'); +const { + ScriptExecutionRepositoryInterface, +} = require('./script-execution-repository-interface'); + +/** + * MongoDB Script Execution Repository Adapter + * Handles script execution persistence using Prisma with MongoDB + * + * MongoDB-specific characteristics: + * - IDs are strings with @db.ObjectId + * - logs field is Json[] - supports push operations + * - Audit fields stored as separate columns + */ +class ScriptExecutionRepositoryMongo extends ScriptExecutionRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Create a new script execution record + * + * @param {Object} params - Execution creation parameters + * @param {string} params.scriptName - Name of the script being executed + * @param {string} [params.scriptVersion] - Version of the script + * @param {string} params.trigger - Trigger type + * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') + * @param {Object} [params.input] - Input parameters for the script + * @param {Object} [params.audit] - Audit information + * @param {string} [params.audit.apiKeyName] - Name of API key used + * @param {string} [params.audit.apiKeyLast4] - Last 4 chars of API key + * @param {string} [params.audit.ipAddress] - IP address of requester + * @returns {Promise} The created execution record + */ + async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { + const data = { + scriptName, + scriptVersion, + trigger, + mode: mode || 'async', + input, + logs: [], + }; + + // Map audit object to separate fields + if (audit) { + if (audit.apiKeyName) data.auditApiKeyName = audit.apiKeyName; + if (audit.apiKeyLast4) data.auditApiKeyLast4 = audit.apiKeyLast4; + if (audit.ipAddress) data.auditIpAddress = audit.ipAddress; + } + + const execution = await this.prisma.scriptExecution.create({ + data, + }); + + return execution; + } + + /** + * Find an execution by its ID + * + * @param {string} id - The execution ID + * @returns {Promise} The execution record or null if not found + */ + async findExecutionById(id) { + const execution = await this.prisma.scriptExecution.findUnique({ + where: { id }, + }); + + return execution; + } + + /** + * Find all executions for a specific script + * + * @param {string} scriptName - The script name to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of execution records + */ + async findExecutionsByScriptName(scriptName, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const executions = await this.prisma.scriptExecution.findMany({ + where: { scriptName }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return executions; + } + + /** + * Find all executions with a specific status + * + * @param {string} status - Status to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of execution records + */ + async findExecutionsByStatus(status, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const executions = await this.prisma.scriptExecution.findMany({ + where: { status }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return executions; + } + + /** + * Update the status of an execution + * + * @param {string} id - The execution ID + * @param {string} status - New status value + * @returns {Promise} Updated execution record + */ + async updateExecutionStatus(id, status) { + const execution = await this.prisma.scriptExecution.update({ + where: { id }, + data: { status }, + }); + + return execution; + } + + /** + * Update the output result of an execution + * + * @param {string} id - The execution ID + * @param {Object} output - Output data from the script + * @returns {Promise} Updated execution record + */ + async updateExecutionOutput(id, output) { + const execution = await this.prisma.scriptExecution.update({ + where: { id }, + data: { output }, + }); + + return execution; + } + + /** + * Update the error details of a failed execution + * + * @param {string} id - The execution ID + * @param {Object} error - Error information + * @param {string} error.name - Error name/type + * @param {string} error.message - Error message + * @param {string} [error.stack] - Error stack trace + * @returns {Promise} Updated execution record + */ + async updateExecutionError(id, error) { + const execution = await this.prisma.scriptExecution.update({ + where: { id }, + data: { + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + }, + }); + + return execution; + } + + /** + * Update the performance metrics of an execution + * + * @param {string} id - The execution ID + * @param {Object} metrics - Performance metrics + * @param {Date} [metrics.startTime] - Execution start time + * @param {Date} [metrics.endTime] - Execution end time + * @param {number} [metrics.durationMs] - Duration in milliseconds + * @returns {Promise} Updated execution record + */ + async updateExecutionMetrics(id, metrics) { + const data = {}; + if (metrics.startTime !== undefined) data.metricsStartTime = metrics.startTime; + if (metrics.endTime !== undefined) data.metricsEndTime = metrics.endTime; + if (metrics.durationMs !== undefined) data.metricsDurationMs = metrics.durationMs; + + const execution = await this.prisma.scriptExecution.update({ + where: { id }, + data, + }); + + return execution; + } + + /** + * Append a log entry to an execution's log array + * + * @param {string} id - The execution ID + * @param {Object} logEntry - Log entry to append + * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') + * @param {string} logEntry.message - Log message + * @param {Object} [logEntry.data] - Additional log data + * @param {string} logEntry.timestamp - ISO timestamp + * @returns {Promise} Updated execution record + */ + async appendExecutionLog(id, logEntry) { + // Get current execution + const execution = await this.prisma.scriptExecution.findUnique({ + where: { id }, + }); + + if (!execution) { + throw new Error(`Execution ${id} not found`); + } + + // Append log entry to logs array + const logs = Array.isArray(execution.logs) ? execution.logs : []; + logs.push(logEntry); + + // Update with new logs array + const updated = await this.prisma.scriptExecution.update({ + where: { id }, + data: { logs }, + }); + + return updated; + } + + /** + * Delete all executions older than a specific date + * Used for cleanup and retention policies + * + * @param {Date} date - Delete executions older than this date + * @returns {Promise} Deletion result with count + */ + async deleteExecutionsOlderThan(date) { + const result = await this.prisma.scriptExecution.deleteMany({ + where: { + createdAt: { + lt: date, + }, + }, + }); + + return { + acknowledged: true, + deletedCount: result.count, + }; + } +} + +module.exports = { ScriptExecutionRepositoryMongo }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js b/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js new file mode 100644 index 000000000..79798738e --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js @@ -0,0 +1,296 @@ +const { prisma } = require('../../database/prisma'); +const { + ScriptExecutionRepositoryInterface, +} = require('./script-execution-repository-interface'); + +/** + * PostgreSQL Script Execution Repository Adapter + * Handles script execution persistence using Prisma with PostgreSQL + * + * PostgreSQL-specific characteristics: + * - Uses Int IDs with autoincrement + * - Requires ID conversion: String (app layer) ↔ Int (database) + * - All returned IDs are converted to strings for application layer consistency + * - logs field is Json[] - supports push operations + */ +class ScriptExecutionRepositoryPostgres extends ScriptExecutionRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Convert string ID to integer for PostgreSQL queries + * @private + * @param {string|number|null|undefined} id - ID to convert + * @returns {number|null|undefined} Integer ID or null/undefined + * @throws {Error} If ID cannot be converted to integer + */ + _convertId(id) { + if (id === null || id === undefined) return id; + const parsed = parseInt(id, 10); + if (isNaN(parsed)) { + throw new Error(`Invalid ID: ${id} cannot be converted to integer`); + } + return parsed; + } + + /** + * Convert execution object IDs to strings + * @private + * @param {Object|null} execution - Execution object from database + * @returns {Object|null} Execution with string IDs + */ + _convertExecutionIds(execution) { + if (!execution) return execution; + return { + ...execution, + id: execution.id?.toString(), + }; + } + + /** + * Create a new script execution record + * + * @param {Object} params - Execution creation parameters + * @param {string} params.scriptName - Name of the script being executed + * @param {string} [params.scriptVersion] - Version of the script + * @param {string} params.trigger - Trigger type + * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') + * @param {Object} [params.input] - Input parameters for the script + * @param {Object} [params.audit] - Audit information + * @param {string} [params.audit.apiKeyName] - Name of API key used + * @param {string} [params.audit.apiKeyLast4] - Last 4 chars of API key + * @param {string} [params.audit.ipAddress] - IP address of requester + * @returns {Promise} The created execution record with string ID + */ + async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { + const data = { + scriptName, + scriptVersion, + trigger, + mode: mode || 'async', + input, + logs: [], + }; + + // Map audit object to separate fields + if (audit) { + if (audit.apiKeyName) data.auditApiKeyName = audit.apiKeyName; + if (audit.apiKeyLast4) data.auditApiKeyLast4 = audit.apiKeyLast4; + if (audit.ipAddress) data.auditIpAddress = audit.ipAddress; + } + + const execution = await this.prisma.scriptExecution.create({ + data, + }); + + return this._convertExecutionIds(execution); + } + + /** + * Find an execution by its ID + * + * @param {string|number} id - The execution ID + * @returns {Promise} The execution record with string ID or null if not found + */ + async findExecutionById(id) { + const intId = this._convertId(id); + const execution = await this.prisma.scriptExecution.findUnique({ + where: { id: intId }, + }); + + return this._convertExecutionIds(execution); + } + + /** + * Find all executions for a specific script + * + * @param {string} scriptName - The script name to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of execution records with string IDs + */ + async findExecutionsByScriptName(scriptName, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const executions = await this.prisma.scriptExecution.findMany({ + where: { scriptName }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return executions.map((execution) => this._convertExecutionIds(execution)); + } + + /** + * Find all executions with a specific status + * + * @param {string} status - Status to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of execution records with string IDs + */ + async findExecutionsByStatus(status, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const executions = await this.prisma.scriptExecution.findMany({ + where: { status }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return executions.map((execution) => this._convertExecutionIds(execution)); + } + + /** + * Update the status of an execution + * + * @param {string|number} id - The execution ID + * @param {string} status - New status value + * @returns {Promise} Updated execution record with string ID + */ + async updateExecutionStatus(id, status) { + const intId = this._convertId(id); + const execution = await this.prisma.scriptExecution.update({ + where: { id: intId }, + data: { status }, + }); + + return this._convertExecutionIds(execution); + } + + /** + * Update the output result of an execution + * + * @param {string|number} id - The execution ID + * @param {Object} output - Output data from the script + * @returns {Promise} Updated execution record with string ID + */ + async updateExecutionOutput(id, output) { + const intId = this._convertId(id); + const execution = await this.prisma.scriptExecution.update({ + where: { id: intId }, + data: { output }, + }); + + return this._convertExecutionIds(execution); + } + + /** + * Update the error details of a failed execution + * + * @param {string|number} id - The execution ID + * @param {Object} error - Error information + * @param {string} error.name - Error name/type + * @param {string} error.message - Error message + * @param {string} [error.stack] - Error stack trace + * @returns {Promise} Updated execution record with string ID + */ + async updateExecutionError(id, error) { + const intId = this._convertId(id); + const execution = await this.prisma.scriptExecution.update({ + where: { id: intId }, + data: { + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + }, + }); + + return this._convertExecutionIds(execution); + } + + /** + * Update the performance metrics of an execution + * + * @param {string|number} id - The execution ID + * @param {Object} metrics - Performance metrics + * @param {Date} [metrics.startTime] - Execution start time + * @param {Date} [metrics.endTime] - Execution end time + * @param {number} [metrics.durationMs] - Duration in milliseconds + * @returns {Promise} Updated execution record with string ID + */ + async updateExecutionMetrics(id, metrics) { + const intId = this._convertId(id); + const data = {}; + if (metrics.startTime !== undefined) data.metricsStartTime = metrics.startTime; + if (metrics.endTime !== undefined) data.metricsEndTime = metrics.endTime; + if (metrics.durationMs !== undefined) data.metricsDurationMs = metrics.durationMs; + + const execution = await this.prisma.scriptExecution.update({ + where: { id: intId }, + data, + }); + + return this._convertExecutionIds(execution); + } + + /** + * Append a log entry to an execution's log array + * + * @param {string|number} id - The execution ID + * @param {Object} logEntry - Log entry to append + * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') + * @param {string} logEntry.message - Log message + * @param {Object} [logEntry.data] - Additional log data + * @param {string} logEntry.timestamp - ISO timestamp + * @returns {Promise} Updated execution record with string ID + */ + async appendExecutionLog(id, logEntry) { + const intId = this._convertId(id); + + // Get current execution + const execution = await this.prisma.scriptExecution.findUnique({ + where: { id: intId }, + }); + + if (!execution) { + throw new Error(`Execution ${id} not found`); + } + + // Append log entry to logs array + const logs = Array.isArray(execution.logs) ? execution.logs : []; + logs.push(logEntry); + + // Update with new logs array + const updated = await this.prisma.scriptExecution.update({ + where: { id: intId }, + data: { logs }, + }); + + return this._convertExecutionIds(updated); + } + + /** + * Delete all executions older than a specific date + * Used for cleanup and retention policies + * + * @param {Date} date - Delete executions older than this date + * @returns {Promise} Deletion result with count + */ + async deleteExecutionsOlderThan(date) { + const result = await this.prisma.scriptExecution.deleteMany({ + where: { + createdAt: { + lt: date, + }, + }, + }); + + return { + acknowledged: true, + deletedCount: result.count, + }; + } +} + +module.exports = { ScriptExecutionRepositoryPostgres }; diff --git a/packages/core/application/commands/__tests__/admin-script-commands.test.js b/packages/core/application/commands/__tests__/admin-script-commands.test.js new file mode 100644 index 000000000..3b864f58f --- /dev/null +++ b/packages/core/application/commands/__tests__/admin-script-commands.test.js @@ -0,0 +1,817 @@ +// Mock database config before imports +jest.mock('../../../database/config', () => ({ + DB_TYPE: 'mongodb', + getDatabaseType: jest.fn(() => 'mongodb'), + PRISMA_LOG_LEVEL: 'error,warn', + PRISMA_QUERY_LOGGING: false, +})); + +// Mock bcrypt for deterministic testing +const mockBcryptHash = jest.fn(); +const mockBcryptCompare = jest.fn(); +jest.mock('bcryptjs', () => ({ + hash: mockBcryptHash, + compare: mockBcryptCompare, +})); + +// Mock uuid for deterministic key generation +const mockUuid = jest.fn(); +jest.mock('uuid', () => ({ + v4: mockUuid, +})); + +// Mock repository factories +const mockApiKeyRepo = { + createApiKey: jest.fn(), + findActiveApiKeys: jest.fn(), + findApiKeyById: jest.fn(), + updateApiKeyLastUsed: jest.fn(), + deactivateApiKey: jest.fn(), +}; + +const mockExecutionRepo = { + createExecution: jest.fn(), + findExecutionById: jest.fn(), + findExecutionsByScriptName: jest.fn(), + findExecutionsByStatus: jest.fn(), + updateExecutionStatus: jest.fn(), + updateExecutionOutput: jest.fn(), + updateExecutionError: jest.fn(), + updateExecutionMetrics: jest.fn(), + appendExecutionLog: jest.fn(), +}; + +jest.mock('../../../admin-scripts/repositories/admin-api-key-repository-factory', () => ({ + createAdminApiKeyRepository: () => mockApiKeyRepo, +})); + +jest.mock('../../../admin-scripts/repositories/script-execution-repository-factory', () => ({ + createScriptExecutionRepository: () => mockExecutionRepo, +})); + +const { createAdminScriptCommands } = require('../admin-script-commands'); + +describe('createAdminScriptCommands', () => { + let commands; + + beforeEach(() => { + jest.clearAllMocks(); + commands = createAdminScriptCommands(); + }); + + describe('createAdminApiKey', () => { + it('creates API key with all fields', async () => { + const rawKey = 'test-uuid-1234-5678-abcd'; + const keyHash = 'hashed-key'; + mockUuid.mockReturnValue(rawKey); + mockBcryptHash.mockResolvedValue(keyHash); + + const mockRecord = { + id: 'key-123', + name: 'Test Key', + keyHash, + keyLast4: 'abcd', + scopes: ['scripts:execute'], + expiresAt: new Date('2025-12-31'), + }; + mockApiKeyRepo.createApiKey.mockResolvedValue(mockRecord); + + const result = await commands.createAdminApiKey({ + name: 'Test Key', + scopes: ['scripts:execute'], + expiresAt: new Date('2025-12-31'), + createdBy: 'admin@example.com', + }); + + expect(mockUuid).toHaveBeenCalled(); + expect(mockBcryptHash).toHaveBeenCalledWith(rawKey, 10); + expect(mockApiKeyRepo.createApiKey).toHaveBeenCalledWith({ + name: 'Test Key', + keyHash, + keyLast4: 'abcd', + scopes: ['scripts:execute'], + expiresAt: new Date('2025-12-31'), + createdBy: 'admin@example.com', + }); + + expect(result).toEqual({ + id: 'key-123', + rawKey, // Only returned once! + name: 'Test Key', + keyLast4: 'abcd', + scopes: ['scripts:execute'], + expiresAt: new Date('2025-12-31'), + }); + }); + + it('returns rawKey only on creation', async () => { + const rawKey = 'unique-key-12345'; + mockUuid.mockReturnValue(rawKey); + mockBcryptHash.mockResolvedValue('hashed'); + + mockApiKeyRepo.createApiKey.mockResolvedValue({ + id: 'key-1', + name: 'Key', + keyHash: 'hashed', + keyLast4: '2345', + scopes: [], + }); + + const result = await commands.createAdminApiKey({ + name: 'Key', + scopes: [], + }); + + expect(result.rawKey).toBe(rawKey); + expect(result.id).toBe('key-1'); + }); + + it('generates unique keys on multiple calls', async () => { + mockUuid + .mockReturnValueOnce('key-1-uuid') + .mockReturnValueOnce('key-2-uuid'); + mockBcryptHash + .mockResolvedValueOnce('hash-1') + .mockResolvedValueOnce('hash-2'); + + mockApiKeyRepo.createApiKey + .mockResolvedValueOnce({ + id: '1', + name: 'First', + keyHash: 'hash-1', + keyLast4: 'uuid', + scopes: [], + }) + .mockResolvedValueOnce({ + id: '2', + name: 'Second', + keyHash: 'hash-2', + keyLast4: 'uuid', + scopes: [], + }); + + const result1 = await commands.createAdminApiKey({ + name: 'First', + scopes: [], + }); + const result2 = await commands.createAdminApiKey({ + name: 'Second', + scopes: [], + }); + + expect(result1.rawKey).toBe('key-1-uuid'); + expect(result2.rawKey).toBe('key-2-uuid'); + expect(result1.id).toBe('1'); + expect(result2.id).toBe('2'); + }); + + it('hashes key with bcrypt cost factor 10', async () => { + mockUuid.mockReturnValue('test-key'); + mockBcryptHash.mockResolvedValue('hashed'); + mockApiKeyRepo.createApiKey.mockResolvedValue({ + id: '1', + name: 'Test', + keyHash: 'hashed', + keyLast4: '-key', + scopes: [], + }); + + await commands.createAdminApiKey({ name: 'Test', scopes: [] }); + + expect(mockBcryptHash).toHaveBeenCalledWith('test-key', 10); + }); + + it('maps error to response on failure', async () => { + mockUuid.mockReturnValue('key'); + mockBcryptHash.mockRejectedValue(new Error('Hashing failed')); + + const result = await commands.createAdminApiKey({ + name: 'Test', + scopes: [], + }); + + expect(result).toHaveProperty('error', 500); + expect(result).toHaveProperty('reason', 'Hashing failed'); + }); + }); + + describe('validateAdminApiKey', () => { + it('returns valid for correct key', async () => { + const rawKey = 'test-key-123'; + const mockKey = { + id: 'key-1', + name: 'Valid Key', + keyHash: 'hashed-test-key', + keyLast4: '-123', + scopes: ['scripts:execute'], + expiresAt: null, + isActive: true, + }; + + mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([mockKey]); + mockBcryptCompare.mockResolvedValue(true); + mockApiKeyRepo.updateApiKeyLastUsed.mockResolvedValue(mockKey); + + const result = await commands.validateAdminApiKey(rawKey); + + expect(mockApiKeyRepo.findActiveApiKeys).toHaveBeenCalled(); + expect(mockBcryptCompare).toHaveBeenCalledWith(rawKey, mockKey.keyHash); + expect(mockApiKeyRepo.updateApiKeyLastUsed).toHaveBeenCalledWith('key-1'); + expect(result).toEqual({ valid: true, apiKey: mockKey }); + }); + + it('returns error for invalid key', async () => { + mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([ + { id: '1', keyHash: 'hash1' }, + { id: '2', keyHash: 'hash2' }, + ]); + mockBcryptCompare.mockResolvedValue(false); + + const result = await commands.validateAdminApiKey('invalid-key'); + + expect(result).toHaveProperty('error', 401); + expect(result).toHaveProperty('code', 'INVALID_API_KEY'); + expect(result).toHaveProperty('reason', 'Invalid API key'); + expect(mockApiKeyRepo.updateApiKeyLastUsed).not.toHaveBeenCalled(); + }); + + it('returns error for expired key', async () => { + const expiredKey = { + id: 'key-1', + keyHash: 'hash', + expiresAt: new Date('2020-01-01'), // Past date + }; + + mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([expiredKey]); + mockBcryptCompare.mockResolvedValue(true); + + const result = await commands.validateAdminApiKey('expired-key'); + + expect(result).toHaveProperty('error', 401); + expect(result).toHaveProperty('code', 'EXPIRED_API_KEY'); + expect(result).toHaveProperty('reason', 'API key has expired'); + expect(mockApiKeyRepo.updateApiKeyLastUsed).not.toHaveBeenCalled(); + }); + + it('updates lastUsedAt on success', async () => { + const validKey = { + id: 'key-1', + keyHash: 'hash', + expiresAt: null, + }; + + mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([validKey]); + mockBcryptCompare.mockResolvedValue(true); + mockApiKeyRepo.updateApiKeyLastUsed.mockResolvedValue({ + ...validKey, + lastUsedAt: new Date(), + }); + + await commands.validateAdminApiKey('valid-key'); + + expect(mockApiKeyRepo.updateApiKeyLastUsed).toHaveBeenCalledWith('key-1'); + }); + + it('checks multiple keys until match found', async () => { + const keys = [ + { id: '1', keyHash: 'hash1' }, + { id: '2', keyHash: 'hash2' }, + { id: '3', keyHash: 'hash3' }, + ]; + + mockApiKeyRepo.findActiveApiKeys.mockResolvedValue(keys); + mockBcryptCompare + .mockResolvedValueOnce(false) // First key doesn't match + .mockResolvedValueOnce(true); // Second key matches + mockApiKeyRepo.updateApiKeyLastUsed.mockResolvedValue(keys[1]); + + const result = await commands.validateAdminApiKey('test-key'); + + expect(mockBcryptCompare).toHaveBeenCalledTimes(2); + expect(result.valid).toBe(true); + expect(result.apiKey).toEqual(keys[1]); + }); + }); + + describe('listAdminApiKeys', () => { + it('returns active keys without keyHash', async () => { + const mockKeys = [ + { + id: 'key-1', + name: 'First Key', + keyHash: 'secret-hash-1', + keyLast4: '1234', + scopes: ['scripts:execute'], + }, + { + id: 'key-2', + name: 'Second Key', + keyHash: 'secret-hash-2', + keyLast4: '5678', + scopes: ['scripts:read'], + }, + ]; + + mockApiKeyRepo.findActiveApiKeys.mockResolvedValue(mockKeys); + + const result = await commands.listAdminApiKeys(); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('keyHash'); + expect(result[1]).not.toHaveProperty('keyHash'); + expect(result[0]).toEqual({ + id: 'key-1', + name: 'First Key', + keyLast4: '1234', + scopes: ['scripts:execute'], + }); + }); + + it('returns empty array if no active keys', async () => { + mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([]); + + const result = await commands.listAdminApiKeys(); + + expect(result).toEqual([]); + }); + + it('maps error on repository failure', async () => { + mockApiKeyRepo.findActiveApiKeys.mockRejectedValue( + new Error('Database error') + ); + + const result = await commands.listAdminApiKeys(); + + expect(result).toHaveProperty('error', 500); + expect(result).toHaveProperty('reason', 'Database error'); + }); + }); + + describe('deactivateAdminApiKey', () => { + it('deactivates existing key', async () => { + const mockDeactivated = { + id: 'key-1', + isActive: false, + }; + + mockApiKeyRepo.deactivateApiKey.mockResolvedValue(mockDeactivated); + + const result = await commands.deactivateAdminApiKey('key-1'); + + expect(mockApiKeyRepo.deactivateApiKey).toHaveBeenCalledWith('key-1'); + expect(result).toEqual(mockDeactivated); + }); + + it('handles non-existent key gracefully', async () => { + mockApiKeyRepo.deactivateApiKey.mockRejectedValue( + new Error('Key not found') + ); + + const result = await commands.deactivateAdminApiKey('non-existent'); + + expect(result).toHaveProperty('error', 500); + expect(result).toHaveProperty('reason', 'Key not found'); + }); + }); + + describe('createScriptExecution', () => { + it('creates execution with all fields', async () => { + const mockExecution = { + id: 'exec-1', + scriptName: 'test-script', + scriptVersion: '1.0.0', + status: 'PENDING', + trigger: 'MANUAL', + mode: 'async', + input: { param: 'value' }, + audit: { + apiKeyName: 'Admin Key', + apiKeyLast4: '1234', + ipAddress: '127.0.0.1', + }, + createdAt: new Date(), + }; + + mockExecutionRepo.createExecution.mockResolvedValue(mockExecution); + + const result = await commands.createScriptExecution({ + scriptName: 'test-script', + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param: 'value' }, + audit: { + apiKeyName: 'Admin Key', + apiKeyLast4: '1234', + ipAddress: '127.0.0.1', + }, + }); + + expect(mockExecutionRepo.createExecution).toHaveBeenCalledWith({ + scriptName: 'test-script', + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param: 'value' }, + audit: { + apiKeyName: 'Admin Key', + apiKeyLast4: '1234', + ipAddress: '127.0.0.1', + }, + }); + expect(result).toEqual(mockExecution); + }); + + it('sets default mode to async if not provided', async () => { + const mockExecution = { + id: 'exec-1', + scriptName: 'test', + status: 'PENDING', + trigger: 'MANUAL', + mode: 'async', + }; + + mockExecutionRepo.createExecution.mockResolvedValue(mockExecution); + + await commands.createScriptExecution({ + scriptName: 'test', + trigger: 'MANUAL', + }); + + expect(mockExecutionRepo.createExecution).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'async', + }) + ); + }); + + it('stores audit info correctly', async () => { + mockExecutionRepo.createExecution.mockResolvedValue({ + id: 'exec-1', + audit: { + apiKeyName: 'Test Key', + apiKeyLast4: 'abcd', + ipAddress: '192.168.1.1', + }, + }); + + await commands.createScriptExecution({ + scriptName: 'test', + trigger: 'MANUAL', + audit: { + apiKeyName: 'Test Key', + apiKeyLast4: 'abcd', + ipAddress: '192.168.1.1', + }, + }); + + expect(mockExecutionRepo.createExecution).toHaveBeenCalledWith( + expect.objectContaining({ + audit: { + apiKeyName: 'Test Key', + apiKeyLast4: 'abcd', + ipAddress: '192.168.1.1', + }, + }) + ); + }); + }); + + describe('findScriptExecutionById', () => { + it('returns execution if found', async () => { + const mockExecution = { + id: 'exec-1', + scriptName: 'test', + status: 'COMPLETED', + }; + + mockExecutionRepo.findExecutionById.mockResolvedValue(mockExecution); + + const result = await commands.findScriptExecutionById('exec-1'); + + expect(mockExecutionRepo.findExecutionById).toHaveBeenCalledWith('exec-1'); + expect(result).toEqual(mockExecution); + }); + + it('returns error if not found', async () => { + mockExecutionRepo.findExecutionById.mockResolvedValue(null); + + const result = await commands.findScriptExecutionById('non-existent'); + + expect(result).toHaveProperty('error', 404); + expect(result).toHaveProperty('code', 'EXECUTION_NOT_FOUND'); + expect(result.reason).toContain('non-existent'); + }); + }); + + describe('findScriptExecutionsByName', () => { + it('finds executions by script name', async () => { + const mockExecutions = [ + { id: 'exec-1', scriptName: 'test', status: 'COMPLETED' }, + { id: 'exec-2', scriptName: 'test', status: 'FAILED' }, + ]; + + mockExecutionRepo.findExecutionsByScriptName.mockResolvedValue( + mockExecutions + ); + + const result = await commands.findScriptExecutionsByName('test'); + + expect(mockExecutionRepo.findExecutionsByScriptName).toHaveBeenCalledWith( + 'test', + {} + ); + expect(result).toEqual(mockExecutions); + }); + + it('passes options to repository', async () => { + mockExecutionRepo.findExecutionsByScriptName.mockResolvedValue([]); + + await commands.findScriptExecutionsByName('test', { + limit: 10, + offset: 5, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + + expect(mockExecutionRepo.findExecutionsByScriptName).toHaveBeenCalledWith( + 'test', + { + limit: 10, + offset: 5, + sortBy: 'createdAt', + sortOrder: 'desc', + } + ); + }); + + it('returns empty array on error', async () => { + mockExecutionRepo.findExecutionsByScriptName.mockRejectedValue( + new Error('DB error') + ); + + const result = await commands.findScriptExecutionsByName('test'); + + expect(result).toEqual([]); + }); + }); + + describe('updateScriptExecutionStatus', () => { + it('updates status correctly', async () => { + const mockUpdated = { + id: 'exec-1', + status: 'RUNNING', + }; + + mockExecutionRepo.updateExecutionStatus.mockResolvedValue(mockUpdated); + + const result = await commands.updateScriptExecutionStatus( + 'exec-1', + 'RUNNING' + ); + + expect(mockExecutionRepo.updateExecutionStatus).toHaveBeenCalledWith( + 'exec-1', + 'RUNNING' + ); + expect(result).toEqual(mockUpdated); + }); + + it('handles all status values', async () => { + const statuses = [ + 'PENDING', + 'RUNNING', + 'COMPLETED', + 'FAILED', + 'TIMEOUT', + 'CANCELLED', + ]; + + for (const status of statuses) { + mockExecutionRepo.updateExecutionStatus.mockResolvedValue({ + id: 'exec-1', + status, + }); + + const result = await commands.updateScriptExecutionStatus( + 'exec-1', + status + ); + + expect(result.status).toBe(status); + } + }); + }); + + describe('appendScriptExecutionLog', () => { + it('appends log entry to logs array', async () => { + const logEntry = { + level: 'info', + message: 'Test log', + data: { detail: 'test' }, + timestamp: new Date().toISOString(), + }; + + const mockUpdated = { + id: 'exec-1', + logs: [logEntry], + }; + + mockExecutionRepo.appendExecutionLog.mockResolvedValue(mockUpdated); + + const result = await commands.appendScriptExecutionLog('exec-1', logEntry); + + expect(mockExecutionRepo.appendExecutionLog).toHaveBeenCalledWith( + 'exec-1', + logEntry + ); + expect(result.logs).toContain(logEntry); + }); + + it('handles different log levels', async () => { + const levels = ['debug', 'info', 'warn', 'error']; + + for (const level of levels) { + const logEntry = { + level, + message: `${level} message`, + timestamp: new Date().toISOString(), + }; + + mockExecutionRepo.appendExecutionLog.mockResolvedValue({ + id: 'exec-1', + logs: [logEntry], + }); + + await commands.appendScriptExecutionLog('exec-1', logEntry); + + expect(mockExecutionRepo.appendExecutionLog).toHaveBeenCalledWith( + 'exec-1', + expect.objectContaining({ level }) + ); + } + }); + }); + + describe('completeScriptExecution', () => { + it('updates status, output, error, and metrics', async () => { + mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); + mockExecutionRepo.updateExecutionOutput.mockResolvedValue({}); + mockExecutionRepo.updateExecutionError.mockResolvedValue({}); + mockExecutionRepo.updateExecutionMetrics.mockResolvedValue({}); + + const result = await commands.completeScriptExecution('exec-1', { + status: 'COMPLETED', + output: { result: 'success' }, + error: null, + metrics: { + startTime: new Date(), + endTime: new Date(), + durationMs: 1234, + }, + }); + + expect(mockExecutionRepo.updateExecutionStatus).toHaveBeenCalledWith( + 'exec-1', + 'COMPLETED' + ); + expect(mockExecutionRepo.updateExecutionOutput).toHaveBeenCalledWith( + 'exec-1', + { result: 'success' } + ); + expect(mockExecutionRepo.updateExecutionMetrics).toHaveBeenCalledWith( + 'exec-1', + expect.objectContaining({ durationMs: 1234 }) + ); + expect(result).toEqual({ success: true }); + }); + + it('handles partial updates', async () => { + mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); + + await commands.completeScriptExecution('exec-1', { + status: 'FAILED', + // No output, error, or metrics + }); + + expect(mockExecutionRepo.updateExecutionStatus).toHaveBeenCalled(); + expect(mockExecutionRepo.updateExecutionOutput).not.toHaveBeenCalled(); + expect(mockExecutionRepo.updateExecutionError).not.toHaveBeenCalled(); + expect(mockExecutionRepo.updateExecutionMetrics).not.toHaveBeenCalled(); + }); + + it('updates error details on failure', async () => { + mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); + mockExecutionRepo.updateExecutionError.mockResolvedValue({}); + + await commands.completeScriptExecution('exec-1', { + status: 'FAILED', + error: { + name: 'ValidationError', + message: 'Invalid input', + stack: 'Error: ...\n at ...', + }, + }); + + expect(mockExecutionRepo.updateExecutionError).toHaveBeenCalledWith( + 'exec-1', + { + name: 'ValidationError', + message: 'Invalid input', + stack: 'Error: ...\n at ...', + } + ); + }); + + it('allows output to be null or undefined', async () => { + mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); + mockExecutionRepo.updateExecutionOutput.mockResolvedValue({}); + + // Test with null + await commands.completeScriptExecution('exec-1', { + status: 'COMPLETED', + output: null, + }); + + expect(mockExecutionRepo.updateExecutionOutput).toHaveBeenCalledWith( + 'exec-1', + null + ); + + jest.clearAllMocks(); + + // Test with undefined (should not call update) + await commands.completeScriptExecution('exec-2', { + status: 'COMPLETED', + // output is undefined + }); + + expect(mockExecutionRepo.updateExecutionOutput).not.toHaveBeenCalled(); + }); + }); + + describe('findRecentExecutions', () => { + it('finds executions by status', async () => { + const mockExecutions = [ + { id: 'exec-1', status: 'FAILED' }, + { id: 'exec-2', status: 'FAILED' }, + ]; + + mockExecutionRepo.findExecutionsByStatus.mockResolvedValue(mockExecutions); + + const result = await commands.findRecentExecutions({ status: 'FAILED' }); + + expect(mockExecutionRepo.findExecutionsByStatus).toHaveBeenCalledWith( + 'FAILED', + { + limit: 20, + sortBy: 'createdAt', + sortOrder: 'desc', + } + ); + expect(result).toEqual(mockExecutions); + }); + + it('uses default limit of 20', async () => { + mockExecutionRepo.findExecutionsByStatus.mockResolvedValue([]); + + await commands.findRecentExecutions({ status: 'COMPLETED' }); + + expect(mockExecutionRepo.findExecutionsByStatus).toHaveBeenCalledWith( + 'COMPLETED', + expect.objectContaining({ limit: 20 }) + ); + }); + + it('allows custom limit', async () => { + mockExecutionRepo.findExecutionsByStatus.mockResolvedValue([]); + + await commands.findRecentExecutions({ + status: 'RUNNING', + limit: 50, + }); + + expect(mockExecutionRepo.findExecutionsByStatus).toHaveBeenCalledWith( + 'RUNNING', + expect.objectContaining({ limit: 50 }) + ); + }); + + it('returns empty array if no status filter', async () => { + const result = await commands.findRecentExecutions({}); + + expect(result).toEqual([]); + expect(mockExecutionRepo.findExecutionsByStatus).not.toHaveBeenCalled(); + }); + + it('returns empty array on error', async () => { + mockExecutionRepo.findExecutionsByStatus.mockRejectedValue( + new Error('DB error') + ); + + const result = await commands.findRecentExecutions({ status: 'FAILED' }); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js new file mode 100644 index 000000000..d58e9ec73 --- /dev/null +++ b/packages/core/application/commands/admin-script-commands.js @@ -0,0 +1,341 @@ +const bcrypt = require('bcryptjs'); +const { v4: uuid } = require('uuid'); + +const ERROR_CODE_MAP = { + INVALID_API_KEY: 401, + EXPIRED_API_KEY: 401, + SCRIPT_NOT_FOUND: 404, + EXECUTION_NOT_FOUND: 404, + UNAUTHORIZED_SCOPE: 403, +}; + +function mapErrorToResponse(error) { + const status = ERROR_CODE_MAP[error?.code] || 500; + return { error: status, reason: error?.message, code: error?.code }; +} + +/** + * Create admin script commands + * Provides command pattern API for admin script management + * + * This follows the Command pattern from integration-commands.js: + * - Creates repositories via factory functions + * - Maps errors to HTTP-friendly responses + * - Returns data or error objects (never throws) + * + * @returns {Object} Command methods for admin scripts + */ +function createAdminScriptCommands() { + // Lazy-load repository factories to avoid circular dependencies + const { createAdminApiKeyRepository } = require('../../admin-scripts/repositories/admin-api-key-repository-factory'); + const { createScriptExecutionRepository } = require('../../admin-scripts/repositories/script-execution-repository-factory'); + + const apiKeyRepository = createAdminApiKeyRepository(); + const executionRepository = createScriptExecutionRepository(); + + return { + // ==================== API Key Management Commands ==================== + + /** + * Create a new admin API key + * Generates a UUID, hashes it with bcrypt, stores in database + * + * @param {Object} params - Key creation parameters + * @param {string} params.name - Human-readable name for the key + * @param {string[]} params.scopes - Permission scopes (e.g., ['scripts:execute']) + * @param {Date} [params.expiresAt] - Optional expiration date + * @param {string} [params.createdBy] - Optional creator identifier + * @returns {Promise} Created key with rawKey (only returned once!) + */ + async createAdminApiKey({ name, scopes, expiresAt, createdBy }) { + try { + // Generate raw key (UUID format) + const rawKey = uuid(); + + // Hash with bcrypt (cost factor 10) + const keyHash = await bcrypt.hash(rawKey, 10); + + // Store last 4 characters for display + const keyLast4 = rawKey.slice(-4); + + // Create via repository + const record = await apiKeyRepository.createApiKey({ + name, + keyHash, + keyLast4, + scopes, + expiresAt, + createdBy, + }); + + // Return record with rawKey (ONLY TIME IT'S RETURNED!) + return { + id: record.id, + rawKey, // User must save this - we never show it again + name: record.name, + keyLast4: record.keyLast4, + scopes: record.scopes, + expiresAt: record.expiresAt, + }; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Validate an admin API key + * Compares bcrypt hash, checks expiration, updates lastUsedAt + * + * @param {string} rawKey - The raw API key to validate + * @returns {Promise} { valid: true, apiKey } or error response + */ + async validateAdminApiKey(rawKey) { + try { + // Find all active keys + const activeKeys = await apiKeyRepository.findActiveApiKeys(); + + // Compare bcrypt hash for each key + for (const key of activeKeys) { + const isMatch = await bcrypt.compare(rawKey, key.keyHash); + if (isMatch) { + // Check expiration + if (key.expiresAt && new Date(key.expiresAt) < new Date()) { + const error = new Error('API key has expired'); + error.code = 'EXPIRED_API_KEY'; + return mapErrorToResponse(error); + } + + // Update lastUsedAt on success + await apiKeyRepository.updateApiKeyLastUsed(key.id); + + return { valid: true, apiKey: key }; + } + } + + // No match found + const error = new Error('Invalid API key'); + error.code = 'INVALID_API_KEY'; + return mapErrorToResponse(error); + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * List all active admin API keys + * Returns keys without keyHash (security) + * + * @returns {Promise} Array of API key records (without keyHash) + */ + async listAdminApiKeys() { + try { + const keys = await apiKeyRepository.findActiveApiKeys(); + + // Remove keyHash from response (security) + return keys.map((key) => { + const { keyHash, ...safeKey } = key; + return safeKey; + }); + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Deactivate an admin API key + * Soft delete - sets isActive to false + * + * @param {string|number} id - The API key ID + * @returns {Promise} Updated record or error + */ + async deactivateAdminApiKey(id) { + try { + const result = await apiKeyRepository.deactivateApiKey(id); + return result; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + // ==================== Execution Management Commands ==================== + + /** + * Create a new script execution record + * + * @param {Object} params - Execution creation parameters + * @param {string} params.scriptName - Name of script being executed + * @param {string} [params.scriptVersion] - Script version + * @param {string} params.trigger - Trigger type ('MANUAL', 'SCHEDULED', 'QUEUE', 'WEBHOOK') + * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') + * @param {Object} [params.input] - Input parameters + * @param {Object} [params.audit] - Audit information (apiKeyName, apiKeyLast4, ipAddress) + * @returns {Promise} Created execution record + */ + async createScriptExecution({ + scriptName, + scriptVersion, + trigger, + mode, + input, + audit, + }) { + try { + const execution = await executionRepository.createExecution({ + scriptName, + scriptVersion, + trigger, + mode: mode || 'async', + input, + audit, + }); + return execution; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Find a script execution by ID + * + * @param {string|number} executionId - The execution ID + * @returns {Promise} Execution record or error + */ + async findScriptExecutionById(executionId) { + try { + const execution = await executionRepository.findExecutionById(executionId); + if (!execution) { + const error = new Error(`Execution ${executionId} not found`); + error.code = 'EXECUTION_NOT_FOUND'; + return mapErrorToResponse(error); + } + return execution; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Find all executions for a specific script + * + * @param {string} scriptName - Script name to filter by + * @param {Object} [options] - Query options (limit, offset, sortBy, sortOrder) + * @returns {Promise} Array of execution records + */ + async findScriptExecutionsByName(scriptName, options = {}) { + try { + const executions = await executionRepository.findExecutionsByScriptName( + scriptName, + options + ); + return executions; + } catch (error) { + // Return empty array on error (non-critical) + return []; + } + }, + + /** + * Update execution status + * + * @param {string|number} executionId - The execution ID + * @param {string} status - New status ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'TIMEOUT', 'CANCELLED') + * @returns {Promise} Updated execution record + */ + async updateScriptExecutionStatus(executionId, status) { + try { + const updated = await executionRepository.updateExecutionStatus( + executionId, + status + ); + return updated; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Append a log entry to an execution's log array + * + * @param {string|number} executionId - The execution ID + * @param {Object} logEntry - Log entry { level, message, data, timestamp } + * @returns {Promise} Updated execution record + */ + async appendScriptExecutionLog(executionId, logEntry) { + try { + const updated = await executionRepository.appendExecutionLog( + executionId, + logEntry + ); + return updated; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Complete a script execution + * Updates status, output, error, and metrics + * + * @param {string|number} executionId - The execution ID + * @param {Object} params - Completion parameters + * @param {string} [params.status] - Final status ('COMPLETED', 'FAILED', 'TIMEOUT') + * @param {Object} [params.output] - Script output/result + * @param {Object} [params.error] - Error details { name, message, stack } + * @param {Object} [params.metrics] - Performance metrics { startTime, endTime, durationMs } + * @returns {Promise} { success: true } or error + */ + async completeScriptExecution(executionId, { status, output, error, metrics }) { + try { + // Update each field independently (partial updates allowed) + if (status) { + await executionRepository.updateExecutionStatus(executionId, status); + } + if (output !== undefined) { + await executionRepository.updateExecutionOutput(executionId, output); + } + if (error) { + await executionRepository.updateExecutionError(executionId, error); + } + if (metrics) { + await executionRepository.updateExecutionMetrics(executionId, metrics); + } + + return { success: true }; + } catch (err) { + return mapErrorToResponse(err); + } + }, + + /** + * Find recent executions across all scripts + * + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum results (default 20) + * @param {string} [options.status] - Filter by status + * @param {Date} [options.since] - Filter by created date + * @returns {Promise} Array of recent executions + */ + async findRecentExecutions(options = {}) { + try { + const { limit = 20, status, since } = options; + + // If status filter provided, use status query + if (status) { + return await executionRepository.findExecutionsByStatus(status, { + limit, + sortBy: 'createdAt', + sortOrder: 'desc', + }); + } + + // Otherwise, use generic recent query (would need to be added to interface) + // For now, fall back to empty array if no status filter + return []; + } catch (error) { + return []; + } + }, + }; +} + +module.exports = { createAdminScriptCommands }; diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 02dd9bd98..5aa2ec0b4 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -360,3 +360,71 @@ model WebsocketConnection { @@index([connectionId]) @@map("WebsocketConnection") } + +// ============================================================================ +// ADMIN SCRIPT RUNNER MODELS +// ============================================================================ + +enum ScriptExecutionStatus { + PENDING + RUNNING + COMPLETED + FAILED + TIMEOUT + CANCELLED +} + +enum ScriptTrigger { + MANUAL + SCHEDULED + QUEUE + WEBHOOK +} + +/// Admin API keys for script execution authentication +/// Key hashes stored with bcrypt +model AdminApiKey { + id String @id @default(auto()) @map("_id") @db.ObjectId + keyHash String @unique // bcrypt hashed + keyLast4 String // Last 4 chars for display + name String // Human-readable name + scopes String[] // ['scripts:execute', 'scripts:read'] + expiresAt DateTime? + createdBy String? // User/admin who created + lastUsedAt DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([keyHash]) + @@index([isActive]) + @@map("AdminApiKey") +} + +/// Script execution tracking and audit log +model ScriptExecution { + id String @id @default(auto()) @map("_id") @db.ObjectId + scriptName String + scriptVersion String? + status ScriptExecutionStatus @default(PENDING) + trigger ScriptTrigger + mode String @default("async") // "sync" | "async" + input Json? + output Json? + logs Json[] // [{level, message, data, timestamp}] + metricsStartTime DateTime? + metricsEndTime DateTime? + metricsDurationMs Int? + errorName String? + errorMessage String? + errorStack String? + auditApiKeyName String? + auditApiKeyLast4 String? + auditIpAddress String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([scriptName, createdAt(sort: Desc)]) + @@index([status]) + @@map("ScriptExecution") +} diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index c8d781e98..29a5e627d 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -343,3 +343,69 @@ model WebsocketConnection { @@index([connectionId]) } + +// ============================================================================ +// ADMIN SCRIPT RUNNER MODELS +// ============================================================================ + +enum ScriptExecutionStatus { + PENDING + RUNNING + COMPLETED + FAILED + TIMEOUT + CANCELLED +} + +enum ScriptTrigger { + MANUAL + SCHEDULED + QUEUE + WEBHOOK +} + +/// Admin API keys for script execution authentication +/// Key hashes stored with bcrypt +model AdminApiKey { + id Int @id @default(autoincrement()) + keyHash String @unique + keyLast4 String + name String + scopes String[] + expiresAt DateTime? + createdBy String? + lastUsedAt DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([keyHash]) + @@index([isActive]) +} + +/// Script execution tracking and audit log +model ScriptExecution { + id Int @id @default(autoincrement()) + scriptName String + scriptVersion String? + status ScriptExecutionStatus @default(PENDING) + trigger ScriptTrigger + mode String @default("async") + input Json? + output Json? + logs Json[] + metricsStartTime DateTime? + metricsEndTime DateTime? + metricsDurationMs Int? + errorName String? + errorMessage String? + errorStack String? + auditApiKeyName String? + auditApiKeyLast4 String? + auditIpAddress String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([scriptName, createdAt(sort: Desc)]) + @@index([status]) +} diff --git a/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js new file mode 100644 index 000000000..49dc9c0c2 --- /dev/null +++ b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js @@ -0,0 +1,200 @@ +/** + * Admin Script Builder + * + * Domain Layer - Hexagonal Architecture + * + * Responsible for: + * - Creating SQS queue for admin script execution + * - Creating Lambda function for script execution (worker) + * - Creating Lambda function for admin API routes (router) + * - Creating EventBridge Scheduler resources (Phase 2) + * - Creating IAM roles for scheduler to invoke Lambda + */ + +const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder'); + +class AdminScriptBuilder extends InfrastructureBuilder { + constructor() { + super(); + this.name = 'AdminScriptBuilder'; + } + + shouldExecute(appDefinition) { + return Array.isArray(appDefinition.adminScripts) && appDefinition.adminScripts.length > 0; + } + + getDependencies() { + return []; // Can run independently + } + + validate(appDefinition) { + const result = new ValidationResult(); + + if (!appDefinition.adminScripts) { + return result; // Not an error, just no scripts + } + + if (!Array.isArray(appDefinition.adminScripts)) { + result.addError('adminScripts must be an array'); + return result; + } + + // Validate each script + appDefinition.adminScripts.forEach((script, index) => { + if (!script?.Definition?.name) { + result.addError(`Admin script at index ${index} is missing Definition or name`); + } + }); + + return result; + } + + async build(appDefinition, discoveredResources) { + console.log(`\n[${this.name}] Configuring admin scripts...`); + console.log(` Processing ${appDefinition.adminScripts.length} scripts...`); + + const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false; + const adminConfig = appDefinition.admin || {}; + + const result = { + functions: {}, + resources: {}, + environment: {}, + custom: {}, + iamStatements: [], + }; + + // Create admin script queue + this.createAdminScriptQueue(result); + + // Create Lambda function for script execution + this.createScriptExecutorFunction(result, usePrismaLayer); + + // Create API routes for script management + this.createAdminScriptRoutes(result, usePrismaLayer); + + // Phase 2: Create EventBridge Scheduler resources + if (adminConfig.enableScheduling) { + this.createSchedulerResources(appDefinition, result); + } + + // Log registered scripts + appDefinition.adminScripts.forEach(script => { + const name = script.Definition?.name || 'unknown'; + const schedule = script.Definition?.schedule; + console.log(` ✓ Registered: ${name}${schedule?.enabled ? ' (scheduled)' : ''}`); + }); + + console.log(`[${this.name}] ✅ Admin script configuration completed`); + return result; + } + + createAdminScriptQueue(result) { + result.resources.AdminScriptQueue = { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: '${self:service}-${self:provider.stage}-AdminScriptQueue', + MessageRetentionPeriod: 86400, // 1 day + VisibilityTimeout: 900, // 15 minutes (Lambda max) + RedrivePolicy: { + maxReceiveCount: 3, + deadLetterTargetArn: { + 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'], + }, + }, + }, + }; + + result.environment.ADMIN_SCRIPT_QUEUE_URL = { Ref: 'AdminScriptQueue' }; + console.log(' ✓ Created AdminScriptQueue'); + } + + createScriptExecutorFunction(result, usePrismaLayer) { + result.functions.adminScriptExecutor = { + handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/script-executor-handler.handler', + skipEsbuild: true, + ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), + timeout: 900, // 15 minutes max + memorySize: 1024, + events: [ + { + sqs: { + arn: { 'Fn::GetAtt': ['AdminScriptQueue', 'Arn'] }, + batchSize: 1, + }, + }, + ], + }; + console.log(' ✓ Created adminScriptExecutor function'); + } + + createAdminScriptRoutes(result, usePrismaLayer) { + result.functions.adminScriptRouter = { + handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/admin-script-router.handler', + skipEsbuild: true, + ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), + timeout: 30, + events: [ + // List scripts + { httpApi: { path: '/admin/scripts', method: 'GET' } }, + // Get script details + { httpApi: { path: '/admin/scripts/{scriptName}', method: 'GET' } }, + // Execute script (sync or async) + { httpApi: { path: '/admin/scripts/{scriptName}/execute', method: 'POST' } }, + // Get execution status + { httpApi: { path: '/admin/executions/{executionId}', method: 'GET' } }, + // List executions + { httpApi: { path: '/admin/executions', method: 'GET' } }, + // Schedule management (Phase 2) + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'GET' } }, + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'PUT' } }, + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'DELETE' } }, + ], + }; + console.log(' ✓ Created adminScriptRouter function'); + } + + createSchedulerResources(appDefinition, result) { + // Create IAM role for EventBridge Scheduler + result.resources.AdminScriptSchedulerRole = { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: '${self:service}-${self:provider.stage}-admin-script-scheduler', + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Principal: { Service: 'scheduler.amazonaws.com' }, + Action: 'sts:AssumeRole', + }], + }, + Policies: [{ + PolicyName: 'InvokeLambda', + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Effect: 'Allow', + Action: 'lambda:InvokeFunction', + Resource: { 'Fn::GetAtt': ['AdminScriptExecutorLambdaFunction', 'Arn'] }, + }], + }, + }], + }, + }; + + // Create schedule group + result.resources.AdminScriptScheduleGroup = { + Type: 'AWS::Scheduler::ScheduleGroup', + Properties: { + Name: '${self:service}-${self:provider.stage}-admin-scripts', + }, + }; + + result.environment.SCHEDULER_ROLE_ARN = { 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'] }; + result.environment.SCHEDULE_GROUP_NAME = { Ref: 'AdminScriptScheduleGroup' }; + + console.log(' ✓ Created EventBridge Scheduler resources'); + } +} + +module.exports = { AdminScriptBuilder }; diff --git a/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js new file mode 100644 index 000000000..f4a302ddf --- /dev/null +++ b/packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js @@ -0,0 +1,499 @@ +/** + * Tests for Admin Script Builder + * + * Tests admin script infrastructure generation including: + * - SQS queue for script execution + * - Lambda executor function + * - Lambda router function with HTTP routes + * - EventBridge Scheduler resources (optional) + */ + +const { AdminScriptBuilder } = require('./admin-script-builder'); +const { ValidationResult } = require('../shared/base-builder'); + +describe('AdminScriptBuilder', () => { + let adminScriptBuilder; + + beforeEach(() => { + adminScriptBuilder = new AdminScriptBuilder(); + }); + + describe('shouldExecute()', () => { + it('should return false when no adminScripts', () => { + const appDefinition = {}; + + expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(false); + }); + + it('should return false when adminScripts is empty array', () => { + const appDefinition = { + adminScripts: [], + }; + + expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(false); + }); + + it('should return true when adminScripts has items', () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(true); + }); + + it('should return false when adminScripts is not an array', () => { + const appDefinition = { + adminScripts: { name: 'test' }, + }; + + expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(false); + }); + }); + + describe('getDependencies()', () => { + it('should have no dependencies', () => { + const deps = adminScriptBuilder.getDependencies(); + + expect(deps).toEqual([]); + }); + }); + + describe('validate()', () => { + it('should pass validation with valid adminScripts', () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'oauth-refresh' } }, + { Definition: { name: 'health-check' } }, + ], + }; + + const result = adminScriptBuilder.validate(appDefinition); + + expect(result).toBeInstanceOf(ValidationResult); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should pass when adminScripts is undefined', () => { + const appDefinition = {}; + + const result = adminScriptBuilder.validate(appDefinition); + + expect(result.valid).toBe(true); + }); + + it('should fail when adminScripts is not an array', () => { + const appDefinition = { + adminScripts: 'invalid', + }; + + const result = adminScriptBuilder.validate(appDefinition); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('adminScripts must be an array'); + }); + + it('should fail when script missing Definition.name', () => { + const appDefinition = { + adminScripts: [ + { Definition: {} }, + ], + }; + + const result = adminScriptBuilder.validate(appDefinition); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Admin script at index 0 is missing Definition or name' + ); + }); + + it('should fail when script missing Definition', () => { + const appDefinition = { + adminScripts: [ + { someOtherField: 'value' }, + ], + }; + + const result = adminScriptBuilder.validate(appDefinition); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + 'Admin script at index 0 is missing Definition or name' + ); + }); + + it('should validate all scripts', () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'valid' } }, + { Definition: {} }, // Invalid - no name + { someField: 'value' }, // Invalid - no Definition + ], + }; + + const result = adminScriptBuilder.validate(appDefinition); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + }); + }); + + describe('build()', () => { + it('should create AdminScriptQueue resource', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.resources.AdminScriptQueue).toBeDefined(); + expect(result.resources.AdminScriptQueue.Type).toBe('AWS::SQS::Queue'); + }); + + it('should configure AdminScriptQueue with correct retention and timeout', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.resources.AdminScriptQueue.Properties.MessageRetentionPeriod).toBe(86400); // 1 day + expect(result.resources.AdminScriptQueue.Properties.VisibilityTimeout).toBe(900); // 15 minutes + }); + + it('should configure AdminScriptQueue redrive policy to InternalErrorQueue', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.resources.AdminScriptQueue.Properties.RedrivePolicy).toEqual({ + maxReceiveCount: 3, + deadLetterTargetArn: { + 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'], + }, + }); + }); + + it('should add ADMIN_SCRIPT_QUEUE_URL to environment variables', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toEqual({ + Ref: 'AdminScriptQueue', + }); + }); + + it('should create adminScriptExecutor function', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptExecutor).toBeDefined(); + expect(result.functions.adminScriptExecutor.handler).toBe( + 'node_modules/@friggframework/admin-scripts/src/infrastructure/script-executor-handler.handler' + ); + }); + + it('should configure adminScriptExecutor with SQS event', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptExecutor.events).toEqual([ + { + sqs: { + arn: { 'Fn::GetAtt': ['AdminScriptQueue', 'Arn'] }, + batchSize: 1, + }, + }, + ]); + }); + + it('should set adminScriptExecutor timeout to 900 seconds', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptExecutor.timeout).toBe(900); // 15 minutes (Lambda max) + }); + + it('should set adminScriptExecutor memory size', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptExecutor.memorySize).toBe(1024); + }); + + it('should attach Prisma layer to adminScriptExecutor', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptExecutor.layers).toEqual([ + { Ref: 'PrismaLambdaLayer' } + ]); + }); + + it('should create adminScriptRouter function', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptRouter).toBeDefined(); + expect(result.functions.adminScriptRouter.handler).toBe( + 'node_modules/@friggframework/admin-scripts/src/infrastructure/admin-script-router.handler' + ); + }); + + it('should configure adminScriptRouter with correct HTTP routes', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptRouter.events).toEqual([ + // List scripts + { httpApi: { path: '/admin/scripts', method: 'GET' } }, + // Get script details + { httpApi: { path: '/admin/scripts/{scriptName}', method: 'GET' } }, + // Execute script (sync or async) + { httpApi: { path: '/admin/scripts/{scriptName}/execute', method: 'POST' } }, + // Get execution status + { httpApi: { path: '/admin/executions/{executionId}', method: 'GET' } }, + // List executions + { httpApi: { path: '/admin/executions', method: 'GET' } }, + // Schedule management (Phase 2) + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'GET' } }, + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'PUT' } }, + { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'DELETE' } }, + ]); + }); + + it('should set adminScriptRouter timeout to 30 seconds', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptRouter.timeout).toBe(30); + }); + + it('should attach Prisma layer to adminScriptRouter', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptRouter.layers).toEqual([ + { Ref: 'PrismaLambdaLayer' } + ]); + }); + + it('should create scheduler resources when admin.enableScheduling is true', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + admin: { + enableScheduling: true, + }, + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + // Check for scheduler IAM role + expect(result.resources.AdminScriptSchedulerRole).toBeDefined(); + expect(result.resources.AdminScriptSchedulerRole.Type).toBe('AWS::IAM::Role'); + + // Check for schedule group + expect(result.resources.AdminScriptScheduleGroup).toBeDefined(); + expect(result.resources.AdminScriptScheduleGroup.Type).toBe('AWS::Scheduler::ScheduleGroup'); + + // Check for environment variables + expect(result.environment.SCHEDULER_ROLE_ARN).toEqual({ + 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'], + }); + expect(result.environment.SCHEDULE_GROUP_NAME).toEqual({ + Ref: 'AdminScriptScheduleGroup', + }); + }); + + it('should not create scheduler resources when enableScheduling is false', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + admin: { + enableScheduling: false, + }, + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.resources.AdminScriptSchedulerRole).toBeUndefined(); + expect(result.resources.AdminScriptScheduleGroup).toBeUndefined(); + expect(result.environment.SCHEDULER_ROLE_ARN).toBeUndefined(); + expect(result.environment.SCHEDULE_GROUP_NAME).toBeUndefined(); + }); + + it('should not create scheduler resources when admin config is not provided', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.resources.AdminScriptSchedulerRole).toBeUndefined(); + expect(result.resources.AdminScriptScheduleGroup).toBeUndefined(); + }); + + it('should use skipEsbuild for all functions', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptExecutor.skipEsbuild).toBe(true); + expect(result.functions.adminScriptRouter.skipEsbuild).toBe(true); + }); + + it('should not attach Prisma layer when usePrismaLambdaLayer=false', async () => { + const appDefinition = { + usePrismaLambdaLayer: false, + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + expect(result.functions.adminScriptExecutor.layers).toBeUndefined(); + expect(result.functions.adminScriptRouter.layers).toBeUndefined(); + }); + + it('should handle multiple admin scripts', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'oauth-refresh' } }, + { Definition: { name: 'health-check' } }, + { Definition: { name: 'attio-healing' } }, + ], + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + // Should still only create one queue and two functions + expect(result.resources.AdminScriptQueue).toBeDefined(); + expect(result.functions.adminScriptExecutor).toBeDefined(); + expect(result.functions.adminScriptRouter).toBeDefined(); + + // Should not create separate resources per script + expect(Object.keys(result.resources)).toHaveLength(1); // Only AdminScriptQueue + expect(Object.keys(result.functions)).toHaveLength(2); // Only executor and router + }); + + it('should configure scheduler role with correct trust policy', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + admin: { + enableScheduling: true, + }, + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + const trustPolicy = result.resources.AdminScriptSchedulerRole.Properties.AssumeRolePolicyDocument; + + expect(trustPolicy.Statement[0]).toEqual({ + Effect: 'Allow', + Principal: { Service: 'scheduler.amazonaws.com' }, + Action: 'sts:AssumeRole', + }); + }); + + it('should configure scheduler role with Lambda invoke permission', async () => { + const appDefinition = { + adminScripts: [ + { Definition: { name: 'test-script' } }, + ], + admin: { + enableScheduling: true, + }, + }; + + const result = await adminScriptBuilder.build(appDefinition, {}); + + const policies = result.resources.AdminScriptSchedulerRole.Properties.Policies; + + expect(policies[0].PolicyName).toBe('InvokeLambda'); + expect(policies[0].PolicyDocument.Statement[0]).toEqual({ + Effect: 'Allow', + Action: 'lambda:InvokeFunction', + Resource: { 'Fn::GetAtt': ['AdminScriptExecutorLambdaFunction', 'Arn'] }, + }); + }); + }); + + describe('getName()', () => { + it('should return AdminScriptBuilder', () => { + expect(adminScriptBuilder.getName()).toBe('AdminScriptBuilder'); + }); + }); +}); diff --git a/packages/devtools/infrastructure/domains/admin-scripts/index.js b/packages/devtools/infrastructure/domains/admin-scripts/index.js new file mode 100644 index 000000000..eee186fbb --- /dev/null +++ b/packages/devtools/infrastructure/domains/admin-scripts/index.js @@ -0,0 +1,5 @@ +const { AdminScriptBuilder } = require('./admin-script-builder'); + +module.exports = { + AdminScriptBuilder +}; diff --git a/packages/devtools/infrastructure/domains/shared/types/app-definition.js b/packages/devtools/infrastructure/domains/shared/types/app-definition.js index 0b9e90076..566aa6001 100644 --- a/packages/devtools/infrastructure/domains/shared/types/app-definition.js +++ b/packages/devtools/infrastructure/domains/shared/types/app-definition.js @@ -106,6 +106,25 @@ * @property {string} Definition.name - Integration name */ +/** + * Admin script definition + * @typedef {Object} AdminScriptDefinition + * @property {Object} Definition - Static definition from script class + * @property {string} Definition.name - Script name identifier + * @property {string} Definition.version - Script version (semver) + * @property {string} [Definition.description] - Human-readable description + * @property {Object} [Definition.schedule] - Schedule configuration + * @property {boolean} [Definition.schedule.enabled] - Whether scheduling is enabled + * @property {string} [Definition.schedule.cronExpression] - Cron expression + */ + +/** + * Admin configuration + * @typedef {Object} AdminConfig + * @property {boolean} [includeBuiltinScripts] - Whether to include built-in scripts + * @property {boolean} [enableScheduling] - Whether to enable EventBridge scheduling + */ + /** * Complete application definition * @typedef {Object} AppDefinition @@ -122,6 +141,8 @@ * @property {MigrationDefinition} [migrations] - Database migration configuration * @property {WebsocketDefinition} [websockets] - WebSocket API configuration * @property {IntegrationDefinition[]} [integrations] - Integration definitions + * @property {AdminScriptDefinition[]} [adminScripts] - Admin script definitions + * @property {AdminConfig} [admin] - Admin configuration * * @property {Object} [environment] - Environment variables */ diff --git a/packages/devtools/infrastructure/infrastructure-composer.js b/packages/devtools/infrastructure/infrastructure-composer.js index 3f367ba6c..2b31a0b6e 100644 --- a/packages/devtools/infrastructure/infrastructure-composer.js +++ b/packages/devtools/infrastructure/infrastructure-composer.js @@ -17,6 +17,7 @@ const { SsmBuilder } = require('./domains/parameters/ssm-builder'); const { WebsocketBuilder } = require('./domains/integration/websocket-builder'); const { IntegrationBuilder } = require('./domains/integration/integration-builder'); const { SchedulerBuilder } = require('./domains/scheduler/scheduler-builder'); +const { AdminScriptBuilder } = require('./domains/admin-scripts/admin-script-builder'); // Utilities const { modifyHandlerPaths } = require('./domains/shared/utilities/handler-path-resolver'); @@ -53,6 +54,7 @@ const composeServerlessDefinition = async (AppDefinition) => { new WebsocketBuilder(), new IntegrationBuilder(), new SchedulerBuilder(), // Add scheduler after IntegrationBuilder (depends on it) + new AdminScriptBuilder(), ]); // Build all infrastructure (orchestrator handles validation, dependencies, parallel execution) From 4e6f9a25ce7b78988e50cd1dc1d7c2bc7b18fd21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 19:33:05 +0000 Subject: [PATCH 07/33] feat(admin-scripts): add application layer and infrastructure handlers Application Layer: - AdminScriptBase: Base class for all admin scripts with Definition pattern - ScriptFactory: Registry for script registration and instantiation - AdminFriggCommands: Helper API for scripts (db access, queue, logging) - ScriptRunner: Orchestrates script execution with error handling Infrastructure Layer: - admin-auth-middleware: Bearer token authentication for admin API keys - admin-script-router: Express router with 5 endpoints for script management - script-executor-handler: SQS worker Lambda for async execution Features: - Sync and async execution modes - Self-queuing pattern via QueuerUtil for long-running scripts - Audit trail (API key, IP address) - Automatic log persistence to execution records Test Coverage: 110 tests passing --- package-lock.json | 69 +- packages/admin-scripts/index.js | 121 ++-- packages/admin-scripts/package.json | 5 +- .../__tests__/admin-frigg-commands.test.js | 643 ++++++++++++++++++ .../__tests__/admin-script-base.test.js | 273 ++++++++ .../__tests__/script-factory.test.js | 381 +++++++++++ .../__tests__/script-runner.test.js | 202 ++++++ .../src/application/admin-frigg-commands.js | 242 +++++++ .../src/application/admin-script-base.js | 138 ++++ .../src/application/script-factory.js | 161 +++++ .../src/application/script-runner.js | 142 ++++ .../__tests__/admin-auth-middleware.test.js | 148 ++++ .../__tests__/admin-script-router.test.js | 277 ++++++++ .../infrastructure/admin-auth-middleware.js | 49 ++ .../src/infrastructure/admin-script-router.js | 191 ++++++ .../infrastructure/script-executor-handler.js | 75 ++ 16 files changed, 3050 insertions(+), 67 deletions(-) create mode 100644 packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js create mode 100644 packages/admin-scripts/src/application/__tests__/admin-script-base.test.js create mode 100644 packages/admin-scripts/src/application/__tests__/script-factory.test.js create mode 100644 packages/admin-scripts/src/application/__tests__/script-runner.test.js create mode 100644 packages/admin-scripts/src/application/admin-frigg-commands.js create mode 100644 packages/admin-scripts/src/application/admin-script-base.js create mode 100644 packages/admin-scripts/src/application/script-factory.js create mode 100644 packages/admin-scripts/src/application/script-runner.js create mode 100644 packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js create mode 100644 packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js create mode 100644 packages/admin-scripts/src/infrastructure/admin-auth-middleware.js create mode 100644 packages/admin-scripts/src/infrastructure/admin-script-router.js create mode 100644 packages/admin-scripts/src/infrastructure/script-executor-handler.js diff --git a/package-lock.json b/package-lock.json index a7e71c18b..2b099ed1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37179,6 +37179,72 @@ "node": ">=4.0.0" } }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/supertest/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest/node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -39697,7 +39763,8 @@ "eslint": "^8.22.0", "jest": "^29.7.0", "prettier": "^2.7.1", - "sinon": "^16.1.1" + "sinon": "^16.1.1", + "supertest": "^7.1.4" } }, "packages/admin-scripts/node_modules/uuid": { diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index b6ad5ab5a..ef8fa3725 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -5,79 +5,72 @@ * in hosted environments with VPC/KMS secured database connections. */ -// Domain Models -const { AdminApiKey } = require('./src/domain/admin-api-key'); -const { ScriptExecution } = require('./src/domain/script-execution'); -const { ScheduleSpec } = require('./src/domain/schedule-spec'); +// Domain Models (TODO: implement these) +// const { AdminApiKey } = require('./src/domain/admin-api-key'); +// const { ScriptExecution } = require('./src/domain/script-execution'); +// const { ScheduleSpec } = require('./src/domain/schedule-spec'); // Application Services -const { ScriptFactory } = require('./src/application/script-factory'); -const { ScriptContext } = require('./src/application/script-context'); -const { FriggCommands } = require('./src/application/frigg-commands'); -const { ScriptRunner } = require('./src/application/script-runner'); +const { ScriptFactory, getScriptFactory, createScriptFactory } = require('./src/application/script-factory'); +const { AdminScriptBase } = require('./src/application/admin-script-base'); +const { AdminFriggCommands, createAdminFriggCommands } = require('./src/application/admin-frigg-commands'); +const { ScriptRunner, createScriptRunner } = require('./src/application/script-runner'); // Infrastructure -const { createAdminScriptRouter } = require('./src/infrastructure/admin-script-router'); -const { createScriptHandler } = require('./src/infrastructure/create-script-handler'); -const { ScriptQueueWorker } = require('./src/infrastructure/script-queue-worker'); -const { requireAdminApiKey } = require('./src/infrastructure/admin-auth-middleware'); +const { adminAuthMiddleware } = require('./src/infrastructure/admin-auth-middleware'); +const { router, app, handler: routerHandler } = require('./src/infrastructure/admin-script-router'); +const { handler: executorHandler } = require('./src/infrastructure/script-executor-handler'); -// Built-in Scripts -const builtinScripts = require('./src/builtins'); +// Built-in Scripts (TODO: implement these) +// const builtinScripts = require('./src/builtins'); -// Factory function for creating the admin backend -function createAdminBackend(params) { - const { - scripts = [], - integrationFactory, - options = {} - } = params; - - // Merge user scripts with builtins if enabled - const allScripts = options.includeBuiltins !== false - ? [...builtinScripts, ...scripts] - : scripts; - - const scriptFactory = new ScriptFactory(allScripts); - - return { - scriptFactory, - integrationFactory, - createRouter: (routerOptions = {}) => createAdminScriptRouter({ - scriptFactory, - integrationFactory, - ...routerOptions - }), - createHandler: (handlerOptions = {}) => createScriptHandler({ - scriptFactory, - integrationFactory, - ...handlerOptions - }), - createWorker: () => new ScriptQueueWorker(scriptFactory, integrationFactory) - }; -} +// Factory function for creating the admin backend (TODO: implement when infrastructure is ready) +// function createAdminBackend(params) { +// const { +// scripts = [], +// integrationFactory, +// options = {} +// } = params; +// +// // Merge user scripts with builtins if enabled +// const allScripts = options.includeBuiltins !== false +// ? [...builtinScripts, ...scripts] +// : scripts; +// +// const scriptFactory = new ScriptFactory(allScripts); +// +// return { +// scriptFactory, +// integrationFactory, +// createRouter: (routerOptions = {}) => createAdminScriptRouter({ +// scriptFactory, +// integrationFactory, +// ...routerOptions +// }), +// createHandler: (handlerOptions = {}) => createScriptHandler({ +// scriptFactory, +// integrationFactory, +// ...handlerOptions +// }), +// createWorker: () => new ScriptQueueWorker(scriptFactory, integrationFactory) +// }; +// } module.exports = { - // Main factory - createAdminBackend, - - // Domain - AdminApiKey, - ScriptExecution, - ScheduleSpec, - - // Application + // Application layer + AdminScriptBase, ScriptFactory, - ScriptContext, - FriggCommands, + getScriptFactory, + createScriptFactory, + AdminFriggCommands, + createAdminFriggCommands, ScriptRunner, + createScriptRunner, - // Infrastructure - createAdminScriptRouter, - createScriptHandler, - ScriptQueueWorker, - requireAdminApiKey, - - // Built-ins - builtinScripts + // Infrastructure layer + adminAuthMiddleware, + router, + app, + routerHandler, + executorHandler, }; diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json index 66965873d..9f389b54b 100644 --- a/packages/admin-scripts/package.json +++ b/packages/admin-scripts/package.json @@ -4,8 +4,8 @@ "version": "2.0.0-next.0", "description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments", "dependencies": { - "@friggframework/core": "^2.0.0-next.0", "@aws-sdk/client-scheduler": "^3.588.0", + "@friggframework/core": "^2.0.0-next.0", "bcryptjs": "^2.4.3", "lodash": "4.17.21", "mongoose": "6.11.6", @@ -19,7 +19,8 @@ "eslint": "^8.22.0", "jest": "^29.7.0", "prettier": "^2.7.1", - "sinon": "^16.1.1" + "sinon": "^16.1.1", + "supertest": "^7.1.4" }, "scripts": { "lint:fix": "prettier --write --loglevel error . && eslint . --fix", diff --git a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js new file mode 100644 index 000000000..c8966fadb --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js @@ -0,0 +1,643 @@ +const { AdminFriggCommands, createAdminFriggCommands } = require('../admin-frigg-commands'); + +// Mock all repository factories +jest.mock('@friggframework/core/integrations/repositories/integration-repository-factory'); +jest.mock('@friggframework/core/user/repositories/user-repository-factory'); +jest.mock('@friggframework/core/modules/repositories/module-repository-factory'); +jest.mock('@friggframework/core/credential/repositories/credential-repository-factory'); +jest.mock('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); +jest.mock('@friggframework/core/queues'); + +describe('AdminFriggCommands', () => { + let mockIntegrationRepo; + let mockUserRepo; + let mockModuleRepo; + let mockCredentialRepo; + let mockScriptExecutionRepo; + let mockQueuerUtil; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Create mock repositories + mockIntegrationRepo = { + findIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + findIntegrationsByUserId: jest.fn(), + updateIntegrationConfig: jest.fn(), + updateIntegrationStatus: jest.fn(), + }; + + mockUserRepo = { + findIndividualUserById: jest.fn(), + findIndividualUserByAppUserId: jest.fn(), + findIndividualUserByUsername: jest.fn(), + }; + + mockModuleRepo = { + findEntity: jest.fn(), + findEntityById: jest.fn(), + findEntitiesByUserId: jest.fn(), + }; + + mockCredentialRepo = { + findCredential: jest.fn(), + updateCredential: jest.fn(), + }; + + mockScriptExecutionRepo = { + appendExecutionLog: jest.fn().mockResolvedValue(undefined), + }; + + mockQueuerUtil = { + send: jest.fn().mockResolvedValue(undefined), + batchSend: jest.fn().mockResolvedValue(undefined), + }; + + // Mock factory functions + const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); + const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); + const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); + const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); + const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); + const { QueuerUtil } = require('@friggframework/core/queues'); + + createIntegrationRepository.mockReturnValue(mockIntegrationRepo); + createUserRepository.mockReturnValue(mockUserRepo); + createModuleRepository.mockReturnValue(mockModuleRepo); + createCredentialRepository.mockReturnValue(mockCredentialRepo); + createScriptExecutionRepository.mockReturnValue(mockScriptExecutionRepo); + + // Mock QueuerUtil methods + QueuerUtil.send = mockQueuerUtil.send; + QueuerUtil.batchSend = mockQueuerUtil.batchSend; + }); + + describe('Constructor', () => { + it('creates with executionId', () => { + const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + + expect(commands.executionId).toBe('exec_123'); + expect(commands.logs).toEqual([]); + expect(commands.integrationFactory).toBeNull(); + }); + + it('creates with integrationFactory', () => { + const mockFactory = { getInstanceFromIntegrationId: jest.fn() }; + const commands = new AdminFriggCommands({ integrationFactory: mockFactory }); + + expect(commands.integrationFactory).toBe(mockFactory); + }); + + it('creates without params (defaults)', () => { + const commands = new AdminFriggCommands(); + + expect(commands.executionId).toBeNull(); + expect(commands.logs).toEqual([]); + expect(commands.integrationFactory).toBeNull(); + }); + }); + + describe('Lazy Repository Loading', () => { + it('creates integrationRepository on first access', () => { + const commands = new AdminFriggCommands(); + const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); + + expect(createIntegrationRepository).not.toHaveBeenCalled(); + + const repo = commands.integrationRepository; + + expect(createIntegrationRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockIntegrationRepo); + }); + + it('returns same instance on subsequent access', () => { + const commands = new AdminFriggCommands(); + + const repo1 = commands.integrationRepository; + const repo2 = commands.integrationRepository; + + expect(repo1).toBe(repo2); + expect(repo1).toBe(mockIntegrationRepo); + }); + + it('creates userRepository on first access', () => { + const commands = new AdminFriggCommands(); + const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); + + expect(createUserRepository).not.toHaveBeenCalled(); + + const repo = commands.userRepository; + + expect(createUserRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockUserRepo); + }); + + it('creates moduleRepository on first access', () => { + const commands = new AdminFriggCommands(); + const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); + + expect(createModuleRepository).not.toHaveBeenCalled(); + + const repo = commands.moduleRepository; + + expect(createModuleRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockModuleRepo); + }); + + it('creates credentialRepository on first access', () => { + const commands = new AdminFriggCommands(); + const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); + + expect(createCredentialRepository).not.toHaveBeenCalled(); + + const repo = commands.credentialRepository; + + expect(createCredentialRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockCredentialRepo); + }); + + it('creates scriptExecutionRepository on first access', () => { + const commands = new AdminFriggCommands(); + const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); + + expect(createScriptExecutionRepository).not.toHaveBeenCalled(); + + const repo = commands.scriptExecutionRepository; + + expect(createScriptExecutionRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockScriptExecutionRepo); + }); + }); + + describe('Integration Queries', () => { + it('listIntegrations with userId filter calls findIntegrationsByUserId', async () => { + const commands = new AdminFriggCommands(); + const mockIntegrations = [{ id: '1' }, { id: '2' }]; + mockIntegrationRepo.findIntegrationsByUserId.mockResolvedValue(mockIntegrations); + + const result = await commands.listIntegrations({ userId: 'user_123' }); + + expect(result).toEqual(mockIntegrations); + expect(mockIntegrationRepo.findIntegrationsByUserId).toHaveBeenCalledWith('user_123'); + }); + + it('listIntegrations without userId calls findIntegrations', async () => { + const commands = new AdminFriggCommands(); + const mockIntegrations = [{ id: '1' }]; + mockIntegrationRepo.findIntegrations.mockResolvedValue(mockIntegrations); + + const result = await commands.listIntegrations({ status: 'active' }); + + expect(result).toEqual(mockIntegrations); + expect(mockIntegrationRepo.findIntegrations).toHaveBeenCalledWith({ status: 'active' }); + }); + + it('findIntegrationById calls repository', async () => { + const commands = new AdminFriggCommands(); + const mockIntegration = { id: 'int_123', name: 'Test' }; + mockIntegrationRepo.findIntegrationById.mockResolvedValue(mockIntegration); + + const result = await commands.findIntegrationById('int_123'); + + expect(result).toEqual(mockIntegration); + expect(mockIntegrationRepo.findIntegrationById).toHaveBeenCalledWith('int_123'); + }); + + it('findIntegrationsByUserId calls repository', async () => { + const commands = new AdminFriggCommands(); + const mockIntegrations = [{ id: '1' }, { id: '2' }]; + mockIntegrationRepo.findIntegrationsByUserId.mockResolvedValue(mockIntegrations); + + const result = await commands.findIntegrationsByUserId('user_123'); + + expect(result).toEqual(mockIntegrations); + expect(mockIntegrationRepo.findIntegrationsByUserId).toHaveBeenCalledWith('user_123'); + }); + + it('updateIntegrationConfig calls repository', async () => { + const commands = new AdminFriggCommands(); + const newConfig = { setting: 'value' }; + const updatedIntegration = { id: 'int_123', config: newConfig }; + mockIntegrationRepo.updateIntegrationConfig.mockResolvedValue(updatedIntegration); + + const result = await commands.updateIntegrationConfig('int_123', newConfig); + + expect(result).toEqual(updatedIntegration); + expect(mockIntegrationRepo.updateIntegrationConfig).toHaveBeenCalledWith('int_123', newConfig); + }); + + it('updateIntegrationStatus calls repository', async () => { + const commands = new AdminFriggCommands(); + const updatedIntegration = { id: 'int_123', status: 'active' }; + mockIntegrationRepo.updateIntegrationStatus.mockResolvedValue(updatedIntegration); + + const result = await commands.updateIntegrationStatus('int_123', 'active'); + + expect(result).toEqual(updatedIntegration); + expect(mockIntegrationRepo.updateIntegrationStatus).toHaveBeenCalledWith('int_123', 'active'); + }); + }); + + describe('User Queries', () => { + it('findUserById calls repository', async () => { + const commands = new AdminFriggCommands(); + const mockUser = { id: 'user_123', email: 'test@example.com' }; + mockUserRepo.findIndividualUserById.mockResolvedValue(mockUser); + + const result = await commands.findUserById('user_123'); + + expect(result).toEqual(mockUser); + expect(mockUserRepo.findIndividualUserById).toHaveBeenCalledWith('user_123'); + }); + + it('findUserByAppUserId calls repository', async () => { + const commands = new AdminFriggCommands(); + const mockUser = { id: 'user_123', appUserId: 'app_456' }; + mockUserRepo.findIndividualUserByAppUserId.mockResolvedValue(mockUser); + + const result = await commands.findUserByAppUserId('app_456'); + + expect(result).toEqual(mockUser); + expect(mockUserRepo.findIndividualUserByAppUserId).toHaveBeenCalledWith('app_456'); + }); + + it('findUserByUsername calls repository', async () => { + const commands = new AdminFriggCommands(); + const mockUser = { id: 'user_123', username: 'testuser' }; + mockUserRepo.findIndividualUserByUsername.mockResolvedValue(mockUser); + + const result = await commands.findUserByUsername('testuser'); + + expect(result).toEqual(mockUser); + expect(mockUserRepo.findIndividualUserByUsername).toHaveBeenCalledWith('testuser'); + }); + }); + + describe('Entity Queries', () => { + it('listEntities with userId filter calls findEntitiesByUserId', async () => { + const commands = new AdminFriggCommands(); + const mockEntities = [{ id: 'ent_1' }, { id: 'ent_2' }]; + mockModuleRepo.findEntitiesByUserId.mockResolvedValue(mockEntities); + + const result = await commands.listEntities({ userId: 'user_123' }); + + expect(result).toEqual(mockEntities); + expect(mockModuleRepo.findEntitiesByUserId).toHaveBeenCalledWith('user_123'); + }); + + it('listEntities without userId calls findEntity', async () => { + const commands = new AdminFriggCommands(); + const mockEntities = [{ id: 'ent_1' }]; + mockModuleRepo.findEntity.mockResolvedValue(mockEntities); + + const result = await commands.listEntities({ type: 'account' }); + + expect(result).toEqual(mockEntities); + expect(mockModuleRepo.findEntity).toHaveBeenCalledWith({ type: 'account' }); + }); + + it('findEntityById calls repository', async () => { + const commands = new AdminFriggCommands(); + const mockEntity = { id: 'ent_123', name: 'Test Entity' }; + mockModuleRepo.findEntityById.mockResolvedValue(mockEntity); + + const result = await commands.findEntityById('ent_123'); + + expect(result).toEqual(mockEntity); + expect(mockModuleRepo.findEntityById).toHaveBeenCalledWith('ent_123'); + }); + }); + + describe('Credential Queries', () => { + it('findCredential calls repository', async () => { + const commands = new AdminFriggCommands(); + const mockCredential = { id: 'cred_123', userId: 'user_123' }; + mockCredentialRepo.findCredential.mockResolvedValue(mockCredential); + + const result = await commands.findCredential({ userId: 'user_123' }); + + expect(result).toEqual(mockCredential); + expect(mockCredentialRepo.findCredential).toHaveBeenCalledWith({ userId: 'user_123' }); + }); + + it('updateCredential calls repository', async () => { + const commands = new AdminFriggCommands(); + const updates = { data: { newToken: 'xyz' } }; + const updatedCredential = { id: 'cred_123', ...updates }; + mockCredentialRepo.updateCredential.mockResolvedValue(updatedCredential); + + const result = await commands.updateCredential('cred_123', updates); + + expect(result).toEqual(updatedCredential); + expect(mockCredentialRepo.updateCredential).toHaveBeenCalledWith('cred_123', updates); + }); + }); + + describe('instantiate()', () => { + it('throws if no integrationFactory', async () => { + const commands = new AdminFriggCommands(); + + await expect(commands.instantiate('int_123')).rejects.toThrow( + 'instantiate() requires integrationFactory. ' + + 'Set Definition.config.requiresIntegrationFactory = true' + ); + }); + + it('calls integrationFactory.getInstanceFromIntegrationId', async () => { + const mockInstance = { primary: { api: {} } }; + const mockFactory = { + getInstanceFromIntegrationId: jest.fn().mockResolvedValue(mockInstance), + }; + const commands = new AdminFriggCommands({ integrationFactory: mockFactory }); + + const result = await commands.instantiate('int_123'); + + expect(result).toEqual(mockInstance); + expect(mockFactory.getInstanceFromIntegrationId).toHaveBeenCalledWith({ + integrationId: 'int_123', + _isAdminContext: true, + }); + }); + + it('passes _isAdminContext: true', async () => { + const mockInstance = { primary: { api: {} } }; + const mockFactory = { + getInstanceFromIntegrationId: jest.fn().mockResolvedValue(mockInstance), + }; + const commands = new AdminFriggCommands({ integrationFactory: mockFactory }); + + await commands.instantiate('int_123'); + + const callArgs = mockFactory.getInstanceFromIntegrationId.mock.calls[0][0]; + expect(callArgs._isAdminContext).toBe(true); + }); + }); + + describe('queueScript()', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('throws if ADMIN_SCRIPT_QUEUE_URL not set', async () => { + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + const commands = new AdminFriggCommands(); + + await expect(commands.queueScript('test-script', {})).rejects.toThrow( + 'ADMIN_SCRIPT_QUEUE_URL environment variable not set' + ); + }); + + it('calls QueuerUtil.send with correct params', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/admin-scripts'; + const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + const params = { integrationId: 'int_456' }; + + await commands.queueScript('test-script', params); + + expect(mockQueuerUtil.send).toHaveBeenCalledWith( + { + scriptName: 'test-script', + trigger: 'QUEUE', + params: { integrationId: 'int_456' }, + parentExecutionId: 'exec_123', + }, + 'https://sqs.us-east-1.amazonaws.com/123456789012/admin-scripts' + ); + }); + + it('includes parentExecutionId from constructor', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const commands = new AdminFriggCommands({ executionId: 'exec_parent' }); + + await commands.queueScript('my-script', {}); + + const callArgs = mockQueuerUtil.send.mock.calls[0][0]; + expect(callArgs.parentExecutionId).toBe('exec_parent'); + }); + + it('logs queuing operation', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const commands = new AdminFriggCommands(); + const params = { batchId: 'batch_1' }; + + await commands.queueScript('test-script', params); + + const logs = commands.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('info'); + expect(logs[0].message).toBe('Queued continuation for test-script'); + expect(logs[0].data).toEqual({ params }); + }); + }); + + describe('queueScriptBatch()', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('throws if ADMIN_SCRIPT_QUEUE_URL not set', async () => { + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + const commands = new AdminFriggCommands(); + + await expect(commands.queueScriptBatch([])).rejects.toThrow( + 'ADMIN_SCRIPT_QUEUE_URL environment variable not set' + ); + }); + + it('calls QueuerUtil.batchSend', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + const entries = [ + { scriptName: 'script-1', params: { id: '1' } }, + { scriptName: 'script-2', params: { id: '2' } }, + ]; + + await commands.queueScriptBatch(entries); + + expect(mockQueuerUtil.batchSend).toHaveBeenCalledWith( + [ + { + scriptName: 'script-1', + trigger: 'QUEUE', + params: { id: '1' }, + parentExecutionId: 'exec_123', + }, + { + scriptName: 'script-2', + trigger: 'QUEUE', + params: { id: '2' }, + parentExecutionId: 'exec_123', + }, + ], + 'https://sqs.example.com/queue' + ); + }); + + it('maps entries correctly', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const commands = new AdminFriggCommands(); + const entries = [ + { scriptName: 'test-script', params: { value: 'abc' } }, + ]; + + await commands.queueScriptBatch(entries); + + const callArgs = mockQueuerUtil.batchSend.mock.calls[0][0]; + expect(callArgs).toHaveLength(1); + expect(callArgs[0].scriptName).toBe('test-script'); + expect(callArgs[0].params).toEqual({ value: 'abc' }); + expect(callArgs[0].trigger).toBe('QUEUE'); + }); + + it('handles entries without params', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const commands = new AdminFriggCommands(); + const entries = [ + { scriptName: 'no-params-script' }, + ]; + + await commands.queueScriptBatch(entries); + + const callArgs = mockQueuerUtil.batchSend.mock.calls[0][0]; + expect(callArgs[0].params).toEqual({}); + }); + + it('logs batch queuing operation', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const commands = new AdminFriggCommands(); + const entries = [ + { scriptName: 'script-1', params: {} }, + { scriptName: 'script-2', params: {} }, + { scriptName: 'script-3', params: {} }, + ]; + + await commands.queueScriptBatch(entries); + + const logs = commands.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('info'); + expect(logs[0].message).toBe('Queued 3 script continuations'); + }); + }); + + describe('Logging', () => { + it('log() adds entry to logs array', () => { + const commands = new AdminFriggCommands(); + + const entry = commands.log('info', 'Test message', { key: 'value' }); + + expect(entry.level).toBe('info'); + expect(entry.message).toBe('Test message'); + expect(entry.data).toEqual({ key: 'value' }); + expect(entry.timestamp).toBeDefined(); + expect(commands.logs).toHaveLength(1); + expect(commands.logs[0]).toBe(entry); + }); + + it('log() persists if executionId set', async () => { + const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + // Force repository creation + commands.scriptExecutionRepository; + + commands.log('warn', 'Warning message', { detail: 'xyz' }); + + // Give async operation a chance to execute + await new Promise(resolve => setImmediate(resolve)); + + expect(mockScriptExecutionRepo.appendExecutionLog).toHaveBeenCalled(); + const callArgs = mockScriptExecutionRepo.appendExecutionLog.mock.calls[0]; + expect(callArgs[0]).toBe('exec_123'); + expect(callArgs[1].level).toBe('warn'); + expect(callArgs[1].message).toBe('Warning message'); + }); + + it('log() does not persist if no executionId', async () => { + const commands = new AdminFriggCommands(); + + commands.log('info', 'Test'); + + await new Promise(resolve => setImmediate(resolve)); + + expect(mockScriptExecutionRepo.appendExecutionLog).not.toHaveBeenCalled(); + }); + + it('log() handles persistence failure gracefully', async () => { + const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + // Force repository creation + commands.scriptExecutionRepository; + mockScriptExecutionRepo.appendExecutionLog.mockRejectedValue(new Error('DB Error')); + + // Should not throw + expect(() => commands.log('error', 'Test error')).not.toThrow(); + }); + + it('getLogs() returns all logs', () => { + const commands = new AdminFriggCommands(); + + commands.log('info', 'First'); + commands.log('warn', 'Second'); + commands.log('error', 'Third'); + + const logs = commands.getLogs(); + + expect(logs).toHaveLength(3); + expect(logs[0].message).toBe('First'); + expect(logs[1].message).toBe('Second'); + expect(logs[2].message).toBe('Third'); + }); + + it('clearLogs() clears logs array', () => { + const commands = new AdminFriggCommands(); + + commands.log('info', 'First'); + commands.log('info', 'Second'); + expect(commands.logs).toHaveLength(2); + + commands.clearLogs(); + + expect(commands.logs).toHaveLength(0); + }); + + it('getExecutionId() returns executionId', () => { + const commands = new AdminFriggCommands({ executionId: 'exec_789' }); + + expect(commands.getExecutionId()).toBe('exec_789'); + }); + + it('getExecutionId() returns null if not set', () => { + const commands = new AdminFriggCommands(); + + expect(commands.getExecutionId()).toBeNull(); + }); + }); + + describe('createAdminFriggCommands factory', () => { + it('creates AdminFriggCommands instance', () => { + const commands = createAdminFriggCommands({ executionId: 'exec_123' }); + + expect(commands).toBeInstanceOf(AdminFriggCommands); + expect(commands.executionId).toBe('exec_123'); + }); + + it('creates with default params', () => { + const commands = createAdminFriggCommands(); + + expect(commands).toBeInstanceOf(AdminFriggCommands); + expect(commands.executionId).toBeNull(); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js new file mode 100644 index 000000000..18a955403 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js @@ -0,0 +1,273 @@ +const { AdminScriptBase } = require('../admin-script-base'); + +describe('AdminScriptBase', () => { + describe('Static Definition pattern', () => { + it('should have a default Definition', () => { + expect(AdminScriptBase.Definition).toBeDefined(); + expect(AdminScriptBase.Definition.name).toBe('Script Name'); + expect(AdminScriptBase.Definition.version).toBe('0.0.0'); + expect(AdminScriptBase.Definition.description).toBe( + 'What this script does' + ); + expect(AdminScriptBase.Definition.source).toBe('USER_DEFINED'); + }); + + it('should allow child classes to override Definition', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'A test script', + source: 'BUILTIN', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + schedule: { + enabled: true, + cronExpression: 'cron(0 12 * * ? *)', + }, + config: { + timeout: 600000, + maxRetries: 3, + requiresIntegrationFactory: true, + }, + display: { + label: 'Test Script', + description: 'For testing', + category: 'testing', + }, + }; + } + + expect(TestScript.Definition.name).toBe('test-script'); + expect(TestScript.Definition.version).toBe('1.0.0'); + expect(TestScript.Definition.description).toBe('A test script'); + expect(TestScript.Definition.source).toBe('BUILTIN'); + expect(TestScript.Definition.schedule.enabled).toBe(true); + expect(TestScript.Definition.config.timeout).toBe(600000); + }); + }); + + describe('Static methods', () => { + it('getName() should return the script name', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'test', + }; + } + + expect(TestScript.getName()).toBe('my-script'); + }); + + it('getCurrentVersion() should return the version', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '2.3.1', + description: 'test', + }; + } + + expect(TestScript.getCurrentVersion()).toBe('2.3.1'); + }); + + it('getDefinition() should return the full Definition', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'test', + source: 'USER_DEFINED', + }; + } + + const definition = TestScript.getDefinition(); + expect(definition).toEqual({ + name: 'my-script', + version: '1.0.0', + description: 'test', + source: 'USER_DEFINED', + }); + }); + }); + + describe('Constructor', () => { + it('should initialize with default values', () => { + const script = new AdminScriptBase(); + + expect(script.executionId).toBeNull(); + expect(script.logs).toEqual([]); + expect(script._startTime).toBeNull(); + expect(script.integrationFactory).toBeNull(); + }); + + it('should accept executionId parameter', () => { + const script = new AdminScriptBase({ executionId: 'exec_123' }); + + expect(script.executionId).toBe('exec_123'); + }); + + it('should accept integrationFactory parameter', () => { + const mockFactory = { mock: true }; + const script = new AdminScriptBase({ + integrationFactory: mockFactory, + }); + + expect(script.integrationFactory).toBe(mockFactory); + }); + + it('should accept both executionId and integrationFactory', () => { + const mockFactory = { mock: true }; + const script = new AdminScriptBase({ + executionId: 'exec_456', + integrationFactory: mockFactory, + }); + + expect(script.executionId).toBe('exec_456'); + expect(script.integrationFactory).toBe(mockFactory); + }); + }); + + describe('execute()', () => { + it('should throw error when not implemented by subclass', async () => { + const script = new AdminScriptBase(); + + await expect(script.execute({}, {})).rejects.toThrow( + 'AdminScriptBase.execute() must be implemented by subclass' + ); + }); + + it('should allow child classes to implement execute()', async () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'test', + }; + + async execute(frigg, params) { + return { result: 'success', params }; + } + } + + const script = new TestScript(); + const frigg = {}; + const params = { foo: 'bar' }; + + const result = await script.execute(frigg, params); + + expect(result.result).toBe('success'); + expect(result.params).toEqual({ foo: 'bar' }); + }); + }); + + describe('Logging methods', () => { + it('log() should create log entry with timestamp', () => { + const script = new AdminScriptBase(); + const beforeTime = new Date().toISOString(); + + const entry = script.log('info', 'Test message', { key: 'value' }); + + const afterTime = new Date().toISOString(); + + expect(entry.level).toBe('info'); + expect(entry.message).toBe('Test message'); + expect(entry.data).toEqual({ key: 'value' }); + expect(entry.timestamp).toBeDefined(); + expect(entry.timestamp >= beforeTime).toBe(true); + expect(entry.timestamp <= afterTime).toBe(true); + }); + + it('log() should add entry to logs array', () => { + const script = new AdminScriptBase(); + + script.log('info', 'First'); + script.log('error', 'Second'); + script.log('warn', 'Third'); + + const logs = script.getLogs(); + + expect(logs).toHaveLength(3); + expect(logs[0].message).toBe('First'); + expect(logs[1].message).toBe('Second'); + expect(logs[2].message).toBe('Third'); + }); + + it('log() should default data to empty object', () => { + const script = new AdminScriptBase(); + + const entry = script.log('info', 'No data'); + + expect(entry.data).toEqual({}); + }); + + it('getLogs() should return logs array', () => { + const script = new AdminScriptBase(); + + script.log('info', 'Message 1'); + script.log('error', 'Message 2'); + + const logs = script.getLogs(); + + expect(logs).toHaveLength(2); + expect(logs[0].level).toBe('info'); + expect(logs[1].level).toBe('error'); + }); + + it('clearLogs() should empty logs array', () => { + const script = new AdminScriptBase(); + + script.log('info', 'Message 1'); + script.log('info', 'Message 2'); + expect(script.getLogs()).toHaveLength(2); + + script.clearLogs(); + + expect(script.getLogs()).toHaveLength(0); + }); + }); + + describe('Integration with child classes', () => { + it('should support full lifecycle', async () => { + class MyScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'My test script', + config: { + requiresIntegrationFactory: true, + }, + }; + + async execute(frigg, params) { + this.log('info', 'Starting execution'); + this.log('debug', 'Processing', params); + + if (this.integrationFactory) { + this.log('info', 'Integration factory available'); + } + + return { processed: true }; + } + } + + const mockFactory = { getInstanceById: jest.fn() }; + const script = new MyScript({ + executionId: 'exec_789', + integrationFactory: mockFactory, + }); + + const frigg = {}; + const result = await script.execute(frigg, { test: 'data' }); + + expect(result).toEqual({ processed: true }); + + const logs = script.getLogs(); + expect(logs).toHaveLength(3); + expect(logs[0].message).toBe('Starting execution'); + expect(logs[1].message).toBe('Processing'); + expect(logs[2].message).toBe('Integration factory available'); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/script-factory.test.js b/packages/admin-scripts/src/application/__tests__/script-factory.test.js new file mode 100644 index 000000000..e7e60c483 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/script-factory.test.js @@ -0,0 +1,381 @@ +const { + ScriptFactory, + createScriptFactory, + getScriptFactory, +} = require('../script-factory'); +const { AdminScriptBase } = require('../admin-script-base'); + +describe('ScriptFactory', () => { + let factory; + + beforeEach(() => { + factory = new ScriptFactory(); + }); + + describe('register()', () => { + it('should register a script class', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'A test script', + }; + } + + factory.register(TestScript); + + expect(factory.has('test-script')).toBe(true); + expect(factory.size).toBe(1); + }); + + it('should throw error if script class has no Definition', () => { + class InvalidScript {} + + expect(() => factory.register(InvalidScript)).toThrow( + 'Script class must have a static Definition property' + ); + }); + + it('should throw error if Definition has no name', () => { + class InvalidScript extends AdminScriptBase { + static Definition = { + version: '1.0.0', + description: 'No name', + }; + } + + expect(() => factory.register(InvalidScript)).toThrow( + 'Script Definition must have a name' + ); + }); + + it('should throw error if script name is already registered', () => { + class Script1 extends AdminScriptBase { + static Definition = { + name: 'duplicate', + version: '1.0.0', + description: 'First', + }; + } + + class Script2 extends AdminScriptBase { + static Definition = { + name: 'duplicate', + version: '2.0.0', + description: 'Second', + }; + } + + factory.register(Script1); + + expect(() => factory.register(Script2)).toThrow( + 'Script "duplicate" is already registered' + ); + }); + }); + + describe('registerAll()', () => { + it('should register multiple scripts', () => { + class Script1 extends AdminScriptBase { + static Definition = { + name: 'script-1', + version: '1.0.0', + description: 'First', + }; + } + + class Script2 extends AdminScriptBase { + static Definition = { + name: 'script-2', + version: '1.0.0', + description: 'Second', + }; + } + + class Script3 extends AdminScriptBase { + static Definition = { + name: 'script-3', + version: '1.0.0', + description: 'Third', + }; + } + + factory.registerAll([Script1, Script2, Script3]); + + expect(factory.size).toBe(3); + expect(factory.has('script-1')).toBe(true); + expect(factory.has('script-2')).toBe(true); + expect(factory.has('script-3')).toBe(true); + }); + + it('should handle empty array', () => { + factory.registerAll([]); + + expect(factory.size).toBe(0); + }); + }); + + describe('get()', () => { + it('should return registered script class', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + const retrieved = factory.get('test'); + + expect(retrieved).toBe(TestScript); + }); + + it('should throw error if script not found', () => { + expect(() => factory.get('non-existent')).toThrow( + 'Script "non-existent" not found' + ); + }); + }); + + describe('has()', () => { + it('should return true for registered script', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + expect(factory.has('test')).toBe(true); + }); + + it('should return false for non-registered script', () => { + expect(factory.has('non-existent')).toBe(false); + }); + }); + + describe('getNames()', () => { + it('should return array of all registered script names', () => { + class Script1 extends AdminScriptBase { + static Definition = { name: 'script-1', version: '1.0.0', description: 'One' }; + } + + class Script2 extends AdminScriptBase { + static Definition = { name: 'script-2', version: '1.0.0', description: 'Two' }; + } + + factory.registerAll([Script1, Script2]); + + const names = factory.getNames(); + + expect(names).toHaveLength(2); + expect(names).toContain('script-1'); + expect(names).toContain('script-2'); + }); + + it('should return empty array when no scripts registered', () => { + const names = factory.getNames(); + + expect(names).toEqual([]); + }); + }); + + describe('getAll()', () => { + it('should return all scripts with their definitions', () => { + class Script1 extends AdminScriptBase { + static Definition = { + name: 'script-1', + version: '1.0.0', + description: 'First script', + }; + } + + class Script2 extends AdminScriptBase { + static Definition = { + name: 'script-2', + version: '2.0.0', + description: 'Second script', + }; + } + + factory.registerAll([Script1, Script2]); + + const all = factory.getAll(); + + expect(all).toHaveLength(2); + + const script1Entry = all.find((s) => s.name === 'script-1'); + const script2Entry = all.find((s) => s.name === 'script-2'); + + expect(script1Entry.definition).toEqual(Script1.Definition); + expect(script2Entry.definition).toEqual(Script2.Definition); + }); + + it('should return empty array when no scripts registered', () => { + const all = factory.getAll(); + + expect(all).toEqual([]); + }); + }); + + describe('createInstance()', () => { + it('should create an instance of registered script', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + const instance = factory.createInstance('test'); + + expect(instance).toBeInstanceOf(TestScript); + expect(instance).toBeInstanceOf(AdminScriptBase); + }); + + it('should pass params to constructor', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + const mockFactory = { mock: true }; + const instance = factory.createInstance('test', { + executionId: 'exec_123', + integrationFactory: mockFactory, + }); + + expect(instance.executionId).toBe('exec_123'); + expect(instance.integrationFactory).toBe(mockFactory); + }); + + it('should throw error if script not found', () => { + expect(() => factory.createInstance('non-existent')).toThrow( + 'Script "non-existent" not found' + ); + }); + }); + + describe('clear()', () => { + it('should remove all registered scripts', () => { + class Script1 extends AdminScriptBase { + static Definition = { name: 'script-1', version: '1.0.0', description: 'One' }; + } + + class Script2 extends AdminScriptBase { + static Definition = { name: 'script-2', version: '1.0.0', description: 'Two' }; + } + + factory.registerAll([Script1, Script2]); + expect(factory.size).toBe(2); + + factory.clear(); + + expect(factory.size).toBe(0); + expect(factory.has('script-1')).toBe(false); + expect(factory.has('script-2')).toBe(false); + }); + }); + + describe('size property', () => { + it('should return count of registered scripts', () => { + expect(factory.size).toBe(0); + + class Script1 extends AdminScriptBase { + static Definition = { name: 'script-1', version: '1.0.0', description: 'One' }; + } + + factory.register(Script1); + expect(factory.size).toBe(1); + + class Script2 extends AdminScriptBase { + static Definition = { name: 'script-2', version: '1.0.0', description: 'Two' }; + } + + factory.register(Script2); + expect(factory.size).toBe(2); + + factory.clear(); + expect(factory.size).toBe(0); + }); + }); + + describe('Global factory functions', () => { + it('getScriptFactory() should return singleton instance', () => { + const factory1 = getScriptFactory(); + const factory2 = getScriptFactory(); + + expect(factory1).toBe(factory2); + expect(factory1).toBeInstanceOf(ScriptFactory); + }); + + it('createScriptFactory() should create new instance', () => { + const factory1 = createScriptFactory(); + const factory2 = createScriptFactory(); + + expect(factory1).not.toBe(factory2); + expect(factory1).toBeInstanceOf(ScriptFactory); + expect(factory2).toBeInstanceOf(ScriptFactory); + }); + + it('global factory should be independent from created instances', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + const customFactory = createScriptFactory(); + customFactory.register(TestScript); + + const globalFactory = getScriptFactory(); + + // Custom factory has the script + expect(customFactory.has('test')).toBe(true); + + // Global factory doesn't (assuming it's empty or has different scripts) + // We can't make assumptions about global factory state in tests + // so we just verify they're different instances + expect(customFactory).not.toBe(globalFactory); + }); + }); + + describe('Exported AdminScriptBase', () => { + it('should export AdminScriptBase class', () => { + expect(AdminScriptBase).toBeDefined(); + expect(typeof AdminScriptBase).toBe('function'); + }); + + it('should be usable to create scripts', () => { + class MyScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'My script', + }; + + async execute(frigg, params) { + return { success: true }; + } + } + + const script = new MyScript(); + expect(script).toBeInstanceOf(AdminScriptBase); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js new file mode 100644 index 000000000..7cf30abe1 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -0,0 +1,202 @@ +const { ScriptRunner, createScriptRunner } = require('../script-runner'); +const { ScriptFactory } = require('../script-factory'); +const { AdminScriptBase } = require('../admin-script-base'); + +// Mock dependencies +jest.mock('../admin-frigg-commands'); +jest.mock('@friggframework/core/application/commands/admin-script-commands'); + +const { createAdminFriggCommands } = require('../admin-frigg-commands'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +describe('ScriptRunner', () => { + let scriptFactory; + let mockCommands; + let mockFrigg; + let testScript; + + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'Test script', + config: { + timeout: 300000, + maxRetries: 0, + requiresIntegrationFactory: false, + }, + }; + + async execute(frigg, params) { + return { success: true, params }; + } + } + + beforeEach(() => { + scriptFactory = new ScriptFactory([TestScript]); + + mockCommands = { + createScriptExecution: jest.fn(), + updateScriptExecutionStatus: jest.fn(), + completeScriptExecution: jest.fn(), + }; + + mockFrigg = { + log: jest.fn(), + getExecutionId: jest.fn(), + }; + + createAdminScriptCommands.mockReturnValue(mockCommands); + createAdminFriggCommands.mockReturnValue(mockFrigg); + + mockCommands.createScriptExecution.mockResolvedValue({ + id: 'exec-123', + }); + mockCommands.updateScriptExecutionStatus.mockResolvedValue({}); + mockCommands.completeScriptExecution.mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute()', () => { + it('should execute script successfully', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('test-script', { foo: 'bar' }, { + trigger: 'MANUAL', + mode: 'async', + audit: { apiKeyName: 'test-key' }, + }); + + expect(result.status).toBe('COMPLETED'); + expect(result.scriptName).toBe('test-script'); + expect(result.output).toEqual({ success: true, params: { foo: 'bar' } }); + expect(result.executionId).toBe('exec-123'); + expect(result.metrics.durationMs).toBeGreaterThanOrEqual(0); + + expect(mockCommands.createScriptExecution).toHaveBeenCalledWith({ + scriptName: 'test-script', + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { foo: 'bar' }, + audit: { apiKeyName: 'test-key' }, + }); + + expect(mockCommands.updateScriptExecutionStatus).toHaveBeenCalledWith( + 'exec-123', + 'RUNNING' + ); + + expect(mockCommands.completeScriptExecution).toHaveBeenCalledWith( + 'exec-123', + expect.objectContaining({ + status: 'COMPLETED', + output: { success: true, params: { foo: 'bar' } }, + metrics: expect.objectContaining({ + durationMs: expect.any(Number), + }), + }) + ); + }); + + it('should handle script execution failure', async () => { + class FailingScript extends AdminScriptBase { + static Definition = { + name: 'failing-script', + version: '1.0.0', + description: 'Failing script', + config: { timeout: 300000, maxRetries: 0 }, + }; + + async execute() { + throw new Error('Script failed'); + } + } + + scriptFactory.register(FailingScript); + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('failing-script', {}, { + trigger: 'MANUAL', + mode: 'sync', + }); + + expect(result.status).toBe('FAILED'); + expect(result.scriptName).toBe('failing-script'); + expect(result.error.message).toBe('Script failed'); + + expect(mockCommands.completeScriptExecution).toHaveBeenCalledWith( + 'exec-123', + expect.objectContaining({ + status: 'FAILED', + error: expect.objectContaining({ + message: 'Script failed', + }), + }) + ); + }); + + it('should throw error if integrationFactory required but not provided', async () => { + class IntegrationScript extends AdminScriptBase { + static Definition = { + name: 'integration-script', + version: '1.0.0', + description: 'Integration script', + config: { + requiresIntegrationFactory: true, + }, + }; + + async execute() { + return {}; + } + } + + scriptFactory.register(IntegrationScript); + const runner = new ScriptRunner({ + scriptFactory, + commands: mockCommands, + integrationFactory: null, + }); + + await expect( + runner.execute('integration-script', {}, { trigger: 'MANUAL' }) + ).rejects.toThrow( + 'Script "integration-script" requires integrationFactory but none was provided' + ); + }); + + it('should reuse existing execution ID when provided', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('test-script', { foo: 'bar' }, { + trigger: 'QUEUE', + executionId: 'existing-exec-456', + }); + + expect(result.executionId).toBe('existing-exec-456'); + expect(mockCommands.createScriptExecution).not.toHaveBeenCalled(); + expect(mockCommands.updateScriptExecutionStatus).toHaveBeenCalledWith( + 'existing-exec-456', + 'RUNNING' + ); + }); + }); + + describe('createScriptRunner()', () => { + it('should create runner with default factory', () => { + const runner = createScriptRunner(); + expect(runner).toBeInstanceOf(ScriptRunner); + }); + + it('should create runner with custom params', () => { + const customFactory = new ScriptFactory(); + const runner = createScriptRunner({ scriptFactory: customFactory }); + expect(runner).toBeInstanceOf(ScriptRunner); + expect(runner.scriptFactory).toBe(customFactory); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/admin-frigg-commands.js b/packages/admin-scripts/src/application/admin-frigg-commands.js new file mode 100644 index 000000000..df71f57c3 --- /dev/null +++ b/packages/admin-scripts/src/application/admin-frigg-commands.js @@ -0,0 +1,242 @@ +const { QueuerUtil } = require('@friggframework/core/queues'); + +/** + * AdminFriggCommands + * + * Helper API for admin scripts. Provides: + * - Database access via repositories + * - Integration instantiation (optional) + * - Logging utilities + * - Queue operations for self-queuing pattern + * + * Follows lazy-loading pattern for repositories to avoid circular dependencies + * and unnecessary initialization. + */ +class AdminFriggCommands { + constructor(params = {}) { + this.executionId = params.executionId || null; + this.logs = []; + + // OPTIONAL: Integration factory for scripts that need external API access + this.integrationFactory = params.integrationFactory || null; + + // Lazy-load repositories to avoid circular deps + this._integrationRepository = null; + this._userRepository = null; + this._moduleRepository = null; + this._credentialRepository = null; + this._scriptExecutionRepository = null; + } + + // ==================== LAZY-LOADED REPOSITORIES ==================== + + get integrationRepository() { + if (!this._integrationRepository) { + const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); + this._integrationRepository = createIntegrationRepository(); + } + return this._integrationRepository; + } + + get userRepository() { + if (!this._userRepository) { + const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); + this._userRepository = createUserRepository(); + } + return this._userRepository; + } + + get moduleRepository() { + if (!this._moduleRepository) { + const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); + this._moduleRepository = createModuleRepository(); + } + return this._moduleRepository; + } + + get credentialRepository() { + if (!this._credentialRepository) { + const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); + this._credentialRepository = createCredentialRepository(); + } + return this._credentialRepository; + } + + get scriptExecutionRepository() { + if (!this._scriptExecutionRepository) { + const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); + this._scriptExecutionRepository = createScriptExecutionRepository(); + } + return this._scriptExecutionRepository; + } + + // ==================== INTEGRATION QUERIES ==================== + + async listIntegrations(filter = {}) { + if (filter.userId) { + return this.integrationRepository.findIntegrationsByUserId(filter.userId); + } + return this.integrationRepository.findIntegrations(filter); + } + + async findIntegrationById(id) { + return this.integrationRepository.findIntegrationById(id); + } + + async findIntegrationsByUserId(userId) { + return this.integrationRepository.findIntegrationsByUserId(userId); + } + + async updateIntegrationConfig(integrationId, config) { + return this.integrationRepository.updateIntegrationConfig(integrationId, config); + } + + async updateIntegrationStatus(integrationId, status) { + return this.integrationRepository.updateIntegrationStatus(integrationId, status); + } + + // ==================== USER QUERIES ==================== + + async findUserById(userId) { + return this.userRepository.findIndividualUserById(userId); + } + + async findUserByAppUserId(appUserId) { + return this.userRepository.findIndividualUserByAppUserId(appUserId); + } + + async findUserByUsername(username) { + return this.userRepository.findIndividualUserByUsername(username); + } + + // ==================== ENTITY QUERIES ==================== + + async listEntities(filter = {}) { + if (filter.userId) { + return this.moduleRepository.findEntitiesByUserId(filter.userId); + } + return this.moduleRepository.findEntity(filter); + } + + async findEntityById(entityId) { + return this.moduleRepository.findEntityById(entityId); + } + + // ==================== CREDENTIAL QUERIES ==================== + + async findCredential(filter) { + return this.credentialRepository.findCredential(filter); + } + + async updateCredential(credentialId, updates) { + return this.credentialRepository.updateCredential(credentialId, updates); + } + + // ==================== INTEGRATION INSTANTIATION ==================== + + /** + * Instantiate an integration instance (for calling external APIs) + * REQUIRES: integrationFactory in constructor + */ + async instantiate(integrationId) { + if (!this.integrationFactory) { + throw new Error( + 'instantiate() requires integrationFactory. ' + + 'Set Definition.config.requiresIntegrationFactory = true' + ); + } + return this.integrationFactory.getInstanceFromIntegrationId({ + integrationId, + _isAdminContext: true, // Bypass user ownership check + }); + } + + // ==================== QUEUE OPERATIONS (Self-Queuing Pattern) ==================== + + /** + * Queue a script for execution + * Used for self-queuing pattern with long-running scripts + */ + async queueScript(scriptName, params = {}) { + const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; + if (!queueUrl) { + throw new Error('ADMIN_SCRIPT_QUEUE_URL environment variable not set'); + } + + await QueuerUtil.send( + { + scriptName, + trigger: 'QUEUE', + params, + parentExecutionId: this.executionId, + }, + queueUrl + ); + + this.log('info', `Queued continuation for ${scriptName}`, { params }); + } + + /** + * Queue multiple scripts in a batch + */ + async queueScriptBatch(entries) { + const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; + if (!queueUrl) { + throw new Error('ADMIN_SCRIPT_QUEUE_URL environment variable not set'); + } + + const messages = entries.map(entry => ({ + scriptName: entry.scriptName, + trigger: 'QUEUE', + params: entry.params || {}, + parentExecutionId: this.executionId, + })); + + await QueuerUtil.batchSend(messages, queueUrl); + this.log('info', `Queued ${entries.length} script continuations`); + } + + // ==================== LOGGING ==================== + + log(level, message, data = {}) { + const entry = { + level, + message, + data, + timestamp: new Date().toISOString(), + }; + this.logs.push(entry); + + // Persist to execution record if we have an executionId + if (this.executionId) { + this.scriptExecutionRepository.appendExecutionLog(this.executionId, entry) + .catch(err => console.error('Failed to persist log:', err)); + } + + return entry; + } + + getExecutionId() { + return this.executionId; + } + + getLogs() { + return this.logs; + } + + clearLogs() { + this.logs = []; + } +} + +/** + * Create AdminFriggCommands instance + */ +function createAdminFriggCommands(params = {}) { + return new AdminFriggCommands(params); +} + +module.exports = { + AdminFriggCommands, + createAdminFriggCommands, +}; diff --git a/packages/admin-scripts/src/application/admin-script-base.js b/packages/admin-scripts/src/application/admin-script-base.js new file mode 100644 index 000000000..93ead1af1 --- /dev/null +++ b/packages/admin-scripts/src/application/admin-script-base.js @@ -0,0 +1,138 @@ +const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); +const { createAdminApiKeyRepository } = require('@friggframework/core/admin-scripts/repositories/admin-api-key-repository-factory'); + +/** + * Admin Script Base Class + * + * Base class for all admin scripts. Provides: + * - Standard script definition pattern + * - Repository access + * - Logging helpers + * - Integration factory support (optional) + * + * Usage: + * ```javascript + * class MyScript extends AdminScriptBase { + * static Definition = { + * name: 'my-script', + * version: '1.0.0', + * description: 'Does something useful', + * ... + * }; + * + * async execute(frigg, params) { + * // Your script logic here + * } + * } + * ``` + */ +class AdminScriptBase { + /** + * CHILDREN SHOULD SPECIFY A DEFINITION FOR THE SCRIPT + * Pattern matches IntegrationBase.Definition + */ + static Definition = { + name: 'Script Name', // Required: unique identifier + version: '0.0.0', // Required: semver for migrations + description: 'What this script does', // Required: human-readable + + // Script-specific properties + source: 'USER_DEFINED', // 'BUILTIN' | 'USER_DEFINED' + + inputSchema: null, // Optional: JSON Schema for params + outputSchema: null, // Optional: JSON Schema for results + + schedule: { + // Optional: Phase 2 + enabled: false, + cronExpression: null, // 'cron(0 12 * * ? *)' + }, + + config: { + timeout: 300000, // Default 5 min (ms) + maxRetries: 0, + requiresIntegrationFactory: false, // Hint: does script need to instantiate integrations? + }, + + display: { + // For future UI + label: 'Script Name', + description: '', + category: 'maintenance', // 'maintenance' | 'healing' | 'sync' | 'custom' + }, + }; + + static getName() { + return this.Definition.name; + } + + static getCurrentVersion() { + return this.Definition.version; + } + + static getDefinition() { + return this.Definition; + } + + /** + * Constructor receives dependencies + * Pattern matches IntegrationBase constructor + */ + constructor(params = {}) { + this.executionId = params.executionId || null; + this.logs = []; + this._startTime = null; + + // OPTIONAL: Integration factory for scripts that need it + this.integrationFactory = params.integrationFactory || null; + + // OPTIONAL: Injected repositories (for testing or custom implementations) + this.scriptExecutionRepository = params.scriptExecutionRepository || null; + this.adminApiKeyRepository = params.adminApiKeyRepository || null; + } + + /** + * CHILDREN MUST IMPLEMENT THIS METHOD + * @param {AdminFriggCommands} frigg - Helper commands object + * @param {Object} params - Script parameters (validated against inputSchema) + * @returns {Promise} - Script results (validated against outputSchema) + */ + async execute(frigg, params) { + throw new Error('AdminScriptBase.execute() must be implemented by subclass'); + } + + /** + * Logging helper + * @param {string} level - Log level (info, warn, error, debug) + * @param {string} message - Log message + * @param {Object} data - Additional data + * @returns {Object} Log entry + */ + log(level, message, data = {}) { + const entry = { + level, + message, + data, + timestamp: new Date().toISOString(), + }; + this.logs.push(entry); + return entry; + } + + /** + * Get all logs + * @returns {Array} Log entries + */ + getLogs() { + return this.logs; + } + + /** + * Clear all logs + */ + clearLogs() { + this.logs = []; + } +} + +module.exports = { AdminScriptBase }; diff --git a/packages/admin-scripts/src/application/script-factory.js b/packages/admin-scripts/src/application/script-factory.js new file mode 100644 index 000000000..8c6ba0229 --- /dev/null +++ b/packages/admin-scripts/src/application/script-factory.js @@ -0,0 +1,161 @@ +/** + * Script Factory + * + * Registry and factory for admin scripts. + * Manages script registration, validation, and instantiation. + * + * Usage: + * ```javascript + * const factory = new ScriptFactory(); + * factory.register(MyScript); + * const script = factory.createInstance('my-script', { executionId: '123' }); + * ``` + */ +class ScriptFactory { + constructor(scripts = []) { + this.registry = new Map(); + + // Register initial scripts + scripts.forEach((ScriptClass) => this.register(ScriptClass)); + } + + /** + * Register a script class + * @param {Function} ScriptClass - Script class extending AdminScriptBase + * @throws {Error} If script invalid or name collision + */ + register(ScriptClass) { + if (!ScriptClass || !ScriptClass.Definition) { + throw new Error('Script class must have a static Definition property'); + } + + const definition = ScriptClass.Definition; + const name = definition.name; + + if (!name) { + throw new Error('Script Definition must have a name'); + } + + if (this.registry.has(name)) { + throw new Error(`Script "${name}" is already registered`); + } + + this.registry.set(name, ScriptClass); + } + + /** + * Register multiple scripts at once + * @param {Array} scriptClasses - Array of script classes + */ + registerAll(scriptClasses) { + scriptClasses.forEach((ScriptClass) => this.register(ScriptClass)); + } + + /** + * Check if script is registered + * @param {string} name - Script name + * @returns {boolean} True if registered + */ + has(name) { + return this.registry.has(name); + } + + /** + * Get script class by name + * @param {string} name - Script name + * @returns {Function} Script class + * @throws {Error} If script not found + */ + get(name) { + const ScriptClass = this.registry.get(name); + if (!ScriptClass) { + throw new Error(`Script "${name}" not found`); + } + return ScriptClass; + } + + /** + * Get array of all registered script names + * @returns {Array} Array of script names + */ + getNames() { + return Array.from(this.registry.keys()); + } + + /** + * Get all registered scripts + * @returns {Array} Array of { name, definition, class } + */ + getAll() { + const scripts = []; + for (const [name, ScriptClass] of this.registry.entries()) { + scripts.push({ + name, + definition: ScriptClass.Definition, + class: ScriptClass, + }); + } + return scripts; + } + + /** + * Create script instance + * @param {string} name - Script name + * @param {Object} params - Constructor parameters + * @returns {Object} Script instance + * @throws {Error} If script not found + */ + createInstance(name, params = {}) { + const ScriptClass = this.get(name); + return new ScriptClass(params); + } + + /** + * Remove script from registry + * @param {string} name - Script name + * @returns {boolean} True if removed + */ + unregister(name) { + return this.registry.delete(name); + } + + /** + * Clear all registered scripts + */ + clear() { + this.registry.clear(); + } + + /** + * Get count of registered scripts + * @returns {number} Count + */ + get size() { + return this.registry.size; + } +} + +// Singleton instance for global use +let globalFactory = null; + +/** + * Get global script factory instance + * @returns {ScriptFactory} Global factory + */ +function getScriptFactory() { + if (!globalFactory) { + globalFactory = new ScriptFactory(); + } + return globalFactory; +} + +/** + * Create a new script factory instance + * @param {Array} scripts - Initial scripts to register + * @returns {ScriptFactory} New factory + */ +function createScriptFactory(scripts = []) { + return new ScriptFactory(scripts); +} + +module.exports = { ScriptFactory, getScriptFactory, createScriptFactory }; diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js new file mode 100644 index 000000000..6aba9664e --- /dev/null +++ b/packages/admin-scripts/src/application/script-runner.js @@ -0,0 +1,142 @@ +const { getScriptFactory } = require('./script-factory'); +const { createAdminFriggCommands } = require('./admin-frigg-commands'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +/** + * Script Runner + * + * Orchestrates script execution with: + * - Execution record creation + * - Script instantiation + * - AdminFriggCommands injection + * - Error handling + * - Status updates + */ +class ScriptRunner { + constructor(params = {}) { + this.scriptFactory = params.scriptFactory || getScriptFactory(); + this.commands = params.commands || createAdminScriptCommands(); + this.integrationFactory = params.integrationFactory || null; + } + + /** + * Execute a script + * @param {string} scriptName - Name of the script to run + * @param {Object} params - Script parameters + * @param {Object} options - Execution options + * @param {string} options.trigger - 'MANUAL' | 'SCHEDULED' | 'QUEUE' + * @param {string} options.mode - 'sync' | 'async' + * @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress } + * @param {string} options.executionId - Reuse existing execution ID + */ + async execute(scriptName, params = {}, options = {}) { + const { trigger = 'MANUAL', audit = {}, executionId: existingExecutionId } = options; + + // Get script class + const scriptClass = this.scriptFactory.get(scriptName); + const definition = scriptClass.Definition; + + // Validate integrationFactory requirement + if (definition.config?.requiresIntegrationFactory && !this.integrationFactory) { + throw new Error( + `Script "${scriptName}" requires integrationFactory but none was provided` + ); + } + + let executionId = existingExecutionId; + + // Create execution record if not provided + if (!executionId) { + const execution = await this.commands.createScriptExecution({ + scriptName, + scriptVersion: definition.version, + trigger, + mode: options.mode || 'async', + input: params, + audit, + }); + executionId = execution.id; + } + + const startTime = new Date(); + + try { + // Update status to RUNNING + await this.commands.updateScriptExecutionStatus(executionId, 'RUNNING'); + + // Create frigg commands for the script + const frigg = createAdminFriggCommands({ + executionId, + integrationFactory: this.integrationFactory, + }); + + // Create script instance + const script = this.scriptFactory.createInstance(scriptName, { + executionId, + integrationFactory: this.integrationFactory, + }); + + // Execute the script + const output = await script.execute(frigg, params); + + // Calculate metrics + const endTime = new Date(); + const durationMs = endTime - startTime; + + // Complete execution + await this.commands.completeScriptExecution(executionId, { + status: 'COMPLETED', + output, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); + + return { + executionId, + status: 'COMPLETED', + scriptName, + output, + metrics: { durationMs }, + }; + } catch (error) { + // Calculate metrics even on failure + const endTime = new Date(); + const durationMs = endTime - startTime; + + // Record failure + await this.commands.completeScriptExecution(executionId, { + status: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); + + return { + executionId, + status: 'FAILED', + scriptName, + error: { + name: error.name, + message: error.message, + }, + metrics: { durationMs }, + }; + } + } +} + +function createScriptRunner(params = {}) { + return new ScriptRunner(params); +} + +module.exports = { ScriptRunner, createScriptRunner }; diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js new file mode 100644 index 000000000..7ba814396 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js @@ -0,0 +1,148 @@ +const { adminAuthMiddleware } = require('../admin-auth-middleware'); + +// Mock the admin script commands +jest.mock('@friggframework/core/application/commands/admin-script-commands', () => ({ + createAdminScriptCommands: jest.fn(), +})); + +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +describe('adminAuthMiddleware', () => { + let mockReq; + let mockRes; + let mockNext; + let mockCommands; + + beforeEach(() => { + mockReq = { + headers: {}, + ip: '127.0.0.1', + }; + + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + mockNext = jest.fn(); + + mockCommands = { + validateAdminApiKey: jest.fn(), + }; + + createAdminScriptCommands.mockReturnValue(mockCommands); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Authorization header validation', () => { + it('should reject request without Authorization header', async () => { + await adminAuthMiddleware(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Missing or invalid Authorization header', + code: 'MISSING_AUTH', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should reject request with invalid Authorization format', async () => { + mockReq.headers.authorization = 'InvalidFormat key123'; + + await adminAuthMiddleware(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Missing or invalid Authorization header', + code: 'MISSING_AUTH', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('API key validation', () => { + it('should reject request with invalid API key', async () => { + mockReq.headers.authorization = 'Bearer invalid-key'; + mockCommands.validateAdminApiKey.mockResolvedValue({ + error: 401, + reason: 'Invalid API key', + code: 'INVALID_API_KEY', + }); + + await adminAuthMiddleware(mockReq, mockRes, mockNext); + + expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('invalid-key'); + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Invalid API key', + code: 'INVALID_API_KEY', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should reject request with expired API key', async () => { + mockReq.headers.authorization = 'Bearer expired-key'; + mockCommands.validateAdminApiKey.mockResolvedValue({ + error: 401, + reason: 'API key has expired', + code: 'EXPIRED_API_KEY', + }); + + await adminAuthMiddleware(mockReq, mockRes, mockNext); + + expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('expired-key'); + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'API key has expired', + code: 'EXPIRED_API_KEY', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should accept request with valid API key', async () => { + const validKey = 'valid-api-key-123'; + mockReq.headers.authorization = `Bearer ${validKey}`; + mockCommands.validateAdminApiKey.mockResolvedValue({ + valid: true, + apiKey: { + id: 'key-id-1', + name: 'test-key', + keyLast4: 'e123', + }, + }); + + await adminAuthMiddleware(mockReq, mockRes, mockNext); + + expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith(validKey); + expect(mockReq.adminApiKey).toBeDefined(); + expect(mockReq.adminApiKey.name).toBe('test-key'); + expect(mockReq.adminAudit).toBeDefined(); + expect(mockReq.adminAudit.apiKeyName).toBe('test-key'); + expect(mockReq.adminAudit.apiKeyLast4).toBe('e123'); + expect(mockReq.adminAudit.ipAddress).toBe('127.0.0.1'); + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('should handle validation errors gracefully', async () => { + mockReq.headers.authorization = 'Bearer some-key'; + mockCommands.validateAdminApiKey.mockRejectedValue( + new Error('Database error') + ); + + await adminAuthMiddleware(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Authentication failed', + code: 'AUTH_ERROR', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js new file mode 100644 index 000000000..50119a570 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -0,0 +1,277 @@ +const request = require('supertest'); +const { app } = require('../admin-script-router'); +const { AdminScriptBase } = require('../../application/admin-script-base'); + +// Mock dependencies +jest.mock('../admin-auth-middleware', () => ({ + adminAuthMiddleware: (req, res, next) => { + // Mock auth - attach admin audit info + req.adminAudit = { + apiKeyName: 'test-key', + apiKeyLast4: '1234', + ipAddress: '127.0.0.1', + }; + next(); + }, +})); + +jest.mock('../../application/script-factory'); +jest.mock('../../application/script-runner'); +jest.mock('@friggframework/core/application/commands/admin-script-commands'); +jest.mock('@friggframework/core/queues'); + +const { getScriptFactory } = require('../../application/script-factory'); +const { createScriptRunner } = require('../../application/script-runner'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); +const { QueuerUtil } = require('@friggframework/core/queues'); + +describe('Admin Script Router', () => { + let mockFactory; + let mockRunner; + let mockCommands; + + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'Test script', + config: { timeout: 300000 }, + display: { category: 'test' }, + }; + + async execute(frigg, params) { + return { success: true, params }; + } + } + + beforeEach(() => { + mockFactory = { + getAll: jest.fn(), + has: jest.fn(), + get: jest.fn(), + }; + + mockRunner = { + execute: jest.fn(), + }; + + mockCommands = { + createScriptExecution: jest.fn(), + findScriptExecutionById: jest.fn(), + findRecentExecutions: jest.fn(), + }; + + getScriptFactory.mockReturnValue(mockFactory); + createScriptRunner.mockReturnValue(mockRunner); + createAdminScriptCommands.mockReturnValue(mockCommands); + QueuerUtil.send = jest.fn().mockResolvedValue({}); + + // Default mock implementations + mockFactory.getAll.mockReturnValue([ + { + name: 'test-script', + definition: TestScript.Definition, + class: TestScript, + }, + ]); + + mockFactory.has.mockReturnValue(true); + mockFactory.get.mockReturnValue(TestScript); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /admin/scripts', () => { + it('should list all registered scripts', async () => { + const response = await request(app).get('/admin/scripts'); + + expect(response.status).toBe(200); + expect(response.body.scripts).toHaveLength(1); + expect(response.body.scripts[0]).toEqual({ + name: 'test-script', + version: '1.0.0', + description: 'Test script', + category: 'test', + requiresIntegrationFactory: false, + schedule: null, + }); + }); + + it('should handle errors gracefully', async () => { + mockFactory.getAll.mockImplementation(() => { + throw new Error('Factory error'); + }); + + const response = await request(app).get('/admin/scripts'); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Failed to list scripts'); + }); + }); + + describe('GET /admin/scripts/:scriptName', () => { + it('should return script details', async () => { + const response = await request(app).get('/admin/scripts/test-script'); + + expect(response.status).toBe(200); + expect(response.body.name).toBe('test-script'); + expect(response.body.version).toBe('1.0.0'); + expect(response.body.description).toBe('Test script'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app).get( + '/admin/scripts/non-existent-script' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); + + describe('POST /admin/scripts/:scriptName/execute', () => { + it('should execute script synchronously', async () => { + mockRunner.execute.mockResolvedValue({ + executionId: 'exec-123', + status: 'COMPLETED', + scriptName: 'test-script', + output: { success: true }, + metrics: { durationMs: 100 }, + }); + + const response = await request(app) + .post('/admin/scripts/test-script/execute') + .send({ + params: { foo: 'bar' }, + mode: 'sync', + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('COMPLETED'); + expect(response.body.executionId).toBe('exec-123'); + expect(mockRunner.execute).toHaveBeenCalledWith( + 'test-script', + { foo: 'bar' }, + expect.objectContaining({ + trigger: 'MANUAL', + mode: 'sync', + }) + ); + }); + + it('should queue script for async execution', async () => { + mockCommands.createScriptExecution.mockResolvedValue({ + id: 'exec-456', + }); + + const response = await request(app) + .post('/admin/scripts/test-script/execute') + .send({ + params: { foo: 'bar' }, + mode: 'async', + }); + + expect(response.status).toBe(202); + expect(response.body.status).toBe('PENDING'); + expect(response.body.executionId).toBe('exec-456'); + expect(QueuerUtil.send).toHaveBeenCalledWith( + expect.objectContaining({ + scriptName: 'test-script', + executionId: 'exec-456', + }), + process.env.ADMIN_SCRIPT_QUEUE_URL + ); + }); + + it('should default to async mode', async () => { + mockCommands.createScriptExecution.mockResolvedValue({ + id: 'exec-789', + }); + + const response = await request(app) + .post('/admin/scripts/test-script/execute') + .send({ + params: { foo: 'bar' }, + }); + + expect(response.status).toBe(202); + expect(response.body.status).toBe('PENDING'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app) + .post('/admin/scripts/non-existent/execute') + .send({ + params: {}, + }); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); + + describe('GET /admin/executions/:executionId', () => { + it('should return execution details', async () => { + mockCommands.findScriptExecutionById.mockResolvedValue({ + id: 'exec-123', + scriptName: 'test-script', + status: 'COMPLETED', + }); + + const response = await request(app).get('/admin/executions/exec-123'); + + expect(response.status).toBe(200); + expect(response.body.id).toBe('exec-123'); + expect(response.body.scriptName).toBe('test-script'); + }); + + it('should return 404 for non-existent execution', async () => { + mockCommands.findScriptExecutionById.mockResolvedValue({ + error: 404, + reason: 'Execution not found', + code: 'EXECUTION_NOT_FOUND', + }); + + const response = await request(app).get( + '/admin/executions/non-existent' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('EXECUTION_NOT_FOUND'); + }); + }); + + describe('GET /admin/executions', () => { + it('should list recent executions', async () => { + mockCommands.findRecentExecutions.mockResolvedValue([ + { id: 'exec-1', scriptName: 'test-script', status: 'COMPLETED' }, + { id: 'exec-2', scriptName: 'test-script', status: 'RUNNING' }, + ]); + + const response = await request(app).get('/admin/executions'); + + expect(response.status).toBe(200); + expect(response.body.executions).toHaveLength(2); + }); + + it('should accept query parameters', async () => { + mockCommands.findRecentExecutions.mockResolvedValue([]); + + await request(app).get( + '/admin/executions?scriptName=test-script&status=COMPLETED&limit=10' + ); + + expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({ + scriptName: 'test-script', + status: 'COMPLETED', + limit: 10, + }); + }); + }); +}); diff --git a/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js b/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js new file mode 100644 index 000000000..cf8080bf2 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js @@ -0,0 +1,49 @@ +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +/** + * Admin API Key Authentication Middleware + * + * Validates admin API keys for script endpoints. + * Expects: Authorization: Bearer + */ +async function adminAuthMiddleware(req, res, next) { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'Missing or invalid Authorization header', + code: 'MISSING_AUTH' + }); + } + + const apiKey = authHeader.substring(7); // Remove 'Bearer ' + const commands = createAdminScriptCommands(); + const result = await commands.validateAdminApiKey(apiKey); + + if (result.error) { + return res.status(result.error).json({ + error: result.reason, + code: result.code + }); + } + + // Attach validated key info to request for audit trail + req.adminApiKey = result.apiKey; + req.adminAudit = { + apiKeyName: result.apiKey.name, + apiKeyLast4: result.apiKey.keyLast4, + ipAddress: req.ip || req.connection?.remoteAddress || 'unknown' + }; + + next(); + } catch (error) { + console.error('Admin auth middleware error:', error); + res.status(500).json({ + error: 'Authentication failed', + code: 'AUTH_ERROR' + }); + } +} + +module.exports = { adminAuthMiddleware }; diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js new file mode 100644 index 000000000..6308fdb55 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -0,0 +1,191 @@ +const express = require('express'); +const serverless = require('serverless-http'); +const { adminAuthMiddleware } = require('./admin-auth-middleware'); +const { getScriptFactory } = require('../application/script-factory'); +const { createScriptRunner } = require('../application/script-runner'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); +const { QueuerUtil } = require('@friggframework/core/queues'); + +const router = express.Router(); + +// Apply auth middleware to all admin routes +router.use(adminAuthMiddleware); + +/** + * GET /admin/scripts + * List all registered scripts + */ +router.get('/scripts', async (req, res) => { + try { + const factory = getScriptFactory(); + const scripts = factory.getAll(); + + res.json({ + scripts: scripts.map((s) => ({ + name: s.name, + version: s.definition.version, + description: s.definition.description, + category: s.definition.display?.category || 'custom', + requiresIntegrationFactory: + s.definition.config?.requiresIntegrationFactory || false, + schedule: s.definition.schedule || null, + })), + }); + } catch (error) { + console.error('Error listing scripts:', error); + res.status(500).json({ error: 'Failed to list scripts' }); + } +}); + +/** + * GET /admin/scripts/:scriptName + * Get script details + */ +router.get('/scripts/:scriptName', async (req, res) => { + try { + const { scriptName } = req.params; + const factory = getScriptFactory(); + + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + const scriptClass = factory.get(scriptName); + const definition = scriptClass.Definition; + + res.json({ + name: definition.name, + version: definition.version, + description: definition.description, + inputSchema: definition.inputSchema, + outputSchema: definition.outputSchema, + config: definition.config, + display: definition.display, + schedule: definition.schedule, + }); + } catch (error) { + console.error('Error getting script:', error); + res.status(500).json({ error: 'Failed to get script details' }); + } +}); + +/** + * POST /admin/scripts/:scriptName/execute + * Execute a script (sync or async) + */ +router.post('/scripts/:scriptName/execute', async (req, res) => { + try { + const { scriptName } = req.params; + const { params = {}, mode = 'async' } = req.body; + const factory = getScriptFactory(); + + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + if (mode === 'sync') { + // Synchronous execution - wait for result + const runner = createScriptRunner(); + const result = await runner.execute(scriptName, params, { + trigger: 'MANUAL', + mode: 'sync', + audit: req.adminAudit, + }); + return res.json(result); + } + + // Async execution - queue and return immediately + const commands = createAdminScriptCommands(); + const execution = await commands.createScriptExecution({ + scriptName, + scriptVersion: factory.get(scriptName).Definition.version, + trigger: 'MANUAL', + mode: 'async', + input: params, + audit: req.adminAudit, + }); + + // Queue the execution + await QueuerUtil.send( + { + scriptName, + executionId: execution.id, + trigger: 'MANUAL', + params, + }, + process.env.ADMIN_SCRIPT_QUEUE_URL + ); + + res.status(202).json({ + executionId: execution.id, + status: 'PENDING', + scriptName, + message: 'Script queued for execution', + }); + } catch (error) { + console.error('Error executing script:', error); + res.status(500).json({ error: 'Failed to execute script' }); + } +}); + +/** + * GET /admin/executions/:executionId + * Get execution status + */ +router.get('/executions/:executionId', async (req, res) => { + try { + const { executionId } = req.params; + const commands = createAdminScriptCommands(); + const execution = await commands.findScriptExecutionById(executionId); + + if (execution.error) { + return res.status(execution.error).json({ + error: execution.reason, + code: execution.code, + }); + } + + res.json(execution); + } catch (error) { + console.error('Error getting execution:', error); + res.status(500).json({ error: 'Failed to get execution' }); + } +}); + +/** + * GET /admin/executions + * List recent executions + */ +router.get('/executions', async (req, res) => { + try { + const { scriptName, status, limit = 50 } = req.query; + const commands = createAdminScriptCommands(); + + const executions = await commands.findRecentExecutions({ + scriptName, + status, + limit: parseInt(limit, 10), + }); + + res.json({ executions }); + } catch (error) { + console.error('Error listing executions:', error); + res.status(500).json({ error: 'Failed to list executions' }); + } +}); + +// Create Express app +const app = express(); +app.use(express.json()); +app.use('/admin', router); + +// Export for Lambda +const handler = serverless(app); + +module.exports = { router, app, handler }; diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js new file mode 100644 index 000000000..41778571d --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -0,0 +1,75 @@ +const { createScriptRunner } = require('../application/script-runner'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +/** + * SQS Queue Worker Lambda Handler + * + * Processes script execution messages from AdminScriptQueue + */ +async function handler(event) { + const results = []; + + for (const record of event.Records) { + const message = JSON.parse(record.body); + const { scriptName, executionId, trigger, params } = message; + + console.log(`Processing script: ${scriptName}, executionId: ${executionId}`); + + try { + const runner = createScriptRunner(); + const commands = createAdminScriptCommands(); + + // If executionId provided (async from API), update existing record + if (executionId) { + await commands.updateScriptExecutionStatus(executionId, 'RUNNING'); + } + + const result = await runner.execute(scriptName, params, { + trigger: trigger || 'QUEUE', + mode: 'async', + executionId, // Reuse existing if provided + }); + + console.log( + `Script completed: ${scriptName}, status: ${result.status}` + ); + results.push({ + scriptName, + status: result.status, + executionId: result.executionId, + }); + } catch (error) { + console.error(`Script failed: ${scriptName}`, error); + + // Try to update execution status if we have an ID + if (executionId) { + const commands = createAdminScriptCommands(); + await commands + .completeScriptExecution(executionId, { + status: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }) + .catch((e) => + console.error('Failed to update execution:', e) + ); + } + + results.push({ + scriptName, + status: 'FAILED', + error: error.message, + }); + } + } + + return { + statusCode: 200, + body: JSON.stringify({ processed: results.length, results }), + }; +} + +module.exports = { handler }; From 45fb9dded672b31f95650dd1d23437491b399439 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 19:40:51 +0000 Subject: [PATCH 08/33] feat(admin-scripts): add built-in scripts for OAuth refresh and health check Built-in Scripts: - OAuthTokenRefreshScript: Refreshes OAuth tokens near expiry - Configurable expiry threshold (default 24h) - Dry-run mode for safe testing - Filters by integration IDs or all - IntegrationHealthCheckScript: Checks integration health - Validates credential presence and expiry - Tests API connectivity - Optionally updates integration status - Schedule-ready (daily cron expression) Both scripts: - Extend AdminScriptBase with Definition pattern - Use AdminFriggCommands for database/API access - Include JSON Schema for input/output validation - Comprehensive error handling and logging Test Coverage: 41 tests passing for built-in scripts --- packages/admin-scripts/index.js | 15 +- .../integration-health-check.test.js | 598 ++++++++++++++++++ .../__tests__/oauth-token-refresh.test.js | 344 ++++++++++ packages/admin-scripts/src/builtins/index.js | 28 + .../src/builtins/integration-health-check.js | 252 ++++++++ .../src/builtins/oauth-token-refresh.js | 215 +++++++ 6 files changed, 1450 insertions(+), 2 deletions(-) create mode 100644 packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js create mode 100644 packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js create mode 100644 packages/admin-scripts/src/builtins/index.js create mode 100644 packages/admin-scripts/src/builtins/integration-health-check.js create mode 100644 packages/admin-scripts/src/builtins/oauth-token-refresh.js diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index ef8fa3725..255b3cace 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -21,8 +21,13 @@ const { adminAuthMiddleware } = require('./src/infrastructure/admin-auth-middlew const { router, app, handler: routerHandler } = require('./src/infrastructure/admin-script-router'); const { handler: executorHandler } = require('./src/infrastructure/script-executor-handler'); -// Built-in Scripts (TODO: implement these) -// const builtinScripts = require('./src/builtins'); +// Built-in Scripts +const { + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, + builtinScripts, + registerBuiltinScripts, +} = require('./src/builtins'); // Factory function for creating the admin backend (TODO: implement when infrastructure is ready) // function createAdminBackend(params) { @@ -73,4 +78,10 @@ module.exports = { app, routerHandler, executorHandler, + + // Built-in scripts + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, + builtinScripts, + registerBuiltinScripts, }; diff --git a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js new file mode 100644 index 000000000..f9422e12e --- /dev/null +++ b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js @@ -0,0 +1,598 @@ +const { IntegrationHealthCheckScript } = require('../integration-health-check'); + +describe('IntegrationHealthCheckScript', () => { + describe('Definition', () => { + it('should have correct name and metadata', () => { + expect(IntegrationHealthCheckScript.Definition.name).toBe('integration-health-check'); + expect(IntegrationHealthCheckScript.Definition.version).toBe('1.0.0'); + expect(IntegrationHealthCheckScript.Definition.source).toBe('BUILTIN'); + expect(IntegrationHealthCheckScript.Definition.config.requiresIntegrationFactory).toBe(true); + }); + + it('should have valid input schema', () => { + const schema = IntegrationHealthCheckScript.Definition.inputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.integrationIds).toBeDefined(); + expect(schema.properties.checkCredentials).toBeDefined(); + expect(schema.properties.checkConnectivity).toBeDefined(); + expect(schema.properties.updateStatus).toBeDefined(); + }); + + it('should have valid output schema', () => { + const schema = IntegrationHealthCheckScript.Definition.outputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.healthy).toBeDefined(); + expect(schema.properties.unhealthy).toBeDefined(); + expect(schema.properties.unknown).toBeDefined(); + expect(schema.properties.results).toBeDefined(); + }); + + it('should have schedule configuration', () => { + const schedule = IntegrationHealthCheckScript.Definition.schedule; + expect(schedule).toBeDefined(); + expect(schedule.enabled).toBe(false); + expect(schedule.cronExpression).toBe('cron(0 6 * * ? *)'); + }); + + it('should have appropriate timeout configuration', () => { + expect(IntegrationHealthCheckScript.Definition.config.timeout).toBe(900000); // 15 minutes + }); + }); + + describe('execute()', () => { + let script; + let mockFrigg; + + beforeEach(() => { + script = new IntegrationHealthCheckScript(); + mockFrigg = { + log: jest.fn(), + listIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + instantiate: jest.fn(), + updateIntegrationStatus: jest.fn(), + }; + }); + + it('should return empty results when no integrations found', async () => { + mockFrigg.listIntegrations.mockResolvedValue([]); + + const result = await script.execute(mockFrigg, {}); + + expect(result.healthy).toBe(0); + expect(result.unhealthy).toBe(0); + expect(result.unknown).toBe(0); + expect(result.results).toEqual([]); + }); + + it('should return healthy for valid integrations', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: true + }); + + expect(result.healthy).toBe(1); + expect(result.unhealthy).toBe(0); + expect(result.results[0]).toMatchObject({ + integrationId: 'int-1', + status: 'healthy', + issues: [] + }); + expect(mockInstance.primary.api.getAuthenticationInfo).toHaveBeenCalled(); + }); + + it('should return unhealthy for missing access token', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: {} // No access_token + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: false + }); + + expect(result.healthy).toBe(0); + expect(result.unhealthy).toBe(1); + expect(result.results[0]).toMatchObject({ + integrationId: 'int-1', + status: 'unhealthy', + issues: ['Missing access token'] + }); + }); + + it('should return unhealthy for expired credentials', async () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: pastDate.toISOString() + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: false + }); + + expect(result.unhealthy).toBe(1); + expect(result.results[0]).toMatchObject({ + integrationId: 'int-1', + status: 'unhealthy', + issues: ['Access token expired'] + }); + }); + + it('should return unhealthy for connectivity failures', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockRejectedValue(new Error('Network error')) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: true + }); + + expect(result.unhealthy).toBe(1); + expect(result.results[0].status).toBe('unhealthy'); + expect(result.results[0].issues).toContainEqual(expect.stringContaining('API connectivity failed')); + }); + + it('should update integration status when updateStatus is true', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockFrigg.updateIntegrationStatus.mockResolvedValue(undefined); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: true, + updateStatus: true + }); + + expect(result.healthy).toBe(1); + expect(mockFrigg.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ACTIVE'); + }); + + it('should update integration status to ERROR for unhealthy integrations', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: {} // Missing credentials + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.updateIntegrationStatus.mockResolvedValue(undefined); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: false, + updateStatus: true + }); + + expect(result.unhealthy).toBe(1); + expect(mockFrigg.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ERROR'); + }); + + it('should not update status when updateStatus is false', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: true, + updateStatus: false + }); + + expect(mockFrigg.updateIntegrationStatus).not.toHaveBeenCalled(); + }); + + it('should handle status update failures gracefully', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockFrigg.updateIntegrationStatus.mockRejectedValue(new Error('Update failed')); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: true, + updateStatus: true + }); + + expect(result.healthy).toBe(1); // Should still report healthy + expect(mockFrigg.log).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Failed to update status'), + expect.any(Object) + ); + }); + + it('should filter by specific integration IDs', async () => { + const integration1 = { + id: 'int-1', + config: { type: 'hubspot', credentials: { access_token: 'token1' } } + }; + const integration2 = { + id: 'int-2', + config: { type: 'salesforce', credentials: { access_token: 'token2' } } + }; + + mockFrigg.findIntegrationById.mockImplementation((id) => { + if (id === 'int-1') return Promise.resolve(integration1); + if (id === 'int-2') return Promise.resolve(integration2); + return Promise.reject(new Error('Not found')); + }); + + const result = await script.execute(mockFrigg, { + integrationIds: ['int-1', 'int-2'], + checkCredentials: true, + checkConnectivity: false + }); + + expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockFrigg.listIntegrations).not.toHaveBeenCalled(); + expect(result.results).toHaveLength(2); + }); + + it('should handle errors when checking integrations', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockRejectedValue(new Error('Instantiation failed')); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: true + }); + + // Should still complete but mark as unknown or unhealthy + expect(result.results).toHaveLength(1); + expect(result.results[0].integrationId).toBe('int-1'); + }); + + it('should skip credential check when checkCredentials is false', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: {} // Missing credentials, but check is disabled + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute(mockFrigg, { + checkCredentials: false, + checkConnectivity: true + }); + + expect(result.results[0].checks.credentials).toBeUndefined(); + expect(result.results[0].checks.connectivity).toBeDefined(); + }); + + it('should skip connectivity check when checkConnectivity is false', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + + const result = await script.execute(mockFrigg, { + checkCredentials: true, + checkConnectivity: false + }); + + expect(result.results[0].checks.credentials).toBeDefined(); + expect(result.results[0].checks.connectivity).toBeUndefined(); + expect(mockFrigg.instantiate).not.toHaveBeenCalled(); + }); + }); + + describe('checkCredentialValidity()', () => { + let script; + + beforeEach(() => { + script = new IntegrationHealthCheckScript(); + }); + + it('should return valid for integrations with valid credentials', () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + }); + + it('should return invalid for missing access token', () => { + const integration = { + id: 'int-1', + config: { + credentials: {} + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(false); + expect(result.issue).toBe('Missing access token'); + }); + + it('should return invalid for expired tokens', () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() - 1000).toISOString() // Expired + } + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(false); + expect(result.issue).toBe('Access token expired'); + }); + + it('should return valid for credentials without expiry', () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123' + // No expires_at + } + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + }); + }); + + describe('checkApiConnectivity()', () => { + let script; + let mockFrigg; + + beforeEach(() => { + script = new IntegrationHealthCheckScript(); + mockFrigg = { + instantiate: jest.fn(), + }; + }); + + it('should return valid for successful API calls', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(mockFrigg, integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + }); + + it('should try getCurrentUser if getAuthenticationInfo is not available', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: { + getCurrentUser: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(mockFrigg, integration); + + expect(result.valid).toBe(true); + expect(mockInstance.primary.api.getCurrentUser).toHaveBeenCalled(); + }); + + it('should return note when no health check endpoint is available', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: {} // No health check methods + } + }; + + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(mockFrigg, integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + expect(result.note).toBe('No health check endpoint available'); + }); + + it('should return invalid for API failures', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockRejectedValue(new Error('Network error')) + } + } + }; + + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(mockFrigg, integration); + + expect(result.valid).toBe(false); + expect(result.issue).toContain('API connectivity failed'); + expect(result.issue).toContain('Network error'); + }); + }); +}); diff --git a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js new file mode 100644 index 000000000..9de4b191a --- /dev/null +++ b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js @@ -0,0 +1,344 @@ +const { OAuthTokenRefreshScript } = require('../oauth-token-refresh'); + +describe('OAuthTokenRefreshScript', () => { + describe('Definition', () => { + it('should have correct name and metadata', () => { + expect(OAuthTokenRefreshScript.Definition.name).toBe('oauth-token-refresh'); + expect(OAuthTokenRefreshScript.Definition.version).toBe('1.0.0'); + expect(OAuthTokenRefreshScript.Definition.source).toBe('BUILTIN'); + expect(OAuthTokenRefreshScript.Definition.config.requiresIntegrationFactory).toBe(true); + }); + + it('should have valid input schema', () => { + const schema = OAuthTokenRefreshScript.Definition.inputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.integrationIds).toBeDefined(); + expect(schema.properties.expiryThresholdHours).toBeDefined(); + expect(schema.properties.dryRun).toBeDefined(); + }); + + it('should have valid output schema', () => { + const schema = OAuthTokenRefreshScript.Definition.outputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.refreshed).toBeDefined(); + expect(schema.properties.failed).toBeDefined(); + expect(schema.properties.skipped).toBeDefined(); + expect(schema.properties.details).toBeDefined(); + }); + + it('should have appropriate timeout configuration', () => { + expect(OAuthTokenRefreshScript.Definition.config.timeout).toBe(600000); // 10 minutes + }); + }); + + describe('execute()', () => { + let script; + let mockFrigg; + + beforeEach(() => { + script = new OAuthTokenRefreshScript(); + mockFrigg = { + log: jest.fn(), + listIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + instantiate: jest.fn(), + }; + }); + + it('should return empty results when no integrations found', async () => { + mockFrigg.listIntegrations.mockResolvedValue([]); + + const result = await script.execute(mockFrigg, {}); + + expect(result.refreshed).toBe(0); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + expect(result.details).toEqual([]); + expect(mockFrigg.log).toHaveBeenCalledWith('info', expect.any(String), expect.any(Object)); + }); + + it('should skip integrations without OAuth credentials', async () => { + const integration = { + id: 'int-1', + config: {} // No credentials + }; + mockFrigg.listIntegrations.mockResolvedValue([integration]); + + const result = await script.execute(mockFrigg, {}); + + expect(result.skipped).toBe(1); + expect(result.refreshed).toBe(0); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'No OAuth credentials found' + }); + }); + + it('should skip integrations without expiry time', async () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123' + // No expires_at + } + } + }; + mockFrigg.listIntegrations.mockResolvedValue([integration]); + + const result = await script.execute(mockFrigg, {}); + + expect(result.skipped).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'No expiry time found' + }); + }); + + it('should skip tokens not near expiry', async () => { + const farFutureExpiry = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours from now + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: farFutureExpiry.toISOString() + } + } + }; + mockFrigg.listIntegrations.mockResolvedValue([integration]); + + const result = await script.execute(mockFrigg, { + expiryThresholdHours: 24 + }); + + expect(result.skipped).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'Token not near expiry' + }); + }); + + it('should refresh tokens that are near expiry', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); // 12 hours from now + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + refreshAccessToken: jest.fn().mockResolvedValue(undefined) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute(mockFrigg, { + expiryThresholdHours: 24 + }); + + expect(result.refreshed).toBe(1); + expect(result.skipped).toBe(0); + expect(mockInstance.primary.api.refreshAccessToken).toHaveBeenCalled(); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'refreshed' + }); + }); + + it('should handle dryRun mode correctly', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + + const result = await script.execute(mockFrigg, { + expiryThresholdHours: 24, + dryRun: true + }); + + expect(result.refreshed).toBe(0); + expect(result.skipped).toBe(1); + expect(mockFrigg.instantiate).not.toHaveBeenCalled(); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'Dry run - would have refreshed' + }); + }); + + it('should handle refresh failures gracefully', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + refreshAccessToken: jest.fn().mockRejectedValue(new Error('API Error')) + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute(mockFrigg, { + expiryThresholdHours: 24 + }); + + expect(result.failed).toBe(1); + expect(result.refreshed).toBe(0); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'failed', + reason: 'API Error' + }); + }); + + it('should skip integrations without refresh support', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + // No refreshAccessToken method + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute(mockFrigg, { + expiryThresholdHours: 24 + }); + + expect(result.skipped).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'API does not support token refresh' + }); + }); + + it('should filter by specific integration IDs', async () => { + const integration1 = { + id: 'int-1', + config: { credentials: { access_token: 'token1' } } + }; + const integration2 = { + id: 'int-2', + config: { credentials: { access_token: 'token2' } } + }; + + mockFrigg.findIntegrationById.mockImplementation((id) => { + if (id === 'int-1') return Promise.resolve(integration1); + if (id === 'int-2') return Promise.resolve(integration2); + return Promise.reject(new Error('Not found')); + }); + + const result = await script.execute(mockFrigg, { + integrationIds: ['int-1', 'int-2'] + }); + + expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockFrigg.listIntegrations).not.toHaveBeenCalled(); + expect(result.details).toHaveLength(2); + }); + + it('should handle errors when processing integrations', async () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString() + } + } + }; + + mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockFrigg.instantiate.mockRejectedValue(new Error('Instantiation failed')); + + const result = await script.execute(mockFrigg, { + expiryThresholdHours: 24 + }); + + expect(result.failed).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'failed', + reason: 'Instantiation failed' + }); + }); + }); + + describe('processIntegration()', () => { + let script; + let mockFrigg; + + beforeEach(() => { + script = new OAuthTokenRefreshScript(); + mockFrigg = { + log: jest.fn(), + instantiate: jest.fn(), + }; + }); + + it('should return correct detail object for each scenario', async () => { + // Test various scenarios are covered in execute() tests above + // This test validates the method can be called directly + const integration = { + id: 'int-1', + config: {} + }; + + const result = await script.processIntegration(mockFrigg, integration, { + expiryThresholdHours: 24, + dryRun: false + }); + + expect(result).toHaveProperty('integrationId'); + expect(result).toHaveProperty('action'); + expect(result).toHaveProperty('reason'); + }); + }); +}); diff --git a/packages/admin-scripts/src/builtins/index.js b/packages/admin-scripts/src/builtins/index.js new file mode 100644 index 000000000..03bde88bc --- /dev/null +++ b/packages/admin-scripts/src/builtins/index.js @@ -0,0 +1,28 @@ +const { OAuthTokenRefreshScript } = require('./oauth-token-refresh'); +const { IntegrationHealthCheckScript } = require('./integration-health-check'); + +/** + * Built-in Admin Scripts + * + * These scripts ship with @friggframework/admin-scripts and provide + * common maintenance and monitoring functionality. + */ +const builtinScripts = [ + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, +]; + +/** + * Register all built-in scripts with a factory + * @param {ScriptFactory} factory - Script factory to register with + */ +function registerBuiltinScripts(factory) { + factory.registerAll(builtinScripts); +} + +module.exports = { + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, + builtinScripts, + registerBuiltinScripts, +}; diff --git a/packages/admin-scripts/src/builtins/integration-health-check.js b/packages/admin-scripts/src/builtins/integration-health-check.js new file mode 100644 index 000000000..7ab904ae1 --- /dev/null +++ b/packages/admin-scripts/src/builtins/integration-health-check.js @@ -0,0 +1,252 @@ +const { AdminScriptBase } = require('../application/admin-script-base'); + +/** + * Integration Health Check Script + * + * Checks the health of integrations by verifying: + * - Credential validity + * - API connectivity + * - Configuration integrity + */ +class IntegrationHealthCheckScript extends AdminScriptBase { + static Definition = { + name: 'integration-health-check', + version: '1.0.0', + description: 'Checks health of integrations and reports issues', + source: 'BUILTIN', + + inputSchema: { + type: 'object', + properties: { + integrationIds: { + type: 'array', + items: { type: 'string' }, + description: 'Specific integration IDs to check (optional, defaults to all)' + }, + checkCredentials: { + type: 'boolean', + default: true, + description: 'Verify credential validity' + }, + checkConnectivity: { + type: 'boolean', + default: true, + description: 'Test API connectivity' + }, + updateStatus: { + type: 'boolean', + default: false, + description: 'Update integration status based on health' + } + } + }, + + outputSchema: { + type: 'object', + properties: { + healthy: { type: 'number' }, + unhealthy: { type: 'number' }, + unknown: { type: 'number' }, + results: { type: 'array' } + } + }, + + config: { + timeout: 900000, // 15 minutes + maxRetries: 0, + requiresIntegrationFactory: true, + }, + + schedule: { + enabled: false, // Can be enabled via API + cronExpression: 'cron(0 6 * * ? *)', // Daily at 6 AM UTC + }, + + display: { + label: 'Integration Health Check', + description: 'Check health and connectivity of integrations', + category: 'maintenance', + }, + }; + + async execute(frigg, params = {}) { + const { + integrationIds = null, + checkCredentials = true, + checkConnectivity = true, + updateStatus = false + } = params; + + const summary = { + healthy: 0, + unhealthy: 0, + unknown: 0, + results: [] + }; + + frigg.log('info', 'Starting integration health check', { + checkCredentials, + checkConnectivity, + updateStatus, + specificIds: integrationIds?.length || 'all' + }); + + // Get integrations to check + let integrations; + if (integrationIds && integrationIds.length > 0) { + integrations = await Promise.all( + integrationIds.map(id => frigg.findIntegrationById(id).catch(() => null)) + ); + integrations = integrations.filter(Boolean); + } else { + integrations = await this.getAllIntegrations(frigg); + } + + frigg.log('info', `Checking ${integrations.length} integrations`); + + for (const integration of integrations) { + const result = await this.checkIntegration(frigg, integration, { + checkCredentials, + checkConnectivity + }); + + summary.results.push(result); + + if (result.status === 'healthy') { + summary.healthy++; + } else if (result.status === 'unhealthy') { + summary.unhealthy++; + } else { + summary.unknown++; + } + + // Optionally update integration status + if (updateStatus && result.status !== 'unknown') { + try { + const newStatus = result.status === 'healthy' ? 'ACTIVE' : 'ERROR'; + await frigg.updateIntegrationStatus(integration.id, newStatus); + frigg.log('info', `Updated status for ${integration.id} to ${newStatus}`); + } catch (error) { + frigg.log('warn', `Failed to update status for ${integration.id}`, { + error: error.message + }); + } + } + } + + frigg.log('info', 'Health check completed', { + healthy: summary.healthy, + unhealthy: summary.unhealthy, + unknown: summary.unknown + }); + + return summary; + } + + async getAllIntegrations(frigg) { + return frigg.listIntegrations({}); + } + + async checkIntegration(frigg, integration, options) { + const { checkCredentials, checkConnectivity } = options; + + const result = { + integrationId: integration.id, + integrationType: integration.config?.type || 'unknown', + status: 'unknown', + checks: {}, + issues: [] + }; + + try { + // Check credentials + if (checkCredentials) { + const credCheck = this.checkCredentialValidity(integration); + result.checks.credentials = credCheck; + if (!credCheck.valid) { + result.issues.push(credCheck.issue); + } + } + + // Check connectivity + if (checkConnectivity) { + const connCheck = await this.checkApiConnectivity(frigg, integration); + result.checks.connectivity = connCheck; + if (!connCheck.valid) { + result.issues.push(connCheck.issue); + } + } + + // Determine overall status + if (result.issues.length === 0) { + result.status = 'healthy'; + } else { + result.status = 'unhealthy'; + } + + } catch (error) { + frigg.log('error', `Error checking integration ${integration.id}`, { + error: error.message + }); + result.status = 'unknown'; + result.issues.push(`Check failed: ${error.message}`); + } + + return result; + } + + checkCredentialValidity(integration) { + const result = { valid: true, issue: null }; + + // Check for access token + if (!integration.config?.credentials?.access_token) { + result.valid = false; + result.issue = 'Missing access token'; + return result; + } + + // Check for expiry + const expiresAt = integration.config?.credentials?.expires_at; + if (expiresAt) { + const expiryTime = new Date(expiresAt); + if (expiryTime < new Date()) { + result.valid = false; + result.issue = 'Access token expired'; + return result; + } + } + + return result; + } + + async checkApiConnectivity(frigg, integration) { + const result = { valid: true, issue: null, responseTime: null }; + + try { + const startTime = Date.now(); + const instance = await frigg.instantiate(integration.id); + + // Try to make a simple API call + if (instance.primary?.api?.getAuthenticationInfo) { + await instance.primary.api.getAuthenticationInfo(); + } else if (instance.primary?.api?.getCurrentUser) { + await instance.primary.api.getCurrentUser(); + } else { + // No suitable health check method + result.valid = true; + result.issue = null; + result.note = 'No health check endpoint available'; + return result; + } + + result.responseTime = Date.now() - startTime; + } catch (error) { + result.valid = false; + result.issue = `API connectivity failed: ${error.message}`; + } + + return result; + } +} + +module.exports = { IntegrationHealthCheckScript }; diff --git a/packages/admin-scripts/src/builtins/oauth-token-refresh.js b/packages/admin-scripts/src/builtins/oauth-token-refresh.js new file mode 100644 index 000000000..ecc321769 --- /dev/null +++ b/packages/admin-scripts/src/builtins/oauth-token-refresh.js @@ -0,0 +1,215 @@ +const { AdminScriptBase } = require('../application/admin-script-base'); + +/** + * OAuth Token Refresh Script + * + * Refreshes OAuth tokens for integrations that are near expiry. + * This helps prevent authentication failures due to expired tokens. + */ +class OAuthTokenRefreshScript extends AdminScriptBase { + static Definition = { + name: 'oauth-token-refresh', + version: '1.0.0', + description: 'Refreshes OAuth tokens for integrations near expiry', + source: 'BUILTIN', + + inputSchema: { + type: 'object', + properties: { + integrationIds: { + type: 'array', + items: { type: 'string' }, + description: 'Specific integration IDs to refresh (optional, defaults to all)' + }, + expiryThresholdHours: { + type: 'number', + default: 24, + description: 'Refresh tokens expiring within this many hours' + }, + dryRun: { + type: 'boolean', + default: false, + description: 'Preview without making changes' + } + } + }, + + outputSchema: { + type: 'object', + properties: { + refreshed: { type: 'number' }, + failed: { type: 'number' }, + skipped: { type: 'number' }, + details: { type: 'array' } + } + }, + + config: { + timeout: 600000, // 10 minutes + maxRetries: 1, + requiresIntegrationFactory: true, // Needs to call external APIs + }, + + display: { + label: 'OAuth Token Refresh', + description: 'Refresh OAuth tokens before they expire', + category: 'maintenance', + }, + }; + + async execute(frigg, params = {}) { + const { + integrationIds = null, + expiryThresholdHours = 24, + dryRun = false + } = params; + + const results = { + refreshed: 0, + failed: 0, + skipped: 0, + details: [] + }; + + frigg.log('info', 'Starting OAuth token refresh', { + expiryThresholdHours, + dryRun, + specificIds: integrationIds?.length || 'all' + }); + + // Get integrations to check + let integrations; + if (integrationIds && integrationIds.length > 0) { + integrations = await Promise.all( + integrationIds.map(id => frigg.findIntegrationById(id).catch(() => null)) + ); + integrations = integrations.filter(Boolean); + } else { + // Get all integrations (this would need to be paginated for large deployments) + integrations = await this.getAllIntegrations(frigg); + } + + frigg.log('info', `Found ${integrations.length} integrations to check`); + + for (const integration of integrations) { + try { + const detail = await this.processIntegration(frigg, integration, { + expiryThresholdHours, + dryRun + }); + + results.details.push(detail); + + if (detail.action === 'refreshed') { + results.refreshed++; + } else if (detail.action === 'skipped') { + results.skipped++; + } else if (detail.action === 'failed') { + results.failed++; + } + } catch (error) { + frigg.log('error', `Error processing integration ${integration.id}`, { + error: error.message + }); + results.failed++; + results.details.push({ + integrationId: integration.id, + action: 'failed', + reason: error.message + }); + } + } + + frigg.log('info', 'OAuth token refresh completed', { + refreshed: results.refreshed, + failed: results.failed, + skipped: results.skipped + }); + + return results; + } + + async getAllIntegrations(frigg) { + // This is a simplified implementation + // In production, would need pagination for large datasets + return frigg.listIntegrations({}); + } + + async processIntegration(frigg, integration, options) { + const { expiryThresholdHours, dryRun } = options; + + // Check if integration has OAuth credentials + if (!integration.config?.credentials?.access_token) { + return { + integrationId: integration.id, + action: 'skipped', + reason: 'No OAuth credentials found' + }; + } + + // Check token expiry + const expiresAt = integration.config?.credentials?.expires_at; + if (!expiresAt) { + return { + integrationId: integration.id, + action: 'skipped', + reason: 'No expiry time found' + }; + } + + const expiryTime = new Date(expiresAt); + const thresholdTime = new Date(Date.now() + (expiryThresholdHours * 60 * 60 * 1000)); + + if (expiryTime > thresholdTime) { + return { + integrationId: integration.id, + action: 'skipped', + reason: 'Token not near expiry', + expiresAt: expiresAt + }; + } + + if (dryRun) { + frigg.log('info', `[DRY RUN] Would refresh token for ${integration.id}`); + return { + integrationId: integration.id, + action: 'skipped', + reason: 'Dry run - would have refreshed' + }; + } + + // Refresh the token + try { + const instance = await frigg.instantiate(integration.id); + + // Call refresh on the primary API + if (instance.primary?.api?.refreshAccessToken) { + await instance.primary.api.refreshAccessToken(); + + frigg.log('info', `Refreshed token for integration ${integration.id}`); + return { + integrationId: integration.id, + action: 'refreshed', + previousExpiry: expiresAt + }; + } else { + return { + integrationId: integration.id, + action: 'skipped', + reason: 'API does not support token refresh' + }; + } + } catch (error) { + frigg.log('error', `Failed to refresh token for ${integration.id}`, { + error: error.message + }); + return { + integrationId: integration.id, + action: 'failed', + reason: error.message + }; + } + } +} + +module.exports = { OAuthTokenRefreshScript }; From c104963c6fc0bd02d3644d1dee55108bd3e71473 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 19:43:13 +0000 Subject: [PATCH 09/33] fix(admin-scripts): prevent array mutation in appendExecutionLog Fixed a bug where the logs array from Prisma was being mutated directly instead of creating a copy first. This caused test failures when the original array reference was used for comparison. --- .../repositories/script-execution-repository-mongo.js | 4 ++-- .../repositories/script-execution-repository-postgres.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js b/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js index 64ba9f561..530f20a0d 100644 --- a/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js +++ b/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js @@ -219,8 +219,8 @@ class ScriptExecutionRepositoryMongo extends ScriptExecutionRepositoryInterface throw new Error(`Execution ${id} not found`); } - // Append log entry to logs array - const logs = Array.isArray(execution.logs) ? execution.logs : []; + // Append log entry to logs array (copy to avoid mutating original) + const logs = Array.isArray(execution.logs) ? [...execution.logs] : []; logs.push(logEntry); // Update with new logs array diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js b/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js index 79798738e..8e88e1328 100644 --- a/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js +++ b/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js @@ -257,8 +257,8 @@ class ScriptExecutionRepositoryPostgres extends ScriptExecutionRepositoryInterfa throw new Error(`Execution ${id} not found`); } - // Append log entry to logs array - const logs = Array.isArray(execution.logs) ? execution.logs : []; + // Append log entry to logs array (copy to avoid mutating original) + const logs = Array.isArray(execution.logs) ? [...execution.logs] : []; logs.push(logEntry); // Update with new logs array From 5d1f1ac5df3ff47ae924595935a04792d022e768 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 20:43:59 +0000 Subject: [PATCH 10/33] feat(admin-scripts): add hybrid scheduling and dry-run mode (Phase 2-3) Phase 2 - Hybrid Scheduling: - ScriptSchedule Prisma model (MongoDB + PostgreSQL) - ScriptSchedule repository implementations with factory - SchedulerAdapter port interface (hexagonal pattern) - AWSSchedulerAdapter for EventBridge Scheduler - LocalSchedulerAdapter for dev/test - Schedule management API endpoints: - GET /scripts/:name/schedule (DB override > Definition default) - PUT /scripts/:name/schedule (create/update override) - DELETE /scripts/:name/schedule (revert to default) - Schedule commands in admin-script-commands.js Phase 3 - Dry-Run Mode: - DryRunRepositoryWrapper: Proxy-based write interception - DryRunHttpInterceptor: Mock HTTP client with service detection - Automatic sanitization of sensitive data in logs - Returns preview with operation log and summary - POST /execute { dryRun: true } support Features: - 20+ external service detection (HubSpot, Salesforce, Slack, etc.) - Smart read vs write operation detection - Timezone-aware scheduling - AWS EventBridge rule tracking (ruleArn, ruleName) Test Coverage: 424 tests passing (141 new tests added) --- packages/admin-scripts/index.js | 16 + .../__tests__/aws-scheduler-adapter.test.js | 322 +++++++++++++++++ .../__tests__/local-scheduler-adapter.test.js | 325 ++++++++++++++++++ .../scheduler-adapter-factory.test.js | 257 ++++++++++++++ .../__tests__/scheduler-adapter.test.js | 103 ++++++ .../src/adapters/aws-scheduler-adapter.js | 138 ++++++++ .../src/adapters/local-scheduler-adapter.js | 103 ++++++ .../src/adapters/scheduler-adapter-factory.js | 69 ++++ .../src/adapters/scheduler-adapter.js | 64 ++++ .../dry-run-http-interceptor.test.js | 313 +++++++++++++++++ .../dry-run-repository-wrapper.test.js | 257 ++++++++++++++ .../application/dry-run-http-interceptor.js | 296 ++++++++++++++++ .../application/dry-run-repository-wrapper.js | 261 ++++++++++++++ .../src/application/script-runner.js | 176 ++++++++-- .../__tests__/admin-script-router.test.js | 253 ++++++++++++++ .../src/infrastructure/admin-script-router.js | 202 ++++++++++- packages/core/admin-scripts/index.js | 12 + ...ript-schedule-repository-interface.test.js | 119 +++++++ .../script-schedule-repository-documentdb.js | 21 ++ .../script-schedule-repository-factory.js | 51 +++ .../script-schedule-repository-interface.js | 108 ++++++ .../script-schedule-repository-mongo.js | 179 ++++++++++ .../script-schedule-repository-postgres.js | 210 +++++++++++ .../commands/admin-script-commands.js | 116 +++++++ packages/core/prisma-mongodb/schema.prisma | 21 ++ packages/core/prisma-postgresql/schema.prisma | 20 ++ 26 files changed, 3978 insertions(+), 34 deletions(-) create mode 100644 packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js create mode 100644 packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js create mode 100644 packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js create mode 100644 packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js create mode 100644 packages/admin-scripts/src/adapters/aws-scheduler-adapter.js create mode 100644 packages/admin-scripts/src/adapters/local-scheduler-adapter.js create mode 100644 packages/admin-scripts/src/adapters/scheduler-adapter-factory.js create mode 100644 packages/admin-scripts/src/adapters/scheduler-adapter.js create mode 100644 packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js create mode 100644 packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js create mode 100644 packages/admin-scripts/src/application/dry-run-http-interceptor.js create mode 100644 packages/admin-scripts/src/application/dry-run-repository-wrapper.js create mode 100644 packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js create mode 100644 packages/core/admin-scripts/repositories/script-schedule-repository-documentdb.js create mode 100644 packages/core/admin-scripts/repositories/script-schedule-repository-factory.js create mode 100644 packages/core/admin-scripts/repositories/script-schedule-repository-interface.js create mode 100644 packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js create mode 100644 packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index 255b3cace..8ce179f6c 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -29,6 +29,15 @@ const { registerBuiltinScripts, } = require('./src/builtins'); +// Adapters +const { SchedulerAdapter } = require('./src/adapters/scheduler-adapter'); +const { AWSSchedulerAdapter } = require('./src/adapters/aws-scheduler-adapter'); +const { LocalSchedulerAdapter } = require('./src/adapters/local-scheduler-adapter'); +const { + createSchedulerAdapter, + detectSchedulerAdapterType, +} = require('./src/adapters/scheduler-adapter-factory'); + // Factory function for creating the admin backend (TODO: implement when infrastructure is ready) // function createAdminBackend(params) { // const { @@ -84,4 +93,11 @@ module.exports = { IntegrationHealthCheckScript, builtinScripts, registerBuiltinScripts, + + // Adapters + SchedulerAdapter, + AWSSchedulerAdapter, + LocalSchedulerAdapter, + createSchedulerAdapter, + detectSchedulerAdapterType, }; diff --git a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js new file mode 100644 index 000000000..2fb518682 --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js @@ -0,0 +1,322 @@ +const { AWSSchedulerAdapter } = require('../aws-scheduler-adapter'); +const { SchedulerAdapter } = require('../scheduler-adapter'); + +// Mock AWS SDK +jest.mock('@aws-sdk/client-scheduler', () => { + const mockSend = jest.fn(); + + return { + SchedulerClient: jest.fn(() => ({ + send: mockSend, + })), + CreateScheduleCommand: jest.fn((params) => ({ _type: 'CreateScheduleCommand', params })), + DeleteScheduleCommand: jest.fn((params) => ({ _type: 'DeleteScheduleCommand', params })), + GetScheduleCommand: jest.fn((params) => ({ _type: 'GetScheduleCommand', params })), + UpdateScheduleCommand: jest.fn((params) => ({ _type: 'UpdateScheduleCommand', params })), + ListSchedulesCommand: jest.fn((params) => ({ _type: 'ListSchedulesCommand', params })), + _mockSend: mockSend, + }; +}); + +describe('AWSSchedulerAdapter', () => { + let adapter; + let mockSend; + let originalEnv; + + beforeAll(() => { + originalEnv = { ...process.env }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset environment variables + process.env.AWS_REGION = 'us-east-1'; + process.env.SCHEDULE_GROUP_NAME = 'test-schedule-group'; + process.env.SCHEDULER_ROLE_ARN = 'arn:aws:iam::123456789012:role/test-role'; + process.env.ADMIN_SCRIPT_LAMBDA_ARN = 'arn:aws:lambda:us-east-1:123456789012:function:test-executor'; + + const sdk = require('@aws-sdk/client-scheduler'); + mockSend = sdk._mockSend; + + adapter = new AWSSchedulerAdapter({ + targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor', + scheduleGroupName: 'frigg-admin-scripts', + }); + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('Inheritance', () => { + it('should extend SchedulerAdapter', () => { + expect(adapter).toBeInstanceOf(SchedulerAdapter); + }); + + it('should have correct adapter name', () => { + expect(adapter.getName()).toBe('aws-eventbridge-scheduler'); + }); + }); + + describe('Constructor', () => { + it('should use provided configuration', () => { + const customAdapter = new AWSSchedulerAdapter({ + region: 'eu-west-1', + targetLambdaArn: 'arn:aws:lambda:eu-west-1:123456789012:function:custom', + scheduleGroupName: 'custom-group', + }); + + expect(customAdapter.region).toBe('eu-west-1'); + expect(customAdapter.targetLambdaArn).toBe('arn:aws:lambda:eu-west-1:123456789012:function:custom'); + expect(customAdapter.scheduleGroupName).toBe('custom-group'); + }); + + it('should use environment variables as fallback', () => { + const envAdapter = new AWSSchedulerAdapter(); + + expect(envAdapter.region).toBe('us-east-1'); + expect(envAdapter.targetLambdaArn).toBe('arn:aws:lambda:us-east-1:123456789012:function:test-executor'); + expect(envAdapter.scheduleGroupName).toBe('test-schedule-group'); + }); + + it('should use defaults when no config or env vars', () => { + delete process.env.AWS_REGION; + delete process.env.SCHEDULE_GROUP_NAME; + delete process.env.ADMIN_SCRIPT_LAMBDA_ARN; + + const defaultAdapter = new AWSSchedulerAdapter(); + + expect(defaultAdapter.region).toBe('us-east-1'); + expect(defaultAdapter.scheduleGroupName).toBe('frigg-admin-scripts'); + }); + }); + + describe('createSchedule()', () => { + it('should create a schedule with required fields', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + const result = await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + expect(result).toEqual({ + ruleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + ruleName: 'frigg-script-test-script', + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('CreateScheduleCommand'); + expect(command.params.Name).toBe('frigg-script-test-script'); + expect(command.params.ScheduleExpression).toBe('cron(0 0 * * ? *)'); + expect(command.params.ScheduleExpressionTimezone).toBe('UTC'); + }); + + it('should create a schedule with all optional fields', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 12 * * ? *)', + timezone: 'America/New_York', + input: { key: 'value' }, + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.ScheduleExpressionTimezone).toBe('America/New_York'); + + const targetInput = JSON.parse(command.params.Target.Input); + expect(targetInput).toEqual({ + scriptName: 'test-script', + trigger: 'SCHEDULED', + params: { key: 'value' }, + }); + }); + + it('should configure target with Lambda ARN and role', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.Target.Arn).toBe('arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor'); + expect(command.params.Target.RoleArn).toBe('arn:aws:iam::123456789012:role/test-role'); + }); + + it('should enable schedule by default', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.State).toBe('ENABLED'); + }); + + it('should set flexible time window to OFF', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.FlexibleTimeWindow).toEqual({ Mode: 'OFF' }); + }); + }); + + describe('deleteSchedule()', () => { + it('should delete a schedule', async () => { + mockSend.mockResolvedValue({}); + + await adapter.deleteSchedule('test-script'); + + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('DeleteScheduleCommand'); + expect(command.params.Name).toBe('frigg-script-test-script'); + expect(command.params.GroupName).toBe('frigg-admin-scripts'); + }); + }); + + describe('setScheduleEnabled()', () => { + beforeEach(() => { + // Mock GetScheduleCommand response + mockSend.mockImplementation((command) => { + if (command._type === 'GetScheduleCommand') { + return Promise.resolve({ + Name: 'frigg-script-test-script', + GroupName: 'frigg-admin-scripts', + ScheduleExpression: 'cron(0 0 * * ? *)', + ScheduleExpressionTimezone: 'UTC', + FlexibleTimeWindow: { Mode: 'OFF' }, + Target: { + Arn: 'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor', + RoleArn: 'arn:aws:iam::123456789012:role/test-role', + Input: '{"scriptName":"test-script","trigger":"SCHEDULED","params":{}}', + }, + State: 'ENABLED', + }); + } + return Promise.resolve({}); + }); + }); + + it('should disable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', false); + + expect(mockSend).toHaveBeenCalledTimes(2); // GET then UPDATE + const updateCommand = mockSend.mock.calls[1][0]; + expect(updateCommand._type).toBe('UpdateScheduleCommand'); + expect(updateCommand.params.State).toBe('DISABLED'); + }); + + it('should enable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', true); + + expect(mockSend).toHaveBeenCalledTimes(2); // GET then UPDATE + const updateCommand = mockSend.mock.calls[1][0]; + expect(updateCommand._type).toBe('UpdateScheduleCommand'); + expect(updateCommand.params.State).toBe('ENABLED'); + }); + + it('should preserve schedule configuration when updating state', async () => { + await adapter.setScheduleEnabled('test-script', false); + + const updateCommand = mockSend.mock.calls[1][0]; + expect(updateCommand.params.ScheduleExpression).toBe('cron(0 0 * * ? *)'); + expect(updateCommand.params.ScheduleExpressionTimezone).toBe('UTC'); + expect(updateCommand.params.FlexibleTimeWindow).toEqual({ Mode: 'OFF' }); + expect(updateCommand.params.Target).toBeDefined(); + }); + }); + + describe('listSchedules()', () => { + it('should list all schedules', async () => { + const mockSchedules = [ + { Name: 'frigg-script-script-1', State: 'ENABLED' }, + { Name: 'frigg-script-script-2', State: 'DISABLED' }, + ]; + + mockSend.mockResolvedValue({ Schedules: mockSchedules }); + + const result = await adapter.listSchedules(); + + expect(result).toEqual(mockSchedules); + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('ListSchedulesCommand'); + expect(command.params.GroupName).toBe('frigg-admin-scripts'); + }); + + it('should return empty array when no schedules exist', async () => { + mockSend.mockResolvedValue({ Schedules: undefined }); + + const result = await adapter.listSchedules(); + + expect(result).toEqual([]); + }); + }); + + describe('getSchedule()', () => { + it('should get schedule details', async () => { + const mockSchedule = { + Name: 'frigg-script-test-script', + GroupName: 'frigg-admin-scripts', + ScheduleExpression: 'cron(0 0 * * ? *)', + ScheduleExpressionTimezone: 'UTC', + State: 'ENABLED', + }; + + mockSend.mockResolvedValue(mockSchedule); + + const result = await adapter.getSchedule('test-script'); + + expect(result).toEqual(mockSchedule); + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('GetScheduleCommand'); + expect(command.params.Name).toBe('frigg-script-test-script'); + expect(command.params.GroupName).toBe('frigg-admin-scripts'); + }); + }); + + describe('Lazy SDK loading', () => { + it('should load AWS SDK on first client access', () => { + const newAdapter = new AWSSchedulerAdapter({ + targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:test', + }); + + expect(newAdapter.scheduler).toBeNull(); + + newAdapter.getSchedulerClient(); + + expect(newAdapter.scheduler).toBeDefined(); + }); + + it('should reuse client after first creation', () => { + const client1 = adapter.getSchedulerClient(); + const client2 = adapter.getSchedulerClient(); + + expect(client1).toBe(client2); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js new file mode 100644 index 000000000..3719c0731 --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js @@ -0,0 +1,325 @@ +const { LocalSchedulerAdapter } = require('../local-scheduler-adapter'); +const { SchedulerAdapter } = require('../scheduler-adapter'); + +describe('LocalSchedulerAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new LocalSchedulerAdapter(); + }); + + afterEach(() => { + adapter.clear(); + }); + + describe('Inheritance', () => { + it('should extend SchedulerAdapter', () => { + expect(adapter).toBeInstanceOf(SchedulerAdapter); + }); + + it('should have correct adapter name', () => { + expect(adapter.getName()).toBe('local-cron'); + }); + }); + + describe('createSchedule()', () => { + it('should create a schedule with required fields', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + const result = await adapter.createSchedule(config); + + expect(result).toEqual({ + ruleName: 'test-script', + ruleArn: 'local:schedule:test-script', + }); + expect(adapter.size).toBe(1); + }); + + it('should create a schedule with all optional fields', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + input: { key: 'value' }, + }; + + const result = await adapter.createSchedule(config); + + expect(result).toEqual({ + ruleName: 'test-script', + ruleArn: 'local:schedule:test-script', + }); + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.ScheduleExpressionTimezone).toBe('America/New_York'); + expect(JSON.parse(schedule.Target.Input).params).toEqual({ key: 'value' }); + }); + + it('should default timezone to UTC', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + await adapter.createSchedule(config); + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.ScheduleExpressionTimezone).toBe('UTC'); + }); + + it('should enable schedule by default', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + await adapter.createSchedule(config); + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.State).toBe('ENABLED'); + }); + + it('should update existing schedule if created again', async () => { + const config1 = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + const config2 = { + scriptName: 'test-script', + cronExpression: '0 12 * * *', + }; + + await adapter.createSchedule(config1); + expect(adapter.size).toBe(1); + + await adapter.createSchedule(config2); + expect(adapter.size).toBe(1); // Still only 1 schedule + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.ScheduleExpression).toBe('0 12 * * *'); + }); + }); + + describe('deleteSchedule()', () => { + it('should delete an existing schedule', async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }); + + expect(adapter.size).toBe(1); + + await adapter.deleteSchedule('test-script'); + + expect(adapter.size).toBe(0); + }); + + it('should not throw error when deleting non-existent schedule', async () => { + await expect(adapter.deleteSchedule('non-existent')).resolves.toBeUndefined(); + }); + + it('should clear intervals if they exist', async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }); + + // Simulate an interval + const intervalId = setInterval(() => {}, 1000); + adapter.intervals.set('test-script', intervalId); + + await adapter.deleteSchedule('test-script'); + + expect(adapter.intervals.has('test-script')).toBe(false); + expect(adapter.size).toBe(0); + }); + }); + + describe('setScheduleEnabled()', () => { + beforeEach(async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }); + }); + + it('should disable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', false); + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.State).toBe('DISABLED'); + }); + + it('should enable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', false); + await adapter.setScheduleEnabled('test-script', true); + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.State).toBe('ENABLED'); + }); + + it('should throw error if schedule not found', async () => { + await expect( + adapter.setScheduleEnabled('non-existent', true) + ).rejects.toThrow('Schedule for script "non-existent" not found'); + }); + + it('should update the updatedAt timestamp', async () => { + const schedule1 = await adapter.getSchedule('test-script'); + const originalUpdatedAt = schedule1.LastModificationDate; + + // Wait a bit to ensure timestamp changes + await new Promise((resolve) => setTimeout(resolve, 10)); + + await adapter.setScheduleEnabled('test-script', false); + + const schedule2 = await adapter.getSchedule('test-script'); + expect(schedule2.LastModificationDate.getTime()).toBeGreaterThan( + originalUpdatedAt.getTime() + ); + }); + }); + + describe('listSchedules()', () => { + it('should return empty array when no schedules exist', async () => { + const schedules = await adapter.listSchedules(); + + expect(schedules).toEqual([]); + }); + + it('should return all schedules', async () => { + await adapter.createSchedule({ + scriptName: 'script-1', + cronExpression: '0 0 * * *', + }); + + await adapter.createSchedule({ + scriptName: 'script-2', + cronExpression: '0 12 * * *', + }); + + await adapter.createSchedule({ + scriptName: 'script-3', + cronExpression: '0 18 * * *', + }); + + const schedules = await adapter.listSchedules(); + + expect(schedules).toHaveLength(3); + expect(schedules.map((s) => s.scriptName)).toContain('script-1'); + expect(schedules.map((s) => s.scriptName)).toContain('script-2'); + expect(schedules.map((s) => s.scriptName)).toContain('script-3'); + }); + + it('should include all schedule properties', async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + input: { key: 'value' }, + }); + + const schedules = await adapter.listSchedules(); + + expect(schedules[0]).toMatchObject({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + input: { key: 'value' }, + enabled: true, + }); + expect(schedules[0]).toHaveProperty('createdAt'); + expect(schedules[0]).toHaveProperty('updatedAt'); + }); + }); + + describe('getSchedule()', () => { + beforeEach(async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + input: { key: 'value' }, + }); + }); + + it('should return schedule details', async () => { + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.Name).toBe('test-script'); + expect(schedule.State).toBe('ENABLED'); + expect(schedule.ScheduleExpression).toBe('0 0 * * *'); + expect(schedule.ScheduleExpressionTimezone).toBe('America/New_York'); + }); + + it('should include target configuration', async () => { + const schedule = await adapter.getSchedule('test-script'); + + const targetInput = JSON.parse(schedule.Target.Input); + expect(targetInput).toEqual({ + scriptName: 'test-script', + trigger: 'SCHEDULED', + params: { key: 'value' }, + }); + }); + + it('should include creation and modification dates', async () => { + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.CreationDate).toBeInstanceOf(Date); + expect(schedule.LastModificationDate).toBeInstanceOf(Date); + }); + + it('should throw error if schedule not found', async () => { + await expect(adapter.getSchedule('non-existent')).rejects.toThrow( + 'Schedule for script "non-existent" not found' + ); + }); + }); + + describe('Utility methods', () => { + it('clear() should remove all schedules', async () => { + await adapter.createSchedule({ + scriptName: 'script-1', + cronExpression: '0 0 * * *', + }); + + await adapter.createSchedule({ + scriptName: 'script-2', + cronExpression: '0 12 * * *', + }); + + expect(adapter.size).toBe(2); + + adapter.clear(); + + expect(adapter.size).toBe(0); + }); + + it('size should return number of schedules', async () => { + expect(adapter.size).toBe(0); + + await adapter.createSchedule({ + scriptName: 'script-1', + cronExpression: '0 0 * * *', + }); + + expect(adapter.size).toBe(1); + + await adapter.createSchedule({ + scriptName: 'script-2', + cronExpression: '0 12 * * *', + }); + + expect(adapter.size).toBe(2); + + await adapter.deleteSchedule('script-1'); + + expect(adapter.size).toBe(1); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js new file mode 100644 index 000000000..1abbc8b5f --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js @@ -0,0 +1,257 @@ +const { + createSchedulerAdapter, + detectSchedulerAdapterType, +} = require('../scheduler-adapter-factory'); +const { AWSSchedulerAdapter } = require('../aws-scheduler-adapter'); +const { LocalSchedulerAdapter } = require('../local-scheduler-adapter'); + +// Mock AWS SDK to prevent actual AWS calls +jest.mock('@aws-sdk/client-scheduler', () => ({ + SchedulerClient: jest.fn(() => ({ + send: jest.fn(), + })), + CreateScheduleCommand: jest.fn(), + DeleteScheduleCommand: jest.fn(), + GetScheduleCommand: jest.fn(), + UpdateScheduleCommand: jest.fn(), + ListSchedulesCommand: jest.fn(), +})); + +describe('Scheduler Adapter Factory', () => { + let originalEnv; + + beforeAll(() => { + originalEnv = { ...process.env }; + }); + + beforeEach(() => { + // Reset environment variables + delete process.env.SCHEDULER_ADAPTER; + delete process.env.STAGE; + delete process.env.NODE_ENV; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('createSchedulerAdapter()', () => { + it('should create local adapter by default', () => { + const adapter = createSchedulerAdapter(); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + expect(adapter.getName()).toBe('local-cron'); + }); + + it('should create local adapter when explicitly specified', () => { + const adapter = createSchedulerAdapter({ type: 'local' }); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + }); + + it('should create AWS adapter when type is "aws"', () => { + const adapter = createSchedulerAdapter({ type: 'aws' }); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter.getName()).toBe('aws-eventbridge-scheduler'); + }); + + it('should create AWS adapter when type is "eventbridge"', () => { + const adapter = createSchedulerAdapter({ type: 'eventbridge' }); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + }); + + it('should use SCHEDULER_ADAPTER env variable', () => { + process.env.SCHEDULER_ADAPTER = 'aws'; + + const adapter = createSchedulerAdapter(); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + }); + + it('should allow explicit type to override env variable', () => { + process.env.SCHEDULER_ADAPTER = 'aws'; + + const adapter = createSchedulerAdapter({ type: 'local' }); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + }); + + it('should handle case-insensitive type values', () => { + const adapter1 = createSchedulerAdapter({ type: 'AWS' }); + const adapter2 = createSchedulerAdapter({ type: 'LOCAL' }); + const adapter3 = createSchedulerAdapter({ type: 'EventBridge' }); + + expect(adapter1).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter2).toBeInstanceOf(LocalSchedulerAdapter); + expect(adapter3).toBeInstanceOf(AWSSchedulerAdapter); + }); + + it('should pass AWS configuration to AWS adapter', () => { + const config = { + type: 'aws', + region: 'eu-west-1', + targetLambdaArn: 'arn:aws:lambda:eu-west-1:123456789012:function:test', + scheduleGroupName: 'custom-group', + }; + + const adapter = createSchedulerAdapter(config); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter.region).toBe('eu-west-1'); + expect(adapter.targetLambdaArn).toBe('arn:aws:lambda:eu-west-1:123456789012:function:test'); + expect(adapter.scheduleGroupName).toBe('custom-group'); + }); + + it('should ignore AWS config for local adapter', () => { + const config = { + type: 'local', + region: 'eu-west-1', // This should be ignored + }; + + const adapter = createSchedulerAdapter(config); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + expect(adapter.region).toBeUndefined(); + }); + + it('should handle unknown adapter type by creating local adapter', () => { + const adapter = createSchedulerAdapter({ type: 'unknown-type' }); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + }); + }); + + describe('detectSchedulerAdapterType()', () => { + it('should return "local" by default', () => { + const type = detectSchedulerAdapterType(); + + expect(type).toBe('local'); + }); + + it('should return env SCHEDULER_ADAPTER when set', () => { + process.env.SCHEDULER_ADAPTER = 'aws'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('aws'); + }); + + it('should return "aws" for production stage', () => { + process.env.STAGE = 'production'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('aws'); + }); + + it('should return "aws" for prod stage', () => { + process.env.STAGE = 'prod'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('aws'); + }); + + it('should return "aws" for staging stage', () => { + process.env.STAGE = 'staging'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('aws'); + }); + + it('should return "aws" for stage stage', () => { + process.env.STAGE = 'stage'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('aws'); + }); + + it('should handle case-insensitive stage values', () => { + process.env.STAGE = 'PRODUCTION'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('aws'); + }); + + it('should return "local" for dev stage', () => { + process.env.STAGE = 'dev'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('local'); + }); + + it('should return "local" for development stage', () => { + process.env.STAGE = 'development'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('local'); + }); + + it('should return "local" for test stage', () => { + process.env.STAGE = 'test'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('local'); + }); + + it('should return "local" for local stage', () => { + process.env.STAGE = 'local'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('local'); + }); + + it('should use NODE_ENV as fallback for STAGE', () => { + delete process.env.STAGE; + process.env.NODE_ENV = 'production'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('aws'); + }); + + it('should prioritize explicit SCHEDULER_ADAPTER over auto-detection', () => { + process.env.SCHEDULER_ADAPTER = 'local'; + process.env.STAGE = 'production'; + + const type = detectSchedulerAdapterType(); + + expect(type).toBe('local'); + }); + }); + + describe('Integration with createSchedulerAdapter', () => { + it('should auto-detect and create AWS adapter in production', () => { + process.env.STAGE = 'production'; + + const adapter = createSchedulerAdapter(); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + }); + + it('should auto-detect and create local adapter in development', () => { + process.env.STAGE = 'development'; + + const adapter = createSchedulerAdapter(); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + }); + + it('should allow explicit override of auto-detection', () => { + process.env.STAGE = 'production'; + + const adapter = createSchedulerAdapter({ type: 'local' }); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js new file mode 100644 index 000000000..eff2756d5 --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js @@ -0,0 +1,103 @@ +const { SchedulerAdapter } = require('../scheduler-adapter'); + +describe('SchedulerAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new SchedulerAdapter(); + }); + + describe('Abstract base class', () => { + it('should throw error for getName()', () => { + expect(() => adapter.getName()).toThrow( + 'SchedulerAdapter.getName() must be implemented' + ); + }); + + it('should throw error for createSchedule()', async () => { + await expect(adapter.createSchedule({})).rejects.toThrow( + 'SchedulerAdapter.createSchedule() must be implemented' + ); + }); + + it('should throw error for deleteSchedule()', async () => { + await expect(adapter.deleteSchedule('test')).rejects.toThrow( + 'SchedulerAdapter.deleteSchedule() must be implemented' + ); + }); + + it('should throw error for setScheduleEnabled()', async () => { + await expect(adapter.setScheduleEnabled('test', true)).rejects.toThrow( + 'SchedulerAdapter.setScheduleEnabled() must be implemented' + ); + }); + + it('should throw error for listSchedules()', async () => { + await expect(adapter.listSchedules()).rejects.toThrow( + 'SchedulerAdapter.listSchedules() must be implemented' + ); + }); + + it('should throw error for getSchedule()', async () => { + await expect(adapter.getSchedule('test')).rejects.toThrow( + 'SchedulerAdapter.getSchedule() must be implemented' + ); + }); + }); + + describe('Inheritance', () => { + it('should be extendable by concrete implementations', () => { + class TestSchedulerAdapter extends SchedulerAdapter { + getName() { + return 'test-adapter'; + } + + async createSchedule(config) { + return { ruleName: config.scriptName }; + } + + async deleteSchedule(scriptName) { + return; + } + + async setScheduleEnabled(scriptName, enabled) { + return; + } + + async listSchedules() { + return []; + } + + async getSchedule(scriptName) { + return { scriptName }; + } + } + + const testAdapter = new TestSchedulerAdapter(); + + expect(testAdapter).toBeInstanceOf(SchedulerAdapter); + expect(testAdapter.getName()).toBe('test-adapter'); + }); + + it('should require all abstract methods to be implemented', async () => { + class IncompleteAdapter extends SchedulerAdapter { + getName() { + return 'incomplete'; + } + // Missing other methods + } + + const incomplete = new IncompleteAdapter(); + + // Should work for implemented method + expect(incomplete.getName()).toBe('incomplete'); + + // Should throw for missing methods + await expect(incomplete.createSchedule({})).rejects.toThrow(); + await expect(incomplete.deleteSchedule('test')).rejects.toThrow(); + await expect(incomplete.setScheduleEnabled('test', true)).rejects.toThrow(); + await expect(incomplete.listSchedules()).rejects.toThrow(); + await expect(incomplete.getSchedule('test')).rejects.toThrow(); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js new file mode 100644 index 000000000..7f69ed0d6 --- /dev/null +++ b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js @@ -0,0 +1,138 @@ +const { SchedulerAdapter } = require('./scheduler-adapter'); + +// Lazy-loaded AWS SDK clients (following AWSProviderAdapter pattern) +let SchedulerClient, CreateScheduleCommand, DeleteScheduleCommand, + GetScheduleCommand, UpdateScheduleCommand, ListSchedulesCommand; + +function loadSchedulerSDK() { + if (!SchedulerClient) { + const schedulerModule = require('@aws-sdk/client-scheduler'); + SchedulerClient = schedulerModule.SchedulerClient; + CreateScheduleCommand = schedulerModule.CreateScheduleCommand; + DeleteScheduleCommand = schedulerModule.DeleteScheduleCommand; + GetScheduleCommand = schedulerModule.GetScheduleCommand; + UpdateScheduleCommand = schedulerModule.UpdateScheduleCommand; + ListSchedulesCommand = schedulerModule.ListSchedulesCommand; + } +} + +/** + * AWS EventBridge Scheduler Adapter + * + * Infrastructure Adapter - Hexagonal Architecture + * + * Implements scheduling using AWS EventBridge Scheduler. + * Supports cron expressions, timezone configuration, and Lambda invocation. + */ +class AWSSchedulerAdapter extends SchedulerAdapter { + constructor({ region, credentials, targetLambdaArn, scheduleGroupName } = {}) { + super(); + this.region = region || process.env.AWS_REGION || 'us-east-1'; + this.credentials = credentials; + this.targetLambdaArn = targetLambdaArn || process.env.ADMIN_SCRIPT_LAMBDA_ARN; + this.scheduleGroupName = scheduleGroupName || process.env.SCHEDULE_GROUP_NAME || 'frigg-admin-scripts'; + this.scheduler = null; + } + + getSchedulerClient() { + if (!this.scheduler) { + loadSchedulerSDK(); + this.scheduler = new SchedulerClient({ + region: this.region, + credentials: this.credentials, + }); + } + return this.scheduler; + } + + getName() { + return 'aws-eventbridge-scheduler'; + } + + async createSchedule({ scriptName, cronExpression, timezone, input }) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + const command = new CreateScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + ScheduleExpression: cronExpression, + ScheduleExpressionTimezone: timezone || 'UTC', + FlexibleTimeWindow: { Mode: 'OFF' }, + Target: { + Arn: this.targetLambdaArn, + RoleArn: process.env.SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + scriptName, + trigger: 'SCHEDULED', + params: input || {}, + }), + }, + State: 'ENABLED', + }); + + const response = await client.send(command); + return { + ruleArn: response.ScheduleArn, + ruleName: scheduleName, + }; + } + + async deleteSchedule(scriptName) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + await client.send(new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + })); + } + + async setScheduleEnabled(scriptName, enabled) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + // Get the current schedule first to preserve all settings + const getCommand = new GetScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + }); + + const currentSchedule = await client.send(getCommand); + + // Update with the new state + await client.send(new UpdateScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + ScheduleExpression: currentSchedule.ScheduleExpression, + ScheduleExpressionTimezone: currentSchedule.ScheduleExpressionTimezone, + FlexibleTimeWindow: currentSchedule.FlexibleTimeWindow, + Target: currentSchedule.Target, + State: enabled ? 'ENABLED' : 'DISABLED', + })); + } + + async listSchedules() { + const client = this.getSchedulerClient(); + + const response = await client.send(new ListSchedulesCommand({ + GroupName: this.scheduleGroupName, + })); + + return response.Schedules || []; + } + + async getSchedule(scriptName) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + const response = await client.send(new GetScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + })); + + return response; + } +} + +module.exports = { AWSSchedulerAdapter }; diff --git a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js new file mode 100644 index 000000000..c5c8dc46a --- /dev/null +++ b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js @@ -0,0 +1,103 @@ +const { SchedulerAdapter } = require('./scheduler-adapter'); + +/** + * Local Scheduler Adapter + * + * Infrastructure Adapter - Hexagonal Architecture + * + * In-memory implementation for local development and testing. + * Stores schedule configurations but does not execute them. + * For actual cron execution, use a library like node-cron. + */ +class LocalSchedulerAdapter extends SchedulerAdapter { + constructor() { + super(); + this.schedules = new Map(); + this.intervals = new Map(); + } + + getName() { + return 'local-cron'; + } + + async createSchedule({ scriptName, cronExpression, timezone, input }) { + // Store schedule (actual cron execution would use node-cron) + this.schedules.set(scriptName, { + scriptName, + cronExpression, + timezone: timezone || 'UTC', + input, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + return { + ruleName: scriptName, + ruleArn: `local:schedule:${scriptName}`, + }; + } + + async deleteSchedule(scriptName) { + this.schedules.delete(scriptName); + if (this.intervals.has(scriptName)) { + clearInterval(this.intervals.get(scriptName)); + this.intervals.delete(scriptName); + } + } + + async setScheduleEnabled(scriptName, enabled) { + const schedule = this.schedules.get(scriptName); + if (!schedule) { + throw new Error(`Schedule for script "${scriptName}" not found`); + } + + schedule.enabled = enabled; + schedule.updatedAt = new Date().toISOString(); + } + + async listSchedules() { + return Array.from(this.schedules.values()); + } + + async getSchedule(scriptName) { + const schedule = this.schedules.get(scriptName); + if (!schedule) { + throw new Error(`Schedule for script "${scriptName}" not found`); + } + + return { + Name: scriptName, + State: schedule.enabled ? 'ENABLED' : 'DISABLED', + ScheduleExpression: schedule.cronExpression, + ScheduleExpressionTimezone: schedule.timezone, + Target: { + Input: JSON.stringify({ + scriptName, + trigger: 'SCHEDULED', + params: schedule.input || {}, + }), + }, + CreationDate: new Date(schedule.createdAt), + LastModificationDate: new Date(schedule.updatedAt), + }; + } + + /** + * Clear all schedules (useful for testing) + */ + clear() { + this.schedules.clear(); + this.intervals.forEach((interval) => clearInterval(interval)); + this.intervals.clear(); + } + + /** + * Get number of schedules (useful for testing) + */ + get size() { + return this.schedules.size; + } +} + +module.exports = { LocalSchedulerAdapter }; diff --git a/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js b/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js new file mode 100644 index 000000000..9e11fd08f --- /dev/null +++ b/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js @@ -0,0 +1,69 @@ +const { AWSSchedulerAdapter } = require('./aws-scheduler-adapter'); +const { LocalSchedulerAdapter } = require('./local-scheduler-adapter'); + +/** + * Scheduler Adapter Factory + * + * Application Layer - Hexagonal Architecture + * + * Creates the appropriate scheduler adapter based on configuration. + * Supports environment-based auto-detection and explicit configuration. + */ + +/** + * Create a scheduler adapter instance + * + * @param {Object} options - Configuration options + * @param {string} [options.type] - Adapter type ('aws', 'eventbridge', 'local') + * @param {string} [options.region] - AWS region (for AWS adapter) + * @param {Object} [options.credentials] - AWS credentials (for AWS adapter) + * @param {string} [options.targetLambdaArn] - Lambda ARN to invoke (for AWS adapter) + * @param {string} [options.scheduleGroupName] - EventBridge schedule group name (for AWS adapter) + * @returns {SchedulerAdapter} Configured scheduler adapter + */ +function createSchedulerAdapter(options = {}) { + const adapterType = options.type || detectSchedulerAdapterType(); + + switch (adapterType.toLowerCase()) { + case 'aws': + case 'eventbridge': + return new AWSSchedulerAdapter({ + region: options.region, + credentials: options.credentials, + targetLambdaArn: options.targetLambdaArn, + scheduleGroupName: options.scheduleGroupName, + }); + + case 'local': + default: + return new LocalSchedulerAdapter(); + } +} + +/** + * Determine the appropriate scheduler adapter type based on environment + * + * @returns {string} Adapter type ('aws' or 'local') + */ +function detectSchedulerAdapterType() { + // If explicitly set, use that + if (process.env.SCHEDULER_ADAPTER) { + return process.env.SCHEDULER_ADAPTER; + } + + // Auto-detect based on environment + const stage = process.env.STAGE || process.env.NODE_ENV || 'local'; + + // Use AWS adapter in production/staging environments + if (['production', 'prod', 'staging', 'stage'].includes(stage.toLowerCase())) { + return 'aws'; + } + + // Use local adapter for dev/test/local + return 'local'; +} + +module.exports = { + createSchedulerAdapter, + detectSchedulerAdapterType, +}; diff --git a/packages/admin-scripts/src/adapters/scheduler-adapter.js b/packages/admin-scripts/src/adapters/scheduler-adapter.js new file mode 100644 index 000000000..04f7db126 --- /dev/null +++ b/packages/admin-scripts/src/adapters/scheduler-adapter.js @@ -0,0 +1,64 @@ +/** + * Scheduler Adapter (Abstract Base Class) + * + * Port - Hexagonal Architecture + * + * Defines the contract for scheduler implementations. + * Supports AWS EventBridge, local cron, or other providers. + */ +class SchedulerAdapter { + getName() { + throw new Error('SchedulerAdapter.getName() must be implemented'); + } + + /** + * Create or update a schedule for a script + * @param {Object} config + * @param {string} config.scriptName - Script identifier + * @param {string} config.cronExpression - Cron expression + * @param {string} [config.timezone] - Timezone (default UTC) + * @param {Object} [config.input] - Optional input params + * @returns {Promise} Created schedule { ruleArn, ruleName } + */ + async createSchedule(config) { + throw new Error('SchedulerAdapter.createSchedule() must be implemented'); + } + + /** + * Delete a schedule + * @param {string} scriptName - Script identifier + * @returns {Promise} + */ + async deleteSchedule(scriptName) { + throw new Error('SchedulerAdapter.deleteSchedule() must be implemented'); + } + + /** + * Enable or disable a schedule + * @param {string} scriptName - Script identifier + * @param {boolean} enabled - Whether to enable + * @returns {Promise} + */ + async setScheduleEnabled(scriptName, enabled) { + throw new Error('SchedulerAdapter.setScheduleEnabled() must be implemented'); + } + + /** + * List all schedules + * @returns {Promise} List of schedules + */ + async listSchedules() { + throw new Error('SchedulerAdapter.listSchedules() must be implemented'); + } + + /** + * Get a specific schedule + * @param {string} scriptName - Script identifier + * @returns {Promise} Schedule details + */ + async getSchedule(scriptName) { + throw new Error('SchedulerAdapter.getSchedule() must be implemented'); + } +} + +module.exports = { SchedulerAdapter }; diff --git a/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js b/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js new file mode 100644 index 000000000..498031649 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js @@ -0,0 +1,313 @@ +const { + createDryRunHttpClient, + injectDryRunHttpClient, + sanitizeHeaders, + sanitizeData, + detectService, +} = require('../dry-run-http-interceptor'); + +describe('Dry-Run HTTP Interceptor', () => { + describe('sanitizeHeaders', () => { + test('should redact authorization headers', () => { + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer secret-token', + 'X-API-Key': 'api-key-123', + 'User-Agent': 'frigg/1.0', + }; + + const sanitized = sanitizeHeaders(headers); + + expect(sanitized['Content-Type']).toBe('application/json'); + expect(sanitized['User-Agent']).toBe('frigg/1.0'); + expect(sanitized.Authorization).toBe('[REDACTED]'); + expect(sanitized['X-API-Key']).toBe('[REDACTED]'); + }); + + test('should handle case variations', () => { + const headers = { + authorization: 'Bearer token', + Authorization: 'Bearer token', + 'x-api-key': 'key1', + 'X-API-Key': 'key2', + }; + + const sanitized = sanitizeHeaders(headers); + + expect(sanitized.authorization).toBe('[REDACTED]'); + expect(sanitized.Authorization).toBe('[REDACTED]'); + expect(sanitized['x-api-key']).toBe('[REDACTED]'); + expect(sanitized['X-API-Key']).toBe('[REDACTED]'); + }); + + test('should handle null/undefined', () => { + expect(sanitizeHeaders(null)).toEqual({}); + expect(sanitizeHeaders(undefined)).toEqual({}); + expect(sanitizeHeaders({})).toEqual({}); + }); + }); + + describe('detectService', () => { + test('should detect CRM services', () => { + expect(detectService('https://api.hubapi.com')).toBe('HubSpot'); + expect(detectService('https://login.salesforce.com')).toBe('Salesforce'); + expect(detectService('https://api.pipedrive.com')).toBe('Pipedrive'); + expect(detectService('https://api.attio.com')).toBe('Attio'); + }); + + test('should detect communication services', () => { + expect(detectService('https://slack.com/api')).toBe('Slack'); + expect(detectService('https://discord.com/api')).toBe('Discord'); + expect(detectService('https://graph.teams.microsoft.com')).toBe('Microsoft Teams'); + }); + + test('should detect project management tools', () => { + expect(detectService('https://app.asana.com/api')).toBe('Asana'); + expect(detectService('https://api.monday.com')).toBe('Monday.com'); + expect(detectService('https://api.trello.com')).toBe('Trello'); + }); + + test('should return unknown for unrecognized services', () => { + expect(detectService('https://example.com/api')).toBe('unknown'); + expect(detectService(null)).toBe('unknown'); + expect(detectService(undefined)).toBe('unknown'); + }); + + test('should be case insensitive', () => { + expect(detectService('HTTPS://API.HUBSPOT.COM')).toBe('HubSpot'); + expect(detectService('https://API.SLACK.COM')).toBe('Slack'); + }); + }); + + describe('sanitizeData', () => { + test('should redact sensitive fields', () => { + const data = { + name: 'Test User', + email: 'test@example.com', + password: 'secret123', + apiToken: 'token-abc', + authKey: 'key-xyz', + }; + + const sanitized = sanitizeData(data); + + expect(sanitized.name).toBe('Test User'); + expect(sanitized.email).toBe('test@example.com'); + expect(sanitized.password).toBe('[REDACTED]'); + expect(sanitized.apiToken).toBe('[REDACTED]'); + expect(sanitized.authKey).toBe('[REDACTED]'); + }); + + test('should handle nested objects', () => { + const data = { + user: { + name: 'Test', + credentials: { + password: 'secret', + token: 'abc123', + }, + }, + }; + + const sanitized = sanitizeData(data); + + expect(sanitized.user.name).toBe('Test'); + expect(sanitized.user.credentials.password).toBe('[REDACTED]'); + expect(sanitized.user.credentials.token).toBe('[REDACTED]'); + }); + + test('should handle arrays', () => { + const data = [ + { id: '1', password: 'secret1' }, + { id: '2', apiKey: 'key2' }, + ]; + + const sanitized = sanitizeData(data); + + expect(sanitized[0].id).toBe('1'); + expect(sanitized[0].password).toBe('[REDACTED]'); + expect(sanitized[1].apiKey).toBe('[REDACTED]'); + }); + + test('should preserve primitives', () => { + expect(sanitizeData('string')).toBe('string'); + expect(sanitizeData(123)).toBe(123); + expect(sanitizeData(true)).toBe(true); + expect(sanitizeData(null)).toBe(null); + expect(sanitizeData(undefined)).toBe(undefined); + }); + }); + + describe('createDryRunHttpClient', () => { + let operationLog; + + beforeEach(() => { + operationLog = []; + }); + + test('should log GET requests', async () => { + const client = createDryRunHttpClient(operationLog); + + const response = await client.get('/contacts', { + baseURL: 'https://api.hubapi.com', + headers: { Authorization: 'Bearer token' }, + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0]).toMatchObject({ + operation: 'HTTP_REQUEST', + method: 'GET', + url: 'https://api.hubapi.com/contacts', + service: 'HubSpot', + }); + + expect(operationLog[0].headers.Authorization).toBe('[REDACTED]'); + expect(response.data._dryRun).toBe(true); + }); + + test('should log POST requests with data', async () => { + const client = createDryRunHttpClient(operationLog); + + const postData = { + name: 'John Doe', + email: 'john@example.com', + password: 'secret123', + }; + + await client.post('/users', postData, { + baseURL: 'https://api.example.com', + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('POST'); + expect(operationLog[0].data.name).toBe('John Doe'); + expect(operationLog[0].data.email).toBe('john@example.com'); + expect(operationLog[0].data.password).toBe('[REDACTED]'); + }); + + test('should log PUT requests', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.put('/users/123', { status: 'active' }, { + baseURL: 'https://api.example.com', + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('PUT'); + expect(operationLog[0].data.status).toBe('active'); + }); + + test('should log PATCH requests', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.patch('/users/123', { name: 'Updated' }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('PATCH'); + }); + + test('should log DELETE requests', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.delete('/users/123', { + baseURL: 'https://api.example.com', + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('DELETE'); + }); + + test('should return mock response', async () => { + const client = createDryRunHttpClient(operationLog); + + const response = await client.get('/test'); + + expect(response.status).toBe(200); + expect(response.statusText).toContain('Dry-Run'); + expect(response.data._dryRun).toBe(true); + expect(response.headers['x-dry-run']).toBe('true'); + }); + + test('should include query params in log', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.get('/search', { + baseURL: 'https://api.example.com', + params: { q: 'test', limit: 10 }, + }); + + expect(operationLog[0].params).toEqual({ q: 'test', limit: 10 }); + }); + }); + + describe('injectDryRunHttpClient', () => { + let operationLog; + let dryRunClient; + + beforeEach(() => { + operationLog = []; + dryRunClient = createDryRunHttpClient(operationLog); + }); + + test('should inject into primary API module', () => { + const integrationInstance = { + primary: { + api: { + _httpClient: { get: jest.fn() }, + }, + }, + }; + + injectDryRunHttpClient(integrationInstance, dryRunClient); + + expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient); + }); + + test('should inject into target API module', () => { + const integrationInstance = { + target: { + api: { + _httpClient: { get: jest.fn() }, + }, + }, + }; + + injectDryRunHttpClient(integrationInstance, dryRunClient); + + expect(integrationInstance.target.api._httpClient).toBe(dryRunClient); + }); + + test('should inject into both primary and target', () => { + const integrationInstance = { + primary: { + api: { _httpClient: { get: jest.fn() } }, + }, + target: { + api: { _httpClient: { get: jest.fn() } }, + }, + }; + + injectDryRunHttpClient(integrationInstance, dryRunClient); + + expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient); + expect(integrationInstance.target.api._httpClient).toBe(dryRunClient); + }); + + test('should handle missing api modules gracefully', () => { + const integrationInstance = { + primary: {}, + target: null, + }; + + expect(() => { + injectDryRunHttpClient(integrationInstance, dryRunClient); + }).not.toThrow(); + }); + + test('should handle null integration instance', () => { + expect(() => { + injectDryRunHttpClient(null, dryRunClient); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js b/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js new file mode 100644 index 000000000..4d3f9eb5d --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js @@ -0,0 +1,257 @@ +const { createDryRunWrapper, wrapAdminFriggCommandsForDryRun, sanitizeArgs } = require('../dry-run-repository-wrapper'); + +describe('Dry-Run Repository Wrapper', () => { + describe('createDryRunWrapper', () => { + let mockRepository; + let operationLog; + + beforeEach(() => { + operationLog = []; + mockRepository = { + // Read operations + findById: jest.fn(async (id) => ({ id, name: 'Test Entity' })), + findAll: jest.fn(async () => [{ id: '1' }, { id: '2' }]), + getStatus: jest.fn(() => 'active'), + + // Write operations + create: jest.fn(async (data) => ({ id: 'new-id', ...data })), + update: jest.fn(async (id, data) => ({ id, ...data })), + delete: jest.fn(async (id) => ({ deletedCount: 1 })), + updateStatus: jest.fn(async (id, status) => ({ id, status })), + }; + }); + + test('should pass through read operations unchanged', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + // Call read operations + const byId = await wrapped.findById('123'); + const all = await wrapped.findAll(); + const status = wrapped.getStatus(); + + // Verify original methods were called + expect(mockRepository.findById).toHaveBeenCalledWith('123'); + expect(mockRepository.findAll).toHaveBeenCalled(); + expect(mockRepository.getStatus).toHaveBeenCalled(); + + // Verify results match + expect(byId).toEqual({ id: '123', name: 'Test Entity' }); + expect(all).toHaveLength(2); + expect(status).toBe('active'); + + // No operations should be logged + expect(operationLog).toHaveLength(0); + }); + + test('should intercept and log write operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + // Call write operations + await wrapped.create({ name: 'New Entity' }); + await wrapped.update('123', { name: 'Updated' }); + await wrapped.delete('456'); + + // Original write methods should NOT be called + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.update).not.toHaveBeenCalled(); + expect(mockRepository.delete).not.toHaveBeenCalled(); + + // All operations should be logged + expect(operationLog).toHaveLength(3); + + expect(operationLog[0]).toMatchObject({ + operation: 'CREATE', + model: 'TestModel', + method: 'create', + }); + + expect(operationLog[1]).toMatchObject({ + operation: 'UPDATE', + model: 'TestModel', + method: 'update', + }); + + expect(operationLog[2]).toMatchObject({ + operation: 'DELETE', + model: 'TestModel', + method: 'delete', + }); + }); + + test('should return mock data for create operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.create({ name: 'Test', value: 42 }); + + expect(result).toMatchObject({ + name: 'Test', + value: 42, + _dryRun: true, + }); + + expect(result.id).toMatch(/^dry-run-/); + expect(result.createdAt).toBeDefined(); + }); + + test('should return mock data for update operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.update('123', { status: 'inactive' }); + + expect(result).toMatchObject({ + id: '123', + status: 'inactive', + _dryRun: true, + }); + }); + + test('should return mock data for delete operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.delete('123'); + + expect(result).toEqual({ + deletedCount: 1, + _dryRun: true, + }); + }); + + test('should try to return existing data for updates when possible', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.updateStatus('123', 'inactive'); + + // Should attempt to read existing data + expect(mockRepository.findById).toHaveBeenCalledWith('123'); + + // If found, should return existing merged with updates + expect(result.id).toBe('123'); + }); + }); + + describe('sanitizeArgs', () => { + test('should redact sensitive fields in objects', () => { + const args = [ + { + id: '123', + password: 'secret123', + token: 'abc-def-ghi', + apiKey: 'sk_live_123', + name: 'Test User', + }, + ]; + + const sanitized = sanitizeArgs(args); + + expect(sanitized[0]).toEqual({ + id: '123', + password: '[REDACTED]', + token: '[REDACTED]', + apiKey: '[REDACTED]', + name: 'Test User', + }); + }); + + test('should handle nested objects', () => { + const args = [ + { + user: { + name: 'Test', + credentials: { + password: 'secret', + apiToken: 'token123', + }, + }, + }, + ]; + + const sanitized = sanitizeArgs(args); + + expect(sanitized[0].user.name).toBe('Test'); + expect(sanitized[0].user.credentials.password).toBe('[REDACTED]'); + expect(sanitized[0].user.credentials.apiToken).toBe('[REDACTED]'); + }); + + test('should handle arrays', () => { + const args = [ + [ + { id: '1', token: 'abc' }, + { id: '2', secret: 'xyz' }, + ], + ]; + + const sanitized = sanitizeArgs(args); + + expect(sanitized[0][0].token).toBe('[REDACTED]'); + expect(sanitized[0][1].secret).toBe('[REDACTED]'); + }); + + test('should preserve primitives', () => { + const args = ['string', 123, true, null, undefined]; + const sanitized = sanitizeArgs(args); + + expect(sanitized).toEqual(['string', 123, true, null, undefined]); + }); + }); + + describe('wrapAdminFriggCommandsForDryRun', () => { + let mockCommands; + let operationLog; + + beforeEach(() => { + operationLog = []; + mockCommands = { + // Read operations + findIntegrationById: jest.fn(async (id) => ({ id, status: 'active' })), + listIntegrations: jest.fn(async () => []), + + // Write operations + updateIntegrationConfig: jest.fn(async (id, config) => ({ id, config })), + updateIntegrationStatus: jest.fn(async (id, status) => ({ id, status })), + updateCredential: jest.fn(async (id, updates) => ({ id, ...updates })), + + // Other methods + log: jest.fn(), + }; + }); + + test('should pass through read operations', async () => { + const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); + + const integration = await wrapped.findIntegrationById('123'); + const list = await wrapped.listIntegrations(); + + expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123'); + expect(mockCommands.listIntegrations).toHaveBeenCalled(); + + expect(integration.id).toBe('123'); + expect(operationLog).toHaveLength(0); + }); + + test('should intercept write operations', async () => { + const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); + + await wrapped.updateIntegrationConfig('123', { setting: 'value' }); + await wrapped.updateIntegrationStatus('456', 'inactive'); + + expect(mockCommands.updateIntegrationConfig).not.toHaveBeenCalled(); + expect(mockCommands.updateIntegrationStatus).not.toHaveBeenCalled(); + + expect(operationLog).toHaveLength(2); + expect(operationLog[0].operation).toBe('UPDATEINTEGRATIONCONFIG'); + expect(operationLog[1].operation).toBe('UPDATEINTEGRATIONSTATUS'); + }); + + test('should return existing data for known update methods', async () => { + const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); + + const result = await wrapped.updateIntegrationConfig('123', { new: 'config' }); + + // Should have tried to fetch existing + expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123'); + + // Should return existing data + expect(result.id).toBe('123'); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/dry-run-http-interceptor.js b/packages/admin-scripts/src/application/dry-run-http-interceptor.js new file mode 100644 index 000000000..9b9aba65c --- /dev/null +++ b/packages/admin-scripts/src/application/dry-run-http-interceptor.js @@ -0,0 +1,296 @@ +/** + * Dry-Run HTTP Interceptor + * + * Creates a mock HTTP client that logs requests instead of executing them. + * Used to intercept API module calls during dry-run. + */ + +/** + * Sanitize headers to remove authentication tokens + * @param {Object} headers - HTTP headers + * @returns {Object} Sanitized headers + */ +function sanitizeHeaders(headers) { + if (!headers || typeof headers !== 'object') { + return {}; + } + + const safe = { ...headers }; + + // Remove common auth headers + const sensitiveHeaders = [ + 'authorization', + 'Authorization', + 'x-api-key', + 'X-API-Key', + 'x-auth-token', + 'X-Auth-Token', + 'api-key', + 'API-Key', + 'apikey', + 'ApiKey', + 'token', + 'Token', + ]; + + for (const header of sensitiveHeaders) { + if (safe[header]) { + safe[header] = '[REDACTED]'; + } + } + + return safe; +} + +/** + * Detect service name from base URL + * @param {string} baseURL - Base URL of the API + * @returns {string} Service name + */ +function detectService(baseURL) { + if (!baseURL) return 'unknown'; + + const url = baseURL.toLowerCase(); + + // CRM Systems + if (url.includes('hubspot') || url.includes('hubapi')) return 'HubSpot'; + if (url.includes('salesforce')) return 'Salesforce'; + if (url.includes('pipedrive')) return 'Pipedrive'; + if (url.includes('zoho')) return 'Zoho CRM'; + if (url.includes('attio')) return 'Attio'; + + // Communication + if (url.includes('slack')) return 'Slack'; + if (url.includes('discord')) return 'Discord'; + if (url.includes('teams.microsoft')) return 'Microsoft Teams'; + + // Project Management + if (url.includes('asana')) return 'Asana'; + if (url.includes('monday')) return 'Monday.com'; + if (url.includes('trello')) return 'Trello'; + if (url.includes('clickup')) return 'ClickUp'; + + // Storage + if (url.includes('googleapis.com/drive')) return 'Google Drive'; + if (url.includes('dropbox')) return 'Dropbox'; + if (url.includes('box.com')) return 'Box'; + + // Email & Marketing + if (url.includes('sendgrid')) return 'SendGrid'; + if (url.includes('mailchimp')) return 'Mailchimp'; + if (url.includes('gmail')) return 'Gmail'; + + // Accounting + if (url.includes('quickbooks')) return 'QuickBooks'; + if (url.includes('xero')) return 'Xero'; + + // Other + if (url.includes('stripe')) return 'Stripe'; + if (url.includes('shopify')) return 'Shopify'; + if (url.includes('github')) return 'GitHub'; + if (url.includes('gitlab')) return 'GitLab'; + + return 'unknown'; +} + +/** + * Sanitize request data to remove sensitive information + * @param {*} data - Request data + * @returns {*} Sanitized data + */ +function sanitizeData(data) { + if (data === null || data === undefined) { + return data; + } + + if (typeof data !== 'object') { + return data; + } + + if (Array.isArray(data)) { + return data.map(sanitizeData); + } + + const sanitized = {}; + for (const [key, value] of Object.entries(data)) { + const lowerKey = key.toLowerCase(); + + // Check if this is a leaf node that should be redacted + const isSensitiveField = + lowerKey === 'password' || + lowerKey === 'token' || + lowerKey === 'secret' || + lowerKey === 'apikey' || + lowerKey.endsWith('password') || + lowerKey.endsWith('token') || + lowerKey.endsWith('secret') || + lowerKey.endsWith('key') && !lowerKey.endsWith('publickey'); + + // Only redact if it's a primitive value (not an object/array) + if (isSensitiveField && typeof value !== 'object') { + sanitized[key] = '[REDACTED]'; + continue; + } + + // Recursively sanitize nested objects + if (typeof value === 'object' && value !== null) { + sanitized[key] = sanitizeData(value); + } else { + sanitized[key] = value; + } + } + + return sanitized; +} + +/** + * Create a dry-run HTTP client + * + * @param {Array} operationLog - Array to append logged HTTP requests + * @returns {Object} Mock HTTP client compatible with axios interface + */ +function createDryRunHttpClient(operationLog) { + /** + * Mock HTTP request handler + * @param {Object} config - Request configuration + * @returns {Promise} Mock response + */ + const mockRequest = async (config) => { + // Build full URL + let fullUrl = config.url; + if (config.baseURL && !config.url.startsWith('http')) { + fullUrl = `${config.baseURL}${config.url.startsWith('/') ? '' : '/'}${config.url}`; + } + + // Log the request that WOULD have been made + const logEntry = { + operation: 'HTTP_REQUEST', + method: (config.method || 'GET').toUpperCase(), + url: fullUrl, + baseURL: config.baseURL, + path: config.url, + service: detectService(config.baseURL || fullUrl), + headers: sanitizeHeaders(config.headers), + timestamp: new Date().toISOString(), + }; + + // Include request data for write operations + if (config.data && ['POST', 'PUT', 'PATCH'].includes(logEntry.method)) { + logEntry.data = sanitizeData(config.data); + } + + // Include query params + if (config.params) { + logEntry.params = sanitizeData(config.params); + } + + operationLog.push(logEntry); + + // Return mock response + return { + status: 200, + statusText: 'OK (Dry-Run)', + data: { + _dryRun: true, + _message: 'This is a dry-run mock response', + _wouldHaveExecuted: `${logEntry.method} ${fullUrl}`, + _service: logEntry.service, + }, + headers: { + 'content-type': 'application/json', + 'x-dry-run': 'true', + }, + config, + }; + }; + + // Return axios-compatible interface + return { + request: mockRequest, + get: (url, config = {}) => mockRequest({ ...config, method: 'GET', url }), + post: (url, data, config = {}) => mockRequest({ ...config, method: 'POST', url, data }), + put: (url, data, config = {}) => mockRequest({ ...config, method: 'PUT', url, data }), + patch: (url, data, config = {}) => + mockRequest({ ...config, method: 'PATCH', url, data }), + delete: (url, config = {}) => mockRequest({ ...config, method: 'DELETE', url }), + head: (url, config = {}) => mockRequest({ ...config, method: 'HEAD', url }), + options: (url, config = {}) => mockRequest({ ...config, method: 'OPTIONS', url }), + + // Axios-specific properties + defaults: { + headers: { + common: {}, + get: {}, + post: {}, + put: {}, + patch: {}, + delete: {}, + }, + }, + + // Interceptors (no-op in dry-run) + interceptors: { + request: { use: () => {}, eject: () => {} }, + response: { use: () => {}, eject: () => {} }, + }, + }; +} + +/** + * Inject dry-run HTTP client into an integration instance + * + * @param {Object} integrationInstance - Integration instance from integrationFactory + * @param {Object} dryRunHttpClient - Dry-run HTTP client + */ +function injectDryRunHttpClient(integrationInstance, dryRunHttpClient) { + if (!integrationInstance) { + return; + } + + // Inject into primary API module + if (integrationInstance.primary?.api) { + injectIntoApiModule(integrationInstance.primary.api, dryRunHttpClient); + } + + // Inject into target API module + if (integrationInstance.target?.api) { + injectIntoApiModule(integrationInstance.target.api, dryRunHttpClient); + } +} + +/** + * Inject dry-run HTTP client into an API module + * @param {Object} apiModule - API module instance + * @param {Object} dryRunHttpClient - Dry-run HTTP client + */ +function injectIntoApiModule(apiModule, dryRunHttpClient) { + // Common property names for HTTP clients in API modules + const httpClientProps = [ + '_httpClient', + 'httpClient', + 'client', + 'axios', + 'request', + 'api', + 'http', + ]; + + for (const prop of httpClientProps) { + if (apiModule[prop] && typeof apiModule[prop] === 'object') { + apiModule[prop] = dryRunHttpClient; + } + } + + // Also check if the API module itself has request methods + if (typeof apiModule.request === 'function') { + Object.assign(apiModule, dryRunHttpClient); + } +} + +module.exports = { + createDryRunHttpClient, + injectDryRunHttpClient, + sanitizeHeaders, + sanitizeData, + detectService, +}; diff --git a/packages/admin-scripts/src/application/dry-run-repository-wrapper.js b/packages/admin-scripts/src/application/dry-run-repository-wrapper.js new file mode 100644 index 000000000..b94a35803 --- /dev/null +++ b/packages/admin-scripts/src/application/dry-run-repository-wrapper.js @@ -0,0 +1,261 @@ +/** + * Dry-Run Repository Wrapper + * + * Wraps any repository to intercept write operations. + * - READ operations pass through unchanged + * - WRITE operations are logged but not executed + * + * Uses Proxy pattern for dynamic method interception + */ + +/** + * Create a dry-run wrapper for any repository + * + * @param {Object} repository - The real repository to wrap + * @param {Array} operationLog - Array to append logged operations + * @param {string} modelName - Name of the model (for logging) + * @returns {Proxy} Wrapped repository that logs write operations + */ +function createDryRunWrapper(repository, operationLog, modelName) { + return new Proxy(repository, { + get(target, prop) { + const value = target[prop]; + + // Return non-function properties as-is + if (typeof value !== 'function') { + return value; + } + + // Identify write operations by name pattern + const writePatterns = /^(create|update|delete|upsert|append|remove|insert|save)/i; + const isWrite = writePatterns.test(prop); + + // Pass through read operations + if (!isWrite) { + return value.bind(target); + } + + // Wrap write operation + return async (...args) => { + // Log the operation that WOULD have been performed + operationLog.push({ + operation: prop.toUpperCase(), + model: modelName, + method: prop, + args: sanitizeArgs(args), + timestamp: new Date().toISOString(), + wouldExecute: `${modelName}.${prop}()`, + }); + + // For write operations, try to return existing data or mock data + // This helps scripts continue executing without errors + + // For updates, try to return existing data + if (prop.includes('update') || prop.includes('upsert')) { + // Try to extract ID from first argument + const possibleId = args[0]; + let existing = null; + + if (possibleId && typeof possibleId === 'string') { + // Try to find existing record + const findMethod = getFindMethod(target, prop); + if (findMethod) { + try { + existing = await findMethod.call(target, possibleId); + } catch (err) { + // Ignore errors, continue to mock + } + } + } + + // Return merged data + if (existing) { + // Merge update data with existing + return { ...existing, ...args[1], _dryRun: true }; + } + + // No existing data, return mock + if (args[1]) { + return { id: possibleId, ...args[1], _dryRun: true }; + } + + return { id: possibleId, _dryRun: true }; + } + + // For creates, return mock object with the data + if (prop.includes('create') || prop.includes('insert')) { + const data = args[0] || {}; + return { + id: `dry-run-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + ...data, + _dryRun: true, + createdAt: new Date().toISOString(), + }; + } + + // For deletes, return success indication + if (prop.includes('delete') || prop.includes('remove')) { + return { deletedCount: 1, _dryRun: true }; + } + + // Default: return mock success + return { success: true, _dryRun: true }; + }; + }, + }); +} + +/** + * Try to find a corresponding find method for an update operation + * @param {Object} target - Repository target + * @param {string} updateMethod - Update method name + * @returns {Function|null} Find method or null + */ +function getFindMethod(target, updateMethod) { + // Common patterns: updateIntegration -> findIntegrationById + const patterns = [ + () => { + const match = updateMethod.match(/update(\w+)/i); + return match ? `find${match[1]}ById` : null; + }, + () => { + const match = updateMethod.match(/update(\w+)/i); + return match ? `get${match[1]}ById` : null; + }, + () => 'findById', + () => 'getById', + ]; + + for (const pattern of patterns) { + const methodName = pattern(); + if (methodName && typeof target[methodName] === 'function') { + return target[methodName]; + } + } + + return null; +} + +/** + * Sanitize arguments for logging (remove sensitive data) + * @param {Array} args - Function arguments + * @returns {Array} Sanitized arguments + */ +function sanitizeArgs(args) { + return args.map((arg) => { + if (arg === null || arg === undefined) { + return arg; + } + + if (typeof arg !== 'object') { + return arg; + } + + if (Array.isArray(arg)) { + return arg.map((item) => sanitizeArgs([item])[0]); + } + + // Sanitize object - remove sensitive fields + const sanitized = {}; + for (const [key, value] of Object.entries(arg)) { + const lowerKey = key.toLowerCase(); + + // Skip sensitive fields + if ( + lowerKey.includes('password') || + lowerKey.includes('token') || + lowerKey.includes('secret') || + lowerKey.includes('key') || + lowerKey.includes('auth') + ) { + sanitized[key] = '[REDACTED]'; + continue; + } + + // Recursively sanitize nested objects + if (typeof value === 'object' && value !== null) { + sanitized[key] = sanitizeArgs([value])[0]; + } else { + sanitized[key] = value; + } + } + + return sanitized; + }); +} + +/** + * Wrap AdminFriggCommands for dry-run mode + * + * @param {Object} realCommands - Real AdminFriggCommands instance + * @param {Array} operationLog - Array to append logged operations + * @returns {Object} Wrapped commands with dry-run repository wrappers + */ +function wrapAdminFriggCommandsForDryRun(realCommands, operationLog) { + return new Proxy(realCommands, { + get(target, prop) { + const value = target[prop]; + + // Pass through non-functions + if (typeof value !== 'function') { + // For lazy-loaded repositories, wrap them + if (prop.endsWith('Repository') && value && typeof value === 'object') { + const modelName = prop.replace('Repository', ''); + return createDryRunWrapper( + value, + operationLog, + modelName.charAt(0).toUpperCase() + modelName.slice(1) + ); + } + return value; + } + + // Identify write operations on the commands themselves + const writePatterns = /^(update|create|delete|append)/i; + const isWrite = writePatterns.test(prop); + + if (!isWrite) { + // Read operations pass through + return value.bind(target); + } + + // Wrap write operations + return async (...args) => { + operationLog.push({ + operation: prop.toUpperCase(), + source: 'AdminFriggCommands', + method: prop, + args: sanitizeArgs(args), + timestamp: new Date().toISOString(), + }); + + // For specific known methods, try to return sensible mocks + if (prop === 'updateIntegrationConfig') { + const [integrationId] = args; + const existing = await target.findIntegrationById(integrationId); + return existing; + } + + if (prop === 'updateIntegrationStatus') { + const [integrationId] = args; + const existing = await target.findIntegrationById(integrationId); + return existing; + } + + if (prop === 'updateCredential') { + const [credentialId, updates] = args; + return { id: credentialId, ...updates, _dryRun: true }; + } + + // Default mock + return { success: true, _dryRun: true }; + }; + }, + }); +} + +module.exports = { + createDryRunWrapper, + wrapAdminFriggCommandsForDryRun, + sanitizeArgs, +}; diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js index 6aba9664e..83dfa9e92 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -1,6 +1,8 @@ const { getScriptFactory } = require('./script-factory'); const { createAdminFriggCommands } = require('./admin-frigg-commands'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); +const { wrapAdminFriggCommandsForDryRun } = require('./dry-run-repository-wrapper'); +const { createDryRunHttpClient, injectDryRunHttpClient } = require('./dry-run-http-interceptor'); /** * Script Runner @@ -28,9 +30,10 @@ class ScriptRunner { * @param {string} options.mode - 'sync' | 'async' * @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress } * @param {string} options.executionId - Reuse existing execution ID + * @param {boolean} options.dryRun - Execute in dry-run mode (no writes, log operations) */ async execute(scriptName, params = {}, options = {}) { - const { trigger = 'MANUAL', audit = {}, executionId: existingExecutionId } = options; + const { trigger = 'MANUAL', audit = {}, executionId: existingExecutionId, dryRun = false } = options; // Get script class const scriptClass = this.scriptFactory.get(scriptName); @@ -61,14 +64,25 @@ class ScriptRunner { const startTime = new Date(); try { - // Update status to RUNNING - await this.commands.updateScriptExecutionStatus(executionId, 'RUNNING'); + // Update status to RUNNING (skip in dry-run) + if (!dryRun) { + await this.commands.updateScriptExecutionStatus(executionId, 'RUNNING'); + } // Create frigg commands for the script - const frigg = createAdminFriggCommands({ - executionId, - integrationFactory: this.integrationFactory, - }); + let frigg; + let operationLog = []; + + if (dryRun) { + // Dry-run mode: wrap commands to intercept writes + frigg = this.createDryRunFriggCommands(operationLog); + } else { + // Normal mode: create real commands + frigg = createAdminFriggCommands({ + executionId, + integrationFactory: this.integrationFactory, + }); + } // Create script instance const script = this.scriptFactory.createInstance(scriptName, { @@ -83,16 +97,34 @@ class ScriptRunner { const endTime = new Date(); const durationMs = endTime - startTime; - // Complete execution - await this.commands.completeScriptExecution(executionId, { - status: 'COMPLETED', - output, - metrics: { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - durationMs, - }, - }); + // Complete execution (skip in dry-run) + if (!dryRun) { + await this.commands.completeScriptExecution(executionId, { + status: 'COMPLETED', + output, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); + } + + // Return dry-run preview if in dry-run mode + if (dryRun) { + return { + executionId, + dryRun: true, + status: 'DRY_RUN_COMPLETED', + scriptName, + preview: { + operations: operationLog, + summary: this.summarizeOperations(operationLog), + scriptOutput: output, + }, + metrics: { durationMs }, + }; + } return { executionId, @@ -106,24 +138,27 @@ class ScriptRunner { const endTime = new Date(); const durationMs = endTime - startTime; - // Record failure - await this.commands.completeScriptExecution(executionId, { - status: 'FAILED', - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - metrics: { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - durationMs, - }, - }); + // Record failure (skip in dry-run) + if (!dryRun) { + await this.commands.completeScriptExecution(executionId, { + status: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); + } return { executionId, - status: 'FAILED', + dryRun, + status: dryRun ? 'DRY_RUN_FAILED' : 'FAILED', scriptName, error: { name: error.name, @@ -133,6 +168,83 @@ class ScriptRunner { }; } } + + /** + * Create dry-run version of AdminFriggCommands + * Intercepts all write operations and logs them + * + * @param {Array} operationLog - Array to collect logged operations + * @returns {Object} Wrapped AdminFriggCommands + */ + createDryRunFriggCommands(operationLog) { + // Create real commands (for read operations) + const realCommands = createAdminFriggCommands({ + executionId: null, // Don't persist logs in dry-run + integrationFactory: this.integrationFactory, + }); + + // Wrap commands to intercept writes + const wrappedCommands = wrapAdminFriggCommandsForDryRun(realCommands, operationLog); + + // Create dry-run HTTP client + const dryRunHttpClient = createDryRunHttpClient(operationLog); + + // Override instantiate to inject dry-run HTTP client + const originalInstantiate = wrappedCommands.instantiate.bind(wrappedCommands); + wrappedCommands.instantiate = async (integrationId) => { + const instance = await originalInstantiate(integrationId); + + // Inject dry-run HTTP client into the integration instance + injectDryRunHttpClient(instance, dryRunHttpClient); + + return instance; + }; + + return wrappedCommands; + } + + /** + * Summarize operations from dry-run log + * + * @param {Array} log - Operation log + * @returns {Object} Summary statistics + */ + summarizeOperations(log) { + const summary = { + totalOperations: log.length, + databaseWrites: 0, + httpRequests: 0, + byOperation: {}, + byModel: {}, + byService: {}, + }; + + for (const op of log) { + // Count by operation type + const operation = op.operation || op.method || 'UNKNOWN'; + summary.byOperation[operation] = (summary.byOperation[operation] || 0) + 1; + + // Database operations + if (op.model) { + summary.databaseWrites++; + summary.byModel[op.model] = summary.byModel[op.model] || []; + summary.byModel[op.model].push({ + operation: op.operation, + method: op.method, + timestamp: op.timestamp, + }); + } + + // HTTP requests + if (op.operation === 'HTTP_REQUEST') { + summary.httpRequests++; + const service = op.service || 'unknown'; + summary.byService[service] = (summary.byService[service] || 0) + 1; + } + } + + return summary; + } } function createScriptRunner(params = {}) { diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js index 50119a570..93e5d1918 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -274,4 +274,257 @@ describe('Admin Script Router', () => { }); }); }); + + describe('GET /admin/scripts/:scriptName/schedule', () => { + it('should return database schedule when override exists', async () => { + const dbSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 9 * * *', + timezone: 'America/New_York', + lastTriggeredAt: new Date('2025-01-01T09:00:00Z'), + nextTriggerAt: new Date('2025-01-02T09:00:00Z'), + awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', + awsRuleName: 'test-script-schedule', + createdAt: new Date('2025-01-01T00:00:00Z'), + updatedAt: new Date('2025-01-01T00:00:00Z'), + }; + + mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(dbSchedule); + + const response = await request(app).get('/admin/scripts/test-script/schedule'); + + expect(response.status).toBe(200); + expect(response.body.source).toBe('database'); + expect(response.body.enabled).toBe(true); + expect(response.body.cronExpression).toBe('0 9 * * *'); + expect(response.body.timezone).toBe('America/New_York'); + }); + + it('should return definition schedule when no database override', async () => { + mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(null); + + // Update test script to include schedule + class ScheduledTestScript extends TestScript { + static Definition = { + ...TestScript.Definition, + schedule: { + enabled: true, + cronExpression: '0 0 * * *', + timezone: 'UTC', + }, + }; + } + + mockFactory.get.mockReturnValue(ScheduledTestScript); + + const response = await request(app).get('/admin/scripts/test-script/schedule'); + + expect(response.status).toBe(200); + expect(response.body.source).toBe('definition'); + expect(response.body.enabled).toBe(true); + expect(response.body.cronExpression).toBe('0 0 * * *'); + expect(response.body.timezone).toBe('UTC'); + }); + + it('should return none when no schedule configured', async () => { + mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(null); + + const response = await request(app).get('/admin/scripts/test-script/schedule'); + + expect(response.status).toBe(200); + expect(response.body.source).toBe('none'); + expect(response.body.enabled).toBe(false); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app).get( + '/admin/scripts/non-existent/schedule' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); + + describe('PUT /admin/scripts/:scriptName/schedule', () => { + it('should create new schedule', async () => { + const newSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + lastTriggeredAt: null, + nextTriggerAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedule.source).toBe('database'); + expect(response.body.schedule.enabled).toBe(true); + expect(response.body.schedule.cronExpression).toBe('0 12 * * *'); + expect(mockCommands.upsertSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + }); + + it('should update existing schedule', async () => { + const updatedSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + lastTriggeredAt: new Date('2025-01-01T09:00:00Z'), + nextTriggerAt: null, + createdAt: new Date('2025-01-01T00:00:00Z'), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(updatedSchedule); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: false, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedule.enabled).toBe(false); + }); + + it('should require enabled field', async () => { + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + cronExpression: '0 12 * * *', + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_INPUT'); + expect(response.body.error).toContain('enabled'); + }); + + it('should require cronExpression when enabled is true', async () => { + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_INPUT'); + expect(response.body.error).toContain('cronExpression'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app) + .put('/admin/scripts/non-existent/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + }); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); + + describe('DELETE /admin/scripts/:scriptName/schedule', () => { + it('should delete schedule override', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + }, + }); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.deletedCount).toBe(1); + expect(response.body.message).toContain('removed'); + expect(mockCommands.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should return definition schedule after deleting override', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + }); + + // Update test script to include schedule + class ScheduledTestScript extends TestScript { + static Definition = { + ...TestScript.Definition, + schedule: { + enabled: true, + cronExpression: '0 0 * * *', + timezone: 'UTC', + }, + }; + } + + mockFactory.get.mockReturnValue(ScheduledTestScript); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(response.body.effectiveSchedule.source).toBe('definition'); + expect(response.body.effectiveSchedule.enabled).toBe(true); + }); + + it('should handle no schedule found', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 0, + }); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(response.body.deletedCount).toBe(0); + expect(response.body.message).toContain('No schedule override found'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app).delete( + '/admin/scripts/non-existent/schedule' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); }); diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index 6308fdb55..0a9aae31f 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -74,12 +74,12 @@ router.get('/scripts/:scriptName', async (req, res) => { /** * POST /admin/scripts/:scriptName/execute - * Execute a script (sync or async) + * Execute a script (sync, async, or dry-run) */ router.post('/scripts/:scriptName/execute', async (req, res) => { try { const { scriptName } = req.params; - const { params = {}, mode = 'async' } = req.body; + const { params = {}, mode = 'async', dryRun = false } = req.body; const factory = getScriptFactory(); if (!factory.has(scriptName)) { @@ -89,6 +89,18 @@ router.post('/scripts/:scriptName/execute', async (req, res) => { }); } + // Dry-run always executes synchronously + if (dryRun) { + const runner = createScriptRunner(); + const result = await runner.execute(scriptName, params, { + trigger: 'MANUAL', + mode: 'sync', + dryRun: true, + audit: req.adminAudit, + }); + return res.json(result); + } + if (mode === 'sync') { // Synchronous execution - wait for result const runner = createScriptRunner(); @@ -180,6 +192,192 @@ router.get('/executions', async (req, res) => { } }); +/** + * GET /admin/scripts/:scriptName/schedule + * Get effective schedule (DB override > Definition default > none) + */ +router.get('/scripts/:scriptName/schedule', async (req, res) => { + try { + const { scriptName } = req.params; + const factory = getScriptFactory(); + const commands = createAdminScriptCommands(); + + // 1. Validate script exists + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + // 2. Get script class to access Definition + const scriptClass = factory.get(scriptName); + const definitionSchedule = scriptClass.Definition?.schedule; + + // 3. Get database schedule (if exists) + const dbSchedule = await commands.getScheduleByScriptName(scriptName); + + // 4. Apply hybrid schedule logic: DB override > Definition default > none + if (dbSchedule) { + // Database override exists + return res.json({ + source: 'database', + scriptName, + enabled: dbSchedule.enabled, + cronExpression: dbSchedule.cronExpression, + timezone: dbSchedule.timezone, + lastTriggeredAt: dbSchedule.lastTriggeredAt, + nextTriggerAt: dbSchedule.nextTriggerAt, + awsRuleArn: dbSchedule.awsRuleArn, + awsRuleName: dbSchedule.awsRuleName, + createdAt: dbSchedule.createdAt, + updatedAt: dbSchedule.updatedAt, + }); + } + + if (definitionSchedule?.enabled) { + // Definition default exists + return res.json({ + source: 'definition', + scriptName, + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + }); + } + + // No schedule configured + return res.json({ + source: 'none', + scriptName, + enabled: false, + }); + } catch (error) { + console.error('Error getting schedule:', error); + res.status(500).json({ error: 'Failed to get schedule' }); + } +}); + +/** + * PUT /admin/scripts/:scriptName/schedule + * Create or update schedule override + */ +router.put('/scripts/:scriptName/schedule', async (req, res) => { + try { + const { scriptName } = req.params; + const { enabled, cronExpression, timezone } = req.body; + const factory = getScriptFactory(); + const commands = createAdminScriptCommands(); + + // 1. Validate script exists + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + // 2. Validate required fields + if (typeof enabled !== 'boolean') { + return res.status(400).json({ + error: 'Field "enabled" is required and must be a boolean', + code: 'INVALID_INPUT', + }); + } + + if (enabled && !cronExpression) { + return res.status(400).json({ + error: 'Field "cronExpression" is required when enabled is true', + code: 'INVALID_INPUT', + }); + } + + // 3. Upsert schedule to database + const schedule = await commands.upsertSchedule({ + scriptName, + enabled, + cronExpression: cronExpression || null, + timezone: timezone || 'UTC', + }); + + // 4. TODO (Phase 3): Create/update EventBridge schedule if enabled + // if (enabled && cronExpression) { + // const awsInfo = await provisionEventBridgeSchedule(scriptName, cronExpression, timezone); + // await commands.updateScheduleAwsRule(scriptName, awsInfo); + // } + + res.json({ + success: true, + schedule: { + source: 'database', + scriptName: schedule.scriptName, + enabled: schedule.enabled, + cronExpression: schedule.cronExpression, + timezone: schedule.timezone, + lastTriggeredAt: schedule.lastTriggeredAt, + nextTriggerAt: schedule.nextTriggerAt, + createdAt: schedule.createdAt, + updatedAt: schedule.updatedAt, + }, + }); + } catch (error) { + console.error('Error updating schedule:', error); + res.status(500).json({ error: 'Failed to update schedule' }); + } +}); + +/** + * DELETE /admin/scripts/:scriptName/schedule + * Remove schedule override (revert to Definition default) + */ +router.delete('/scripts/:scriptName/schedule', async (req, res) => { + try { + const { scriptName } = req.params; + const factory = getScriptFactory(); + const commands = createAdminScriptCommands(); + + // 1. Validate script exists + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + // 2. Delete schedule from database + const result = await commands.deleteSchedule(scriptName); + + // 3. TODO (Phase 3): Delete EventBridge schedule if exists + // if (result.deleted?.awsRuleArn) { + // await deleteEventBridgeSchedule(result.deleted.awsRuleName); + // } + + // 4. Check if Definition default exists + const scriptClass = factory.get(scriptName); + const definitionSchedule = scriptClass.Definition?.schedule; + + res.json({ + success: true, + deletedCount: result.deletedCount, + message: + result.deletedCount > 0 + ? 'Schedule override removed' + : 'No schedule override found', + effectiveSchedule: definitionSchedule?.enabled + ? { + source: 'definition', + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + } + : { source: 'none', enabled: false }, + }); + } catch (error) { + console.error('Error deleting schedule:', error); + res.status(500).json({ error: 'Failed to delete schedule' }); + } +}); + // Create Express app const app = express(); app.use(express.json()); diff --git a/packages/core/admin-scripts/index.js b/packages/core/admin-scripts/index.js index 8a952b3eb..f6f94810b 100644 --- a/packages/core/admin-scripts/index.js +++ b/packages/core/admin-scripts/index.js @@ -14,6 +14,7 @@ // Repository Interfaces const { AdminApiKeyRepositoryInterface } = require('./repositories/admin-api-key-repository-interface'); const { ScriptExecutionRepositoryInterface } = require('./repositories/script-execution-repository-interface'); +const { ScriptScheduleRepositoryInterface } = require('./repositories/script-schedule-repository-interface'); // Repository Factories const { @@ -28,15 +29,23 @@ const { ScriptExecutionRepositoryPostgres, ScriptExecutionRepositoryDocumentDB, } = require('./repositories/script-execution-repository-factory'); +const { + createScriptScheduleRepository, + ScriptScheduleRepositoryMongo, + ScriptScheduleRepositoryPostgres, + ScriptScheduleRepositoryDocumentDB, +} = require('./repositories/script-schedule-repository-factory'); module.exports = { // Repository Interfaces AdminApiKeyRepositoryInterface, ScriptExecutionRepositoryInterface, + ScriptScheduleRepositoryInterface, // Repository Factories (primary exports for use cases) createAdminApiKeyRepository, createScriptExecutionRepository, + createScriptScheduleRepository, // Concrete Implementations (for testing) AdminApiKeyRepositoryMongo, @@ -45,4 +54,7 @@ module.exports = { ScriptExecutionRepositoryMongo, ScriptExecutionRepositoryPostgres, ScriptExecutionRepositoryDocumentDB, + ScriptScheduleRepositoryMongo, + ScriptScheduleRepositoryPostgres, + ScriptScheduleRepositoryDocumentDB, }; diff --git a/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js new file mode 100644 index 000000000..5e73f5c11 --- /dev/null +++ b/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js @@ -0,0 +1,119 @@ +const { ScriptScheduleRepositoryInterface } = require('../script-schedule-repository-interface'); + +describe('ScriptScheduleRepositoryInterface', () => { + let repository; + + beforeEach(() => { + repository = new ScriptScheduleRepositoryInterface(); + }); + + describe('Interface contract', () => { + it('should throw error when findScheduleByScriptName is not implemented', async () => { + await expect( + repository.findScheduleByScriptName('test-script') + ).rejects.toThrow('Method findScheduleByScriptName must be implemented by subclass'); + }); + + it('should throw error when upsertSchedule is not implemented', async () => { + await expect( + repository.upsertSchedule({ + scriptName: 'test-script', + enabled: true, + cronExpression: '0 0 * * *', + timezone: 'UTC', + }) + ).rejects.toThrow('Method upsertSchedule must be implemented by subclass'); + }); + + it('should throw error when deleteSchedule is not implemented', async () => { + await expect( + repository.deleteSchedule('test-script') + ).rejects.toThrow('Method deleteSchedule must be implemented by subclass'); + }); + + it('should throw error when updateScheduleAwsRule is not implemented', async () => { + await expect( + repository.updateScheduleAwsRule('test-script', { + awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test-rule', + awsRuleName: 'test-rule', + }) + ).rejects.toThrow('Method updateScheduleAwsRule must be implemented by subclass'); + }); + + it('should throw error when updateScheduleLastTriggered is not implemented', async () => { + await expect( + repository.updateScheduleLastTriggered('test-script', new Date()) + ).rejects.toThrow('Method updateScheduleLastTriggered must be implemented by subclass'); + }); + + it('should throw error when updateScheduleNextTrigger is not implemented', async () => { + await expect( + repository.updateScheduleNextTrigger('test-script', new Date()) + ).rejects.toThrow('Method updateScheduleNextTrigger must be implemented by subclass'); + }); + + it('should throw error when listSchedules is not implemented', async () => { + await expect( + repository.listSchedules() + ).rejects.toThrow('Method listSchedules must be implemented by subclass'); + }); + }); + + describe('Method signatures', () => { + it('should accept scriptName in findScheduleByScriptName', async () => { + await expect( + repository.findScheduleByScriptName('test-script') + ).rejects.toThrow(); + }); + + it('should accept all required parameters in upsertSchedule', async () => { + const params = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', + awsRuleName: 'test-rule', + }; + + await expect(repository.upsertSchedule(params)).rejects.toThrow(); + }); + + it('should accept scriptName in deleteSchedule', async () => { + await expect( + repository.deleteSchedule('test-script') + ).rejects.toThrow(); + }); + + it('should accept scriptName and awsInfo in updateScheduleAwsRule', async () => { + await expect( + repository.updateScheduleAwsRule('test-script', { + awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', + awsRuleName: 'test-rule', + }) + ).rejects.toThrow(); + }); + + it('should accept scriptName and timestamp in updateScheduleLastTriggered', async () => { + await expect( + repository.updateScheduleLastTriggered('test-script', new Date()) + ).rejects.toThrow(); + }); + + it('should accept scriptName and timestamp in updateScheduleNextTrigger', async () => { + await expect( + repository.updateScheduleNextTrigger('test-script', new Date()) + ).rejects.toThrow(); + }); + + it('should accept options in listSchedules', async () => { + await expect( + repository.listSchedules({ enabledOnly: true }) + ).rejects.toThrow(); + }); + + it('should accept no parameters in listSchedules', async () => { + await expect(repository.listSchedules()).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-documentdb.js b/packages/core/admin-scripts/repositories/script-schedule-repository-documentdb.js new file mode 100644 index 000000000..cc1f97936 --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-documentdb.js @@ -0,0 +1,21 @@ +const { + ScriptScheduleRepositoryMongo, +} = require('./script-schedule-repository-mongo'); + +/** + * DocumentDB Script Schedule Repository Adapter + * Handles script schedule persistence using Prisma with AWS DocumentDB + * + * DocumentDB is MongoDB-compatible with some limitations: + * - Uses MongoDB wire protocol + * - Same Prisma schema as MongoDB + * - Inherits all MongoDB repository methods + * + * For schedule operations, DocumentDB and MongoDB behavior is identical. + */ +class ScriptScheduleRepositoryDocumentDB extends ScriptScheduleRepositoryMongo { + // Inherits all methods from MongoDB implementation + // DocumentDB is MongoDB-compatible for these operations +} + +module.exports = { ScriptScheduleRepositoryDocumentDB }; diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js b/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js new file mode 100644 index 000000000..dc8e44974 --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-factory.js @@ -0,0 +1,51 @@ +const { ScriptScheduleRepositoryMongo } = require('./script-schedule-repository-mongo'); +const { ScriptScheduleRepositoryPostgres } = require('./script-schedule-repository-postgres'); +const { + ScriptScheduleRepositoryDocumentDB, +} = require('./script-schedule-repository-documentdb'); +const config = require('../../database/config'); + +/** + * Script Schedule Repository Factory + * Creates the appropriate repository adapter based on database type + * + * This implements the Factory pattern for Hexagonal Architecture: + * - Reads database type from app definition (backend/index.js) + * - Returns correct adapter (MongoDB, DocumentDB, or PostgreSQL) + * - Provides clear error for unsupported databases + * + * Usage: + * ```javascript + * const repository = createScriptScheduleRepository(); + * ``` + * + * @returns {ScriptScheduleRepositoryInterface} Configured repository adapter + * @throws {Error} If database type is not supported + */ +function createScriptScheduleRepository() { + const dbType = config.DB_TYPE; + + switch (dbType) { + case 'mongodb': + return new ScriptScheduleRepositoryMongo(); + + case 'postgresql': + return new ScriptScheduleRepositoryPostgres(); + + case 'documentdb': + return new ScriptScheduleRepositoryDocumentDB(); + + default: + throw new Error( + `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'` + ); + } +} + +module.exports = { + createScriptScheduleRepository, + // Export adapters for direct testing + ScriptScheduleRepositoryMongo, + ScriptScheduleRepositoryPostgres, + ScriptScheduleRepositoryDocumentDB, +}; diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js b/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js new file mode 100644 index 000000000..5da7a946e --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js @@ -0,0 +1,108 @@ +/** + * Script Schedule Repository Interface + * Abstract base class defining the contract for script schedule persistence adapters + * + * This follows the Port in Hexagonal Architecture: + * - Domain layer depends on this abstraction + * - Concrete adapters implement this interface + * - Use cases receive repositories via dependency injection + * + * Script schedules support Phase 2 hybrid scheduling: + * - Database overrides take precedence over Definition defaults + * - EventBridge rules provisioned for enabled schedules + * - lastTriggeredAt and nextTriggerAt for monitoring + * + * @abstract + */ +class ScriptScheduleRepositoryInterface { + /** + * Find a schedule by script name + * + * @param {string} scriptName - The script name + * @returns {Promise} Schedule record or null if not found + * @abstract + */ + async findScheduleByScriptName(scriptName) { + throw new Error('Method findScheduleByScriptName must be implemented by subclass'); + } + + /** + * Create or update a schedule (upsert) + * + * @param {Object} params - Schedule parameters + * @param {string} params.scriptName - Name of the script + * @param {boolean} params.enabled - Whether schedule is enabled + * @param {string} params.cronExpression - Cron expression + * @param {string} [params.timezone] - Timezone (default 'UTC') + * @param {string} [params.awsRuleArn] - AWS EventBridge rule ARN + * @param {string} [params.awsRuleName] - AWS EventBridge rule name + * @returns {Promise} Created or updated schedule record + * @abstract + */ + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsRuleArn, awsRuleName }) { + throw new Error('Method upsertSchedule must be implemented by subclass'); + } + + /** + * Delete a schedule by script name + * + * @param {string} scriptName - The script name + * @returns {Promise} Deletion result + * @abstract + */ + async deleteSchedule(scriptName) { + throw new Error('Method deleteSchedule must be implemented by subclass'); + } + + /** + * Update AWS EventBridge rule information + * + * @param {string} scriptName - The script name + * @param {Object} awsInfo - AWS rule information + * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN + * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @returns {Promise} Updated schedule record + * @abstract + */ + async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { + throw new Error('Method updateScheduleAwsRule must be implemented by subclass'); + } + + /** + * Update last triggered timestamp + * + * @param {string} scriptName - The script name + * @param {Date} [timestamp] - Trigger timestamp (default: now) + * @returns {Promise} Updated schedule record + * @abstract + */ + async updateScheduleLastTriggered(scriptName, timestamp) { + throw new Error('Method updateScheduleLastTriggered must be implemented by subclass'); + } + + /** + * Update next trigger timestamp + * + * @param {string} scriptName - The script name + * @param {Date} timestamp - Next trigger timestamp + * @returns {Promise} Updated schedule record + * @abstract + */ + async updateScheduleNextTrigger(scriptName, timestamp) { + throw new Error('Method updateScheduleNextTrigger must be implemented by subclass'); + } + + /** + * List all schedules + * + * @param {Object} [options] - Query options + * @param {boolean} [options.enabledOnly] - Only return enabled schedules + * @returns {Promise} Array of schedule records + * @abstract + */ + async listSchedules(options = {}) { + throw new Error('Method listSchedules must be implemented by subclass'); + } +} + +module.exports = { ScriptScheduleRepositoryInterface }; diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js b/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js new file mode 100644 index 000000000..05ff62b1b --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js @@ -0,0 +1,179 @@ +const { prisma } = require('../../database/prisma'); +const { + ScriptScheduleRepositoryInterface, +} = require('./script-schedule-repository-interface'); + +/** + * MongoDB Script Schedule Repository Adapter + * Handles script schedule persistence using Prisma with MongoDB + * + * MongoDB-specific characteristics: + * - IDs are strings with @db.ObjectId + * - scriptName has unique index + * - Supports upsert operations natively + */ +class ScriptScheduleRepositoryMongo extends ScriptScheduleRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Find a schedule by script name + * + * @param {string} scriptName - The script name + * @returns {Promise} Schedule record or null if not found + */ + async findScheduleByScriptName(scriptName) { + const schedule = await this.prisma.scriptSchedule.findUnique({ + where: { scriptName }, + }); + + return schedule; + } + + /** + * Create or update a schedule (upsert) + * + * @param {Object} params - Schedule parameters + * @param {string} params.scriptName - Name of the script + * @param {boolean} params.enabled - Whether schedule is enabled + * @param {string} params.cronExpression - Cron expression + * @param {string} [params.timezone] - Timezone (default 'UTC') + * @param {string} [params.awsRuleArn] - AWS EventBridge rule ARN + * @param {string} [params.awsRuleName] - AWS EventBridge rule name + * @returns {Promise} Created or updated schedule record + */ + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsRuleArn, awsRuleName }) { + const data = { + enabled, + cronExpression, + timezone: timezone || 'UTC', + }; + + // Only set AWS fields if provided + if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; + if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + + const schedule = await this.prisma.scriptSchedule.upsert({ + where: { scriptName }, + update: data, + create: { + scriptName, + ...data, + }, + }); + + return schedule; + } + + /** + * Delete a schedule by script name + * + * @param {string} scriptName - The script name + * @returns {Promise} Deletion result + */ + async deleteSchedule(scriptName) { + try { + const schedule = await this.prisma.scriptSchedule.delete({ + where: { scriptName }, + }); + + return { + acknowledged: true, + deletedCount: 1, + deleted: schedule, + }; + } catch (error) { + // Return 0 count if not found + if (error.code === 'P2025') { + return { + acknowledged: true, + deletedCount: 0, + }; + } + throw error; + } + } + + /** + * Update AWS EventBridge rule information + * + * @param {string} scriptName - The script name + * @param {Object} awsInfo - AWS rule information + * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN + * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @returns {Promise} Updated schedule record + */ + async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { + const data = {}; + if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; + if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + + const schedule = await this.prisma.scriptSchedule.update({ + where: { scriptName }, + data, + }); + + return schedule; + } + + /** + * Update last triggered timestamp + * + * @param {string} scriptName - The script name + * @param {Date} [timestamp] - Trigger timestamp (default: now) + * @returns {Promise} Updated schedule record + */ + async updateScheduleLastTriggered(scriptName, timestamp) { + const schedule = await this.prisma.scriptSchedule.update({ + where: { scriptName }, + data: { + lastTriggeredAt: timestamp || new Date(), + }, + }); + + return schedule; + } + + /** + * Update next trigger timestamp + * + * @param {string} scriptName - The script name + * @param {Date} timestamp - Next trigger timestamp + * @returns {Promise} Updated schedule record + */ + async updateScheduleNextTrigger(scriptName, timestamp) { + const schedule = await this.prisma.scriptSchedule.update({ + where: { scriptName }, + data: { + nextTriggerAt: timestamp, + }, + }); + + return schedule; + } + + /** + * List all schedules + * + * @param {Object} [options] - Query options + * @param {boolean} [options.enabledOnly] - Only return enabled schedules + * @returns {Promise} Array of schedule records + */ + async listSchedules(options = {}) { + const where = {}; + if (options.enabledOnly) { + where.enabled = true; + } + + const schedules = await this.prisma.scriptSchedule.findMany({ + where, + orderBy: { scriptName: 'asc' }, + }); + + return schedules; + } +} + +module.exports = { ScriptScheduleRepositoryMongo }; diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js new file mode 100644 index 000000000..481cea2f1 --- /dev/null +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js @@ -0,0 +1,210 @@ +const { prisma } = require('../../database/prisma'); +const { + ScriptScheduleRepositoryInterface, +} = require('./script-schedule-repository-interface'); + +/** + * PostgreSQL Script Schedule Repository Adapter + * Handles script schedule persistence using Prisma with PostgreSQL + * + * PostgreSQL-specific characteristics: + * - Uses Int IDs with autoincrement + * - Requires ID conversion: String (app layer) ↔ Int (database) + * - All returned IDs are converted to strings for application layer consistency + * - scriptName has unique index + */ +class ScriptScheduleRepositoryPostgres extends ScriptScheduleRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Convert string ID to integer for PostgreSQL queries + * @private + * @param {string|number|null|undefined} id - ID to convert + * @returns {number|null|undefined} Integer ID or null/undefined + * @throws {Error} If ID cannot be converted to integer + */ + _convertId(id) { + if (id === null || id === undefined) return id; + const parsed = parseInt(id, 10); + if (isNaN(parsed)) { + throw new Error(`Invalid ID: ${id} cannot be converted to integer`); + } + return parsed; + } + + /** + * Convert schedule object IDs to strings + * @private + * @param {Object|null} schedule - Schedule object from database + * @returns {Object|null} Schedule with string IDs + */ + _convertScheduleIds(schedule) { + if (!schedule) return schedule; + return { + ...schedule, + id: schedule.id?.toString(), + }; + } + + /** + * Find a schedule by script name + * + * @param {string} scriptName - The script name + * @returns {Promise} Schedule record with string ID or null if not found + */ + async findScheduleByScriptName(scriptName) { + const schedule = await this.prisma.scriptSchedule.findUnique({ + where: { scriptName }, + }); + + return this._convertScheduleIds(schedule); + } + + /** + * Create or update a schedule (upsert) + * + * @param {Object} params - Schedule parameters + * @param {string} params.scriptName - Name of the script + * @param {boolean} params.enabled - Whether schedule is enabled + * @param {string} params.cronExpression - Cron expression + * @param {string} [params.timezone] - Timezone (default 'UTC') + * @param {string} [params.awsRuleArn] - AWS EventBridge rule ARN + * @param {string} [params.awsRuleName] - AWS EventBridge rule name + * @returns {Promise} Created or updated schedule record with string ID + */ + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsRuleArn, awsRuleName }) { + const data = { + enabled, + cronExpression, + timezone: timezone || 'UTC', + }; + + // Only set AWS fields if provided + if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; + if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + + const schedule = await this.prisma.scriptSchedule.upsert({ + where: { scriptName }, + update: data, + create: { + scriptName, + ...data, + }, + }); + + return this._convertScheduleIds(schedule); + } + + /** + * Delete a schedule by script name + * + * @param {string} scriptName - The script name + * @returns {Promise} Deletion result + */ + async deleteSchedule(scriptName) { + try { + const schedule = await this.prisma.scriptSchedule.delete({ + where: { scriptName }, + }); + + return { + acknowledged: true, + deletedCount: 1, + deleted: this._convertScheduleIds(schedule), + }; + } catch (error) { + // Return 0 count if not found + if (error.code === 'P2025') { + return { + acknowledged: true, + deletedCount: 0, + }; + } + throw error; + } + } + + /** + * Update AWS EventBridge rule information + * + * @param {string} scriptName - The script name + * @param {Object} awsInfo - AWS rule information + * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN + * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @returns {Promise} Updated schedule record with string ID + */ + async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { + const data = {}; + if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; + if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + + const schedule = await this.prisma.scriptSchedule.update({ + where: { scriptName }, + data, + }); + + return this._convertScheduleIds(schedule); + } + + /** + * Update last triggered timestamp + * + * @param {string} scriptName - The script name + * @param {Date} [timestamp] - Trigger timestamp (default: now) + * @returns {Promise} Updated schedule record with string ID + */ + async updateScheduleLastTriggered(scriptName, timestamp) { + const schedule = await this.prisma.scriptSchedule.update({ + where: { scriptName }, + data: { + lastTriggeredAt: timestamp || new Date(), + }, + }); + + return this._convertScheduleIds(schedule); + } + + /** + * Update next trigger timestamp + * + * @param {string} scriptName - The script name + * @param {Date} timestamp - Next trigger timestamp + * @returns {Promise} Updated schedule record with string ID + */ + async updateScheduleNextTrigger(scriptName, timestamp) { + const schedule = await this.prisma.scriptSchedule.update({ + where: { scriptName }, + data: { + nextTriggerAt: timestamp, + }, + }); + + return this._convertScheduleIds(schedule); + } + + /** + * List all schedules + * + * @param {Object} [options] - Query options + * @param {boolean} [options.enabledOnly] - Only return enabled schedules + * @returns {Promise} Array of schedule records with string IDs + */ + async listSchedules(options = {}) { + const where = {}; + if (options.enabledOnly) { + where.enabled = true; + } + + const schedules = await this.prisma.scriptSchedule.findMany({ + where, + orderBy: { scriptName: 'asc' }, + }); + + return schedules.map((schedule) => this._convertScheduleIds(schedule)); + } +} + +module.exports = { ScriptScheduleRepositoryPostgres }; diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js index d58e9ec73..fc9024bd7 100644 --- a/packages/core/application/commands/admin-script-commands.js +++ b/packages/core/application/commands/admin-script-commands.js @@ -29,9 +29,11 @@ function createAdminScriptCommands() { // Lazy-load repository factories to avoid circular dependencies const { createAdminApiKeyRepository } = require('../../admin-scripts/repositories/admin-api-key-repository-factory'); const { createScriptExecutionRepository } = require('../../admin-scripts/repositories/script-execution-repository-factory'); + const { createScriptScheduleRepository } = require('../../admin-scripts/repositories/script-schedule-repository-factory'); const apiKeyRepository = createAdminApiKeyRepository(); const executionRepository = createScriptExecutionRepository(); + const scheduleRepository = createScriptScheduleRepository(); return { // ==================== API Key Management Commands ==================== @@ -335,6 +337,120 @@ function createAdminScriptCommands() { return []; } }, + + // ==================== Schedule Management Commands ==================== + + /** + * Get schedule by script name + * Returns database override or null + * + * @param {string} scriptName - The script name + * @returns {Promise} Schedule record or null + */ + async getScheduleByScriptName(scriptName) { + try { + const schedule = await scheduleRepository.findScheduleByScriptName(scriptName); + return schedule; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Create or update a schedule (upsert) + * + * @param {Object} params - Schedule parameters + * @param {string} params.scriptName - Name of the script + * @param {boolean} params.enabled - Whether schedule is enabled + * @param {string} params.cronExpression - Cron expression + * @param {string} [params.timezone] - Timezone (default 'UTC') + * @returns {Promise} Created or updated schedule + */ + async upsertSchedule({ scriptName, enabled, cronExpression, timezone }) { + try { + const schedule = await scheduleRepository.upsertSchedule({ + scriptName, + enabled, + cronExpression, + timezone, + }); + return schedule; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Delete a schedule by script name + * + * @param {string} scriptName - The script name + * @returns {Promise} Deletion result + */ + async deleteSchedule(scriptName) { + try { + const result = await scheduleRepository.deleteSchedule(scriptName); + return result; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Update AWS EventBridge rule information + * + * @param {string} scriptName - The script name + * @param {Object} awsInfo - AWS rule information + * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN + * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @returns {Promise} Updated schedule + */ + async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { + try { + const schedule = await scheduleRepository.updateScheduleAwsRule(scriptName, { + awsRuleArn, + awsRuleName, + }); + return schedule; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * Update last triggered timestamp + * Called when a schedule triggers + * + * @param {string} scriptName - The script name + * @param {Date} [timestamp] - Trigger timestamp (default: now) + * @returns {Promise} Updated schedule + */ + async updateScheduleLastTriggered(scriptName, timestamp) { + try { + const schedule = await scheduleRepository.updateScheduleLastTriggered( + scriptName, + timestamp + ); + return schedule; + } catch (error) { + return mapErrorToResponse(error); + } + }, + + /** + * List all schedules + * + * @param {Object} [options] - Query options + * @param {boolean} [options.enabledOnly] - Only return enabled schedules + * @returns {Promise} Array of schedule records + */ + async listSchedules(options = {}) { + try { + const schedules = await scheduleRepository.listSchedules(options); + return schedules; + } catch (error) { + return []; + } + }, }; } diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 5aa2ec0b4..91dc02cce 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -428,3 +428,24 @@ model ScriptExecution { @@index([status]) @@map("ScriptExecution") } + +/// Script scheduling configuration for hybrid scheduling (SQS + EventBridge) +model ScriptSchedule { + id String @id @default(auto()) @map("_id") @db.ObjectId + scriptName String @unique + enabled Boolean @default(false) + cronExpression String? + timezone String @default("UTC") + lastTriggeredAt DateTime? + nextTriggerAt DateTime? + + // AWS EventBridge Rule (if provisioned) + awsRuleArn String? + awsRuleName String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([enabled]) + @@map("ScriptSchedule") +} diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index 29a5e627d..caf4853a6 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -409,3 +409,23 @@ model ScriptExecution { @@index([scriptName, createdAt(sort: Desc)]) @@index([status]) } + +/// Script scheduling configuration for hybrid scheduling (SQS + EventBridge) +model ScriptSchedule { + id Int @id @default(autoincrement()) + scriptName String @unique + enabled Boolean @default(false) + cronExpression String? + timezone String @default("UTC") + lastTriggeredAt DateTime? + nextTriggerAt DateTime? + + // AWS EventBridge Rule (if provisioned) + awsRuleArn String? + awsRuleName String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([enabled]) +} From bb5ca378555483c1830815dc4c310a88a32a440b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 21:12:55 +0000 Subject: [PATCH 11/33] chore: remove plan file from version control --- PLAN-admin-script-runner.md | 2127 ----------------------------------- 1 file changed, 2127 deletions(-) delete mode 100644 PLAN-admin-script-runner.md diff --git a/PLAN-admin-script-runner.md b/PLAN-admin-script-runner.md deleted file mode 100644 index 022e2fd96..000000000 --- a/PLAN-admin-script-runner.md +++ /dev/null @@ -1,2127 +0,0 @@ -# Admin Script Runner Service - Implementation Plan - -> **Status**: Planning (Updated for `next` branch architecture) -> **Target Branch**: `next` -> **Feature Branch**: `claude/add-admin-script-runner-*` - ---- - -## Executive Summary - -The Admin Script Runner enables Frigg adopters to write and execute scripts in a hosted environment with access to VPC/KMS-secured database connections. This is a **high-risk, high-value** feature requiring careful security controls. - -### Core Use Cases -1. **Healing Scripts** - Fix broken integrations (e.g., Attio config corruption) -2. **Recurring Maintenance** - Webhook refreshers (e.g., Zoho channel expiry) -3. **Built-in Utilities** - OAuth refresh, DB cleanup, log rotation - ---- - -## CRITICAL: `next` Branch Architecture Alignment - -The `next` branch has a **fundamentally different architecture** from `main`: - -| Aspect | `main` Branch | `next` Branch | -|--------|---------------|---------------| -| ORM | Mongoose | Prisma | -| Data Access | Direct Model calls | Command Pattern | -| DB Support | MongoDB only | MongoDB, PostgreSQL, DocumentDB | -| Repository | None | Interface + Factory Pattern | -| Encryption | Basic | Field-level KMS/AES encryption | - -**This plan follows `next` branch patterns:** -- `createAdminScriptCommands()` factory (like `createIntegrationCommands()`) -- Repository interfaces with factory pattern -- Prisma schema definitions -- Encryption schema registry integration - ---- - -## appDefinition Schema Update - -**File to modify**: `/home/user/frigg/packages/devtools/infrastructure/domains/shared/types/app-definition.js` - -Add `adminScripts` to the AppDefinition typedef: - -```javascript -/** - * Complete application definition - * @typedef {Object} AppDefinition - * @property {string} name - Application name - * @property {string} stage - Deployment stage - * @property {IntegrationDefinition[]} [integrations] - Integration definitions - * @property {AdminScriptDefinition[]} [adminScripts] - Admin script definitions (NEW) - * @property {AdminConfig} [admin] - Admin configuration (NEW) - * ... - */ -``` - -**Usage in backend/index.js**: -```javascript -const Definition = { - name: 'my-app', - integrations: [ - HubSpotIntegration, - SalesforceIntegration, - ], - - // NEW: Admin scripts array (OPTIONAL) - adminScripts: [ - AttioHealingScript, - ZohoWebhookRefreshScript, - ], - - // NEW: Admin configuration (OPTIONAL) - admin: { - includeBuiltinScripts: true, - }, - - database: { postgres: { enable: true } }, -}; - -module.exports = { Definition }; -``` - ---- - -## AdminScriptBase Definition Pattern - -**Following IntegrationBase pattern from**: `/home/user/frigg/packages/core/integrations/integration-base.js:35-100` - -```javascript -// packages/core/admin-scripts/admin-script-base.js - -const { createScriptExecutionRepository } = require('./repositories/script-execution-repository-factory'); -const { createAdminApiKeyRepository } = require('./repositories/admin-api-key-repository-factory'); - -class AdminScriptBase { - // Class-level repository instances (like IntegrationBase lines 37-44) - scriptExecutionRepository = createScriptExecutionRepository(); - adminApiKeyRepository = createAdminApiKeyRepository(); - - /** - * CHILDREN SHOULD SPECIFY A DEFINITION FOR THE SCRIPT - * Pattern matches IntegrationBase.Definition (lines 57-69) - */ - static Definition = { - name: 'Script Name', // Required: unique identifier - version: '0.0.0', // Required: semver for migrations - description: 'What this script does', // Required: human-readable - - // Script-specific properties - source: 'USER_DEFINED', // 'BUILTIN' | 'USER_DEFINED' - - inputSchema: null, // Optional: JSON Schema for params - outputSchema: null, // Optional: JSON Schema for results - - schedule: { // Optional: Phase 2 - enabled: false, - cronExpression: null, // 'cron(0 12 * * ? *)' - }, - - config: { - timeout: 300000, // Default 5 min (ms) - maxRetries: 0, - requiresIntegrationFactory: false, // Hint: does script need to instantiate integrations? - }, - - display: { // For future UI - label: 'Script Name', - description: '', - category: 'maintenance', // 'maintenance' | 'healing' | 'sync' | 'custom' - }, - }; - - static getName() { - return this.Definition.name; - } - - static getCurrentVersion() { - return this.Definition.version; - } - - static getDefinition() { - return this.Definition; - } - - /** - * Constructor receives dependencies - * Pattern matches IntegrationBase constructor (lines 81-100) - */ - constructor(params = {}) { - this.executionId = params.executionId || null; - this.logs = []; - this._startTime = null; - - // OPTIONAL: Integration factory for scripts that need it - this.integrationFactory = params.integrationFactory || null; - - // Injected repositories (can override class-level) - if (params.scriptExecutionRepository) { - this.scriptExecutionRepository = params.scriptExecutionRepository; - } - if (params.adminApiKeyRepository) { - this.adminApiKeyRepository = params.adminApiKeyRepository; - } - } - - /** - * CHILDREN MUST IMPLEMENT THIS METHOD - * @param {AdminFriggCommands} frigg - Helper commands object - * @param {Object} params - Script parameters (validated against inputSchema) - * @returns {Promise} - Script results (validated against outputSchema) - */ - async execute(frigg, params) { - throw new Error('AdminScriptBase.execute() must be implemented by subclass'); - } - - // Logging helper - log(level, message, data = {}) { - const entry = { - level, - message, - data, - timestamp: new Date().toISOString(), - }; - this.logs.push(entry); - return entry; - } - - getLogs() { - return this.logs; - } -} - -module.exports = { AdminScriptBase }; -``` - ---- - -## Architecture Decision Records - -### ADR-1: Follow Definition Pattern from IntegrationBase - -**Decision**: Create `AdminScriptBase` with `static Definition` matching IntegrationBase pattern. - -**Reference**: `/home/user/frigg/packages/core/integrations/integration-base.js:57-69` - -**Rationale**: -- Consistent with existing Frigg patterns -- Familiar to Frigg developers -- Supports versioning and migrations -- Enables validation at load time - -### ADR-2: Repository Factory Pattern (No-Arg Constructors) - -**Decision**: Create repository factories following existing pattern. - -**Reference**: `/home/user/frigg/packages/core/integrations/repositories/integration-repository-factory.js` - -**Pattern**: -```javascript -// Factory returns instance with NO arguments -function createScriptExecutionRepository() { - const dbType = config.DB_TYPE; - switch (dbType) { - case 'mongodb': return new ScriptExecutionRepositoryMongo(); - case 'postgresql': return new ScriptExecutionRepositoryPostgres(); - case 'documentdb': return new ScriptExecutionRepositoryDocumentDB(); - default: throw new Error(`Unsupported database type: ${dbType}`); - } -} -``` - -### ADR-3: Optional IntegrationFactory - -**Decision**: `integrationFactory` is OPTIONAL for admin scripts. - -**Rationale**: -- Many scripts only need database access (cleanup, reporting) -- Scripts that need to call external APIs require `integrationFactory` -- Fail-fast with clear error if script needs factory but none provided - -**Implementation**: -```javascript -// Scripts declare their needs via Definition.config -static Definition = { - config: { - requiresIntegrationFactory: true, // or false - } -}; - -// ScriptRunner validates before execution -if (scriptClass.Definition.config.requiresIntegrationFactory && !integrationFactory) { - throw new Error(`Script "${scriptName}" requires integrationFactory`); -} -``` - -### ADR-4: Separate adminScripts Array in appDefinition - -**Decision**: Add `adminScripts[]` to appDefinition schema (separate from `integrations[]`). - -**Reference**: `/home/user/frigg/packages/devtools/infrastructure/domains/shared/types/app-definition.js` - -**Rationale**: -- Clear separation of concerns -- Scripts are operational, integrations are domain -- Can be deployed independently - -### ADR-5: Execution Modes (Sync vs Async) - -**Decision**: One-off scripts support both synchronous and asynchronous execution. - -**Behavior**: -- **Default**: Asynchronous (queued) with execution ID returned immediately -- **Optional**: Synchronous for simple scripts (dev's responsibility for timeout) - -**Implementation**: -```javascript -// POST /admin/scripts/:scriptName/execute -{ - "params": { /* script parameters */ }, - "mode": "async" // "async" (default) | "sync" -} - -// Response for async -{ - "executionId": "exec_abc123", - "status": "PENDING", - "scriptName": "attio-healing", - "message": "Script queued for execution" -} - -// Response for sync (returns when complete) -{ - "executionId": "exec_abc123", - "status": "COMPLETED", - "scriptName": "attio-healing", - "output": { /* script result */ }, - "metrics": { "durationMs": 1234 } -} -``` - -**Rationale**: -- Async is safer (Lambda timeout protection, queue durability) -- Sync is convenient for simple scripts and debugging -- Developer chooses based on script complexity - -### ADR-6: Hybrid Scheduling Approach - -**Decision**: Script schedules can come from Definition (hardcoded) OR database/API (runtime). - -**Priority Order** (highest to lowest): -1. **Database/API override** - Runtime schedule stored in `ScriptSchedule` model -2. **Definition default** - `static Definition.schedule` in script class - -**Implementation**: -```javascript -// 1. Definition default (hardcoded in script) -class ZohoWebhookRefreshScript extends AdminScriptBase { - static Definition = { - name: 'zoho-webhook-refresh', - schedule: { - enabled: true, - cronExpression: 'cron(0 */12 * * ? *)', // Every 12 hours - } - }; -} - -// 2. Database override (via API or seed) -// POST /admin/scripts/:scriptName/schedule -{ - "enabled": true, - "cronExpression": "cron(0 6 * * ? *)", // Override to 6 AM daily - "timezone": "America/New_York" -} -``` - -**New Model**: -```prisma -model ScriptSchedule { - id String @id @default(auto()) @map("_id") @db.ObjectId - scriptName String @unique - enabled Boolean @default(false) - cronExpression String? - timezone String @default("UTC") - lastTriggeredAt DateTime? - nextTriggerAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // AWS EventBridge Rule ARN (if provisioned) - awsRuleArn String? - awsRuleName String? -} -``` - -**Rationale**: -- Definition provides sensible defaults -- API allows runtime modification without code changes -- Supports multi-tenant scenarios with different schedules - -### ADR-7: DDD/Hexagonal Architecture for Admin Scripts - -**Decision**: Follow the established DDD/Hexagonal architecture patterns from devtools. - -**Reference Files**: -- `/home/user/frigg/packages/devtools/infrastructure/domains/shared/base-builder.js` -- `/home/user/frigg/packages/devtools/infrastructure/domains/shared/providers/cloud-provider-adapter.js` -- `/home/user/frigg/packages/devtools/infrastructure/domains/integration/integration-builder.js` - -**Architecture Layers**: -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Adapter Layer (Handlers/Routers) │ -│ packages/admin-scripts/src/infrastructure/ │ -│ - admin-script-router.js │ -│ - admin-auth-middleware.js │ -│ - create-script-handler.js │ -└──────────────────────────┬──────────────────────────────────────────┘ - │ calls -┌──────────────────────────▼──────────────────────────────────────────┐ -│ Application Layer (Use Cases / Commands) │ -│ packages/admin-scripts/src/application/ │ -│ - script-runner.js (orchestrates execution) │ -│ - script-factory.js (registry & instantiation) │ -│ - admin-frigg-commands.js (helper API for scripts) │ -│ packages/core/application/commands/ │ -│ - admin-script-commands.js (API key & execution management) │ -└──────────────────────────┬──────────────────────────────────────────┘ - │ calls -┌──────────────────────────▼──────────────────────────────────────────┐ -│ Infrastructure Layer (Adapters / Repositories) │ -│ packages/core/admin-scripts/repositories/ │ -│ - script-execution-repository-*.js │ -│ - admin-api-key-repository-*.js │ -│ packages/admin-scripts/src/adapters/ │ -│ - scheduler-adapter.js (port interface) │ -│ - aws-scheduler-adapter.js (EventBridge implementation) │ -│ - local-scheduler-adapter.js (dev/test implementation) │ -└──────────────────────────┬──────────────────────────────────────────┘ - │ accesses -┌──────────────────────────▼──────────────────────────────────────────┐ -│ External Systems │ -│ - Prisma (MongoDB, PostgreSQL, DocumentDB) │ -│ - AWS EventBridge Scheduler │ -│ - SQS Queues │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### ADR-8: Scheduler Adapter Pattern (EventBridge) - -**Decision**: Create scheduler adapter following `CloudProviderAdapter` pattern. - -**Reference**: `/home/user/frigg/packages/devtools/infrastructure/domains/shared/providers/cloud-provider-adapter.js` - -**Port Interface** (Abstract): -```javascript -// packages/admin-scripts/src/adapters/scheduler-adapter.js - -/** - * Scheduler Adapter (Abstract Base Class) - * - * Port - Hexagonal Architecture - * - * Defines the contract for scheduler implementations. - * Supports AWS EventBridge, local cron, or other providers. - */ -class SchedulerAdapter { - getName() { - throw new Error('SchedulerAdapter.getName() must be implemented'); - } - - /** - * Create or update a schedule for a script - * @param {Object} config - * @param {string} config.scriptName - Script identifier - * @param {string} config.cronExpression - Cron expression - * @param {Object} [config.input] - Optional input params - * @returns {Promise} Created schedule { ruleArn, ruleName } - */ - async createSchedule(config) { - throw new Error('SchedulerAdapter.createSchedule() must be implemented'); - } - - /** - * Delete a schedule - * @param {string} scriptName - Script identifier - * @returns {Promise} - */ - async deleteSchedule(scriptName) { - throw new Error('SchedulerAdapter.deleteSchedule() must be implemented'); - } - - /** - * Enable or disable a schedule - * @param {string} scriptName - Script identifier - * @param {boolean} enabled - Whether to enable - * @returns {Promise} - */ - async setScheduleEnabled(scriptName, enabled) { - throw new Error('SchedulerAdapter.setScheduleEnabled() must be implemented'); - } - - /** - * List all schedules - * @returns {Promise} List of schedules - */ - async listSchedules() { - throw new Error('SchedulerAdapter.listSchedules() must be implemented'); - } -} - -module.exports = { SchedulerAdapter }; -``` - -**AWS Implementation**: -```javascript -// packages/admin-scripts/src/adapters/aws-scheduler-adapter.js - -const { SchedulerAdapter } = require('./scheduler-adapter'); - -// Lazy-loaded AWS SDK clients (following AWSProviderAdapter pattern) -let SchedulerClient, CreateScheduleCommand, DeleteScheduleCommand, - GetScheduleCommand, UpdateScheduleCommand, ListSchedulesCommand; - -function loadSchedulerSDK() { - if (!SchedulerClient) { - const schedulerModule = require('@aws-sdk/client-scheduler'); - SchedulerClient = schedulerModule.SchedulerClient; - CreateScheduleCommand = schedulerModule.CreateScheduleCommand; - DeleteScheduleCommand = schedulerModule.DeleteScheduleCommand; - GetScheduleCommand = schedulerModule.GetScheduleCommand; - UpdateScheduleCommand = schedulerModule.UpdateScheduleCommand; - ListSchedulesCommand = schedulerModule.ListSchedulesCommand; - } -} - -class AWSSchedulerAdapter extends SchedulerAdapter { - constructor({ region, credentials, targetLambdaArn, scheduleGroupName }) { - super(); - this.region = region || process.env.AWS_REGION || 'us-east-1'; - this.credentials = credentials; - this.targetLambdaArn = targetLambdaArn; - this.scheduleGroupName = scheduleGroupName || 'frigg-admin-scripts'; - this.scheduler = null; - } - - getSchedulerClient() { - if (!this.scheduler) { - loadSchedulerSDK(); - this.scheduler = new SchedulerClient({ - region: this.region, - ...this.credentials, - }); - } - return this.scheduler; - } - - getName() { - return 'aws-eventbridge-scheduler'; - } - - async createSchedule({ scriptName, cronExpression, timezone, input }) { - const client = this.getSchedulerClient(); - const scheduleName = `frigg-script-${scriptName}`; - - const command = new CreateScheduleCommand({ - Name: scheduleName, - GroupName: this.scheduleGroupName, - ScheduleExpression: cronExpression, - ScheduleExpressionTimezone: timezone || 'UTC', - FlexibleTimeWindow: { Mode: 'OFF' }, - Target: { - Arn: this.targetLambdaArn, - RoleArn: process.env.SCHEDULER_ROLE_ARN, - Input: JSON.stringify({ - scriptName, - trigger: 'SCHEDULED', - params: input || {}, - }), - }, - State: 'ENABLED', - }); - - const response = await client.send(command); - return { - ruleArn: response.ScheduleArn, - ruleName: scheduleName, - }; - } - - async deleteSchedule(scriptName) { - const client = this.getSchedulerClient(); - const scheduleName = `frigg-script-${scriptName}`; - - await client.send(new DeleteScheduleCommand({ - Name: scheduleName, - GroupName: this.scheduleGroupName, - })); - } - - async setScheduleEnabled(scriptName, enabled) { - const client = this.getSchedulerClient(); - const scheduleName = `frigg-script-${scriptName}`; - - await client.send(new UpdateScheduleCommand({ - Name: scheduleName, - GroupName: this.scheduleGroupName, - State: enabled ? 'ENABLED' : 'DISABLED', - })); - } - - async listSchedules() { - const client = this.getSchedulerClient(); - - const response = await client.send(new ListSchedulesCommand({ - GroupName: this.scheduleGroupName, - })); - - return response.Schedules || []; - } -} - -module.exports = { AWSSchedulerAdapter }; -``` - -**Local Implementation** (for dev/test): -```javascript -// packages/admin-scripts/src/adapters/local-scheduler-adapter.js - -const { SchedulerAdapter } = require('./scheduler-adapter'); - -class LocalSchedulerAdapter extends SchedulerAdapter { - constructor() { - super(); - this.schedules = new Map(); - this.intervals = new Map(); - } - - getName() { - return 'local-cron'; - } - - async createSchedule({ scriptName, cronExpression, input }) { - // Store schedule (actual cron execution would use node-cron) - this.schedules.set(scriptName, { - scriptName, - cronExpression, - input, - enabled: true, - createdAt: new Date().toISOString(), - }); - return { ruleName: scriptName }; - } - - async deleteSchedule(scriptName) { - this.schedules.delete(scriptName); - if (this.intervals.has(scriptName)) { - clearInterval(this.intervals.get(scriptName)); - this.intervals.delete(scriptName); - } - } - - async setScheduleEnabled(scriptName, enabled) { - const schedule = this.schedules.get(scriptName); - if (schedule) { - schedule.enabled = enabled; - } - } - - async listSchedules() { - return Array.from(this.schedules.values()); - } -} - -module.exports = { LocalSchedulerAdapter }; -``` - -### ADR-9: AdminScriptBuilder for Infrastructure Generation - -**Decision**: Create `AdminScriptBuilder` following the existing builder pattern. - -**Reference**: -- `/home/user/frigg/packages/devtools/infrastructure/domains/shared/base-builder.js` -- `/home/user/frigg/packages/devtools/infrastructure/domains/integration/integration-builder.js` - -**Implementation**: -```javascript -// packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js - -const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder'); - -/** - * Admin Script Builder - * - * Domain Layer - Hexagonal Architecture - * - * Responsible for: - * - Creating SQS queue for admin script execution - * - Creating Lambda function for script execution - * - Creating EventBridge Scheduler resources (Phase 2) - * - Creating IAM roles for scheduler to invoke Lambda - */ -class AdminScriptBuilder extends InfrastructureBuilder { - constructor() { - super(); - this.name = 'AdminScriptBuilder'; - } - - shouldExecute(appDefinition) { - return Array.isArray(appDefinition.adminScripts) && appDefinition.adminScripts.length > 0; - } - - getDependencies() { - return []; // Can run independently - } - - validate(appDefinition) { - const result = new ValidationResult(); - - if (!appDefinition.adminScripts) { - return result; // Not an error, just no scripts - } - - if (!Array.isArray(appDefinition.adminScripts)) { - result.addError('adminScripts must be an array'); - return result; - } - - // Validate each script - appDefinition.adminScripts.forEach((script, index) => { - if (!script?.Definition?.name) { - result.addError(`Admin script at index ${index} is missing Definition or name`); - } - }); - - return result; - } - - async build(appDefinition, discoveredResources) { - console.log(`\n[${this.name}] Configuring admin scripts...`); - console.log(` Processing ${appDefinition.adminScripts.length} scripts...`); - - const usePrismaLayer = appDefinition.usePrismaLambdaLayer !== false; - const adminConfig = appDefinition.admin || {}; - - const result = { - functions: {}, - resources: {}, - environment: {}, - custom: {}, - iamStatements: [], - }; - - // Create admin script queue - this.createAdminScriptQueue(result); - - // Create Lambda function for script execution - this.createScriptExecutorFunction(result, usePrismaLayer); - - // Create API routes for script management - this.createAdminScriptRoutes(result, usePrismaLayer); - - // Phase 2: Create EventBridge Scheduler resources - if (adminConfig.enableScheduling) { - this.createSchedulerResources(appDefinition, result); - } - - // Log registered scripts - appDefinition.adminScripts.forEach(script => { - const name = script.Definition?.name || 'unknown'; - const schedule = script.Definition?.schedule; - console.log(` ✓ Registered: ${name}${schedule?.enabled ? ' (scheduled)' : ''}`); - }); - - console.log(`[${this.name}] ✅ Admin script configuration completed`); - return result; - } - - createAdminScriptQueue(result) { - result.resources.AdminScriptQueue = { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: '${self:service}-${self:provider.stage}-AdminScriptQueue', - MessageRetentionPeriod: 86400, // 1 day - VisibilityTimeout: 900, // 15 minutes (Lambda max) - RedrivePolicy: { - maxReceiveCount: 3, - deadLetterTargetArn: { - 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'], - }, - }, - }, - }; - - result.environment.ADMIN_SCRIPT_QUEUE_URL = { Ref: 'AdminScriptQueue' }; - console.log(' ✓ Created AdminScriptQueue'); - } - - createScriptExecutorFunction(result, usePrismaLayer) { - result.functions.adminScriptExecutor = { - handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/script-executor-handler.handler', - skipEsbuild: true, - ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), - timeout: 900, // 15 minutes max - memorySize: 1024, - events: [ - { - sqs: { - arn: { 'Fn::GetAtt': ['AdminScriptQueue', 'Arn'] }, - batchSize: 1, - }, - }, - ], - }; - console.log(' ✓ Created adminScriptExecutor function'); - } - - createAdminScriptRoutes(result, usePrismaLayer) { - result.functions.adminScriptRouter = { - handler: 'node_modules/@friggframework/admin-scripts/src/infrastructure/admin-script-router.handler', - skipEsbuild: true, - ...(usePrismaLayer && { layers: [{ Ref: 'PrismaLambdaLayer' }] }), - timeout: 30, - events: [ - // List scripts - { httpApi: { path: '/admin/scripts', method: 'GET' } }, - // Get script details - { httpApi: { path: '/admin/scripts/{scriptName}', method: 'GET' } }, - // Execute script (sync or async) - { httpApi: { path: '/admin/scripts/{scriptName}/execute', method: 'POST' } }, - // Get execution status - { httpApi: { path: '/admin/executions/{executionId}', method: 'GET' } }, - // List executions - { httpApi: { path: '/admin/executions', method: 'GET' } }, - // Schedule management (Phase 2) - { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'GET' } }, - { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'PUT' } }, - { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'DELETE' } }, - ], - }; - console.log(' ✓ Created adminScriptRouter function'); - } - - createSchedulerResources(appDefinition, result) { - // Create IAM role for EventBridge Scheduler - result.resources.AdminScriptSchedulerRole = { - Type: 'AWS::IAM::Role', - Properties: { - RoleName: '${self:service}-${self:provider.stage}-admin-script-scheduler', - AssumeRolePolicyDocument: { - Version: '2012-10-17', - Statement: [{ - Effect: 'Allow', - Principal: { Service: 'scheduler.amazonaws.com' }, - Action: 'sts:AssumeRole', - }], - }, - Policies: [{ - PolicyName: 'InvokeLambda', - PolicyDocument: { - Version: '2012-10-17', - Statement: [{ - Effect: 'Allow', - Action: 'lambda:InvokeFunction', - Resource: { 'Fn::GetAtt': ['AdminScriptExecutorLambdaFunction', 'Arn'] }, - }], - }, - }], - }, - }; - - // Create schedule group - result.resources.AdminScriptScheduleGroup = { - Type: 'AWS::Scheduler::ScheduleGroup', - Properties: { - Name: '${self:service}-${self:provider.stage}-admin-scripts', - }, - }; - - result.environment.SCHEDULER_ROLE_ARN = { 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'] }; - result.environment.SCHEDULE_GROUP_NAME = { Ref: 'AdminScriptScheduleGroup' }; - - console.log(' ✓ Created EventBridge Scheduler resources'); - } -} - -module.exports = { AdminScriptBuilder }; -``` - -### Wiring AdminScriptBuilder into Deployment - -**File to modify**: `/home/user/frigg/packages/devtools/infrastructure/infrastructure-composer.js` - -The `AdminScriptBuilder` must be registered with the `BuilderOrchestrator` to be included in the serverless template generation: - -```javascript -// packages/devtools/infrastructure/infrastructure-composer.js - -// Add import -const { AdminScriptBuilder } = require('./domains/admin-scripts/admin-script-builder'); - -// Register in orchestrator (line ~46-54) -const orchestrator = new BuilderOrchestrator([ - new VpcBuilder(), - new KmsBuilder(), - new AuroraBuilder(), - new MigrationBuilder(), - new SsmBuilder(), - new WebsocketBuilder(), - new IntegrationBuilder(), - new AdminScriptBuilder(), // NEW: Admin script infrastructure -]); -``` - -**Deployment Flow**: -``` -1. User runs `frigg deploy` or `serverless deploy` - ↓ -2. serverless.js calls composeServerlessDefinition(AppDefinition) - ↓ -3. BuilderOrchestrator validates each builder's shouldExecute() - - AdminScriptBuilder.shouldExecute() returns true if adminScripts[] exists - ↓ -4. Builders execute in dependency order - - AdminScriptBuilder has no dependencies, can run in parallel - ↓ -5. AdminScriptBuilder.build() generates: - - AdminScriptQueue (SQS) - - adminScriptExecutor (Lambda function) - - adminScriptRouter (Lambda function with HTTP routes) - - EventBridge Scheduler resources (if admin.enableScheduling = true) - ↓ -6. BuilderOrchestrator.mergeResults() combines all builder outputs - ↓ -7. Final serverless.yml includes: - - functions: { adminScriptExecutor, adminScriptRouter, ... } - - resources: { AdminScriptQueue, AdminScriptSchedulerRole, ... } - - provider.environment: { ADMIN_SCRIPT_QUEUE_URL, ... } -``` - -**Generated Serverless Resources** (when `adminScripts[]` is defined): -```yaml -# serverless.yml (generated) -functions: - adminScriptExecutor: - handler: node_modules/@friggframework/admin-scripts/src/infrastructure/script-executor-handler.handler - timeout: 900 - memorySize: 1024 - layers: - - Ref: PrismaLambdaLayer - events: - - sqs: - arn: !GetAtt AdminScriptQueue.Arn - batchSize: 1 - - adminScriptRouter: - handler: node_modules/@friggframework/admin-scripts/src/infrastructure/admin-script-router.handler - timeout: 30 - layers: - - Ref: PrismaLambdaLayer - events: - - httpApi: { path: '/admin/scripts', method: GET } - - httpApi: { path: '/admin/scripts/{scriptName}', method: GET } - - httpApi: { path: '/admin/scripts/{scriptName}/execute', method: POST } - - httpApi: { path: '/admin/executions/{executionId}', method: GET } - - httpApi: { path: '/admin/executions', method: GET } - -resources: - Resources: - AdminScriptQueue: - Type: AWS::SQS::Queue - Properties: - QueueName: ${self:service}-${self:provider.stage}-AdminScriptQueue - MessageRetentionPeriod: 86400 - VisibilityTimeout: 900 - RedrivePolicy: - maxReceiveCount: 3 - deadLetterTargetArn: !GetAtt InternalErrorQueue.Arn -``` - ---- - -## Domain Model (Prisma Schema) - -### Prisma Schema Additions - -```prisma -// Add to packages/core/prisma-mongodb/schema.prisma - -enum ScriptExecutionStatus { - PENDING - RUNNING - COMPLETED - FAILED - TIMEOUT - CANCELLED -} - -enum ScriptTrigger { - MANUAL - SCHEDULED - QUEUE - WEBHOOK -} - -model AdminApiKey { - id String @id @default(auto()) @map("_id") @db.ObjectId - keyHash String @unique // bcrypt hashed - keyLast4 String // Last 4 chars for display - name String // Human-readable name - scopes String[] // ['scripts:execute', 'scripts:read'] - expiresAt DateTime? - createdBy String? // User/admin who created - lastUsedAt DateTime? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([keyHash]) - @@index([isActive]) -} - -model ScriptExecution { - id String @id @default(auto()) @map("_id") @db.ObjectId - scriptName String - scriptVersion String? - status ScriptExecutionStatus @default(PENDING) - trigger ScriptTrigger - mode String @default("async") // "sync" | "async" - input Json? - output Json? - logs Json[] // [{level, message, data, timestamp}] - metricsStartTime DateTime? - metricsEndTime DateTime? - metricsDurationMs Int? - errorName String? - errorMessage String? - errorStack String? - auditApiKeyName String? - auditApiKeyLast4 String? - auditIpAddress String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([scriptName, createdAt(sort: Desc)]) - @@index([status]) -} - -model ScriptSchedule { - id String @id @default(auto()) @map("_id") @db.ObjectId - scriptName String @unique - enabled Boolean @default(false) - cronExpression String? - timezone String @default("UTC") - lastTriggeredAt DateTime? - nextTriggerAt DateTime? - - // AWS EventBridge Rule (if provisioned) - awsRuleArn String? - awsRuleName String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([enabled]) -} -``` - -### PostgreSQL Schema (Equivalent) - -```prisma -// Add to packages/core/prisma-postgresql/schema.prisma - -enum ScriptExecutionStatus { - PENDING - RUNNING - COMPLETED - FAILED - TIMEOUT - CANCELLED -} - -enum ScriptTrigger { - MANUAL - SCHEDULED - QUEUE - WEBHOOK -} - -model AdminApiKey { - id Int @id @default(autoincrement()) - keyHash String @unique - keyLast4 String - name String - scopes String[] - expiresAt DateTime? - createdBy String? - lastUsedAt DateTime? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([keyHash]) - @@index([isActive]) -} - -model ScriptExecution { - id Int @id @default(autoincrement()) - scriptName String - scriptVersion String? - status ScriptExecutionStatus @default(PENDING) - trigger ScriptTrigger - mode String @default("async") // "sync" | "async" - input Json? - output Json? - logs Json[] - metricsStartTime DateTime? - metricsEndTime DateTime? - metricsDurationMs Int? - errorName String? - errorMessage String? - errorStack String? - auditApiKeyName String? - auditApiKeyLast4 String? - auditIpAddress String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([scriptName, createdAt(sort: Desc)]) - @@index([status]) -} - -model ScriptSchedule { - id Int @id @default(autoincrement()) - scriptName String @unique - enabled Boolean @default(false) - cronExpression String? - timezone String @default("UTC") - lastTriggeredAt DateTime? - nextTriggerAt DateTime? - - // AWS EventBridge Rule (if provisioned) - awsRuleArn String? - awsRuleName String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([enabled]) -} -``` - ---- - -## Repository Interfaces - -### AdminApiKeyRepositoryInterface - -```javascript -// packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js -class AdminApiKeyRepositoryInterface { - async createApiKey({ name, scopes, expiresAt, createdBy }) { } - async findApiKeyByHash(keyHash) { } - async findApiKeyById(id) { } - async findActiveApiKeys() { } - async updateApiKeyLastUsed(id) { } - async deactivateApiKey(id) { } - async deleteApiKey(id) { } -} -``` - -### ScriptExecutionRepositoryInterface - -```javascript -// packages/core/admin-scripts/repositories/script-execution-repository-interface.js -class ScriptExecutionRepositoryInterface { - async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { } - async findExecutionById(id) { } - async findExecutionsByScriptName(scriptName, options = {}) { } - async findExecutionsByStatus(status, options = {}) { } - async updateExecutionStatus(id, status) { } - async updateExecutionOutput(id, output) { } - async updateExecutionError(id, error) { } - async updateExecutionMetrics(id, metrics) { } - async appendExecutionLog(id, logEntry) { } - async deleteExecutionsOlderThan(date) { } -} -``` - -### ScriptScheduleRepositoryInterface - -```javascript -// packages/core/admin-scripts/repositories/script-schedule-repository-interface.js -class ScriptScheduleRepositoryInterface { - async createSchedule({ scriptName, enabled, cronExpression, timezone }) { } - async findScheduleByScriptName(scriptName) { } - async findEnabledSchedules() { } - async updateSchedule(scriptName, updates) { } - async updateLastTriggered(scriptName, timestamp) { } - async updateAwsRule(scriptName, { ruleArn, ruleName }) { } - async deleteSchedule(scriptName) { } -} -``` - ---- - -## Command Pattern Implementation - -### createAdminScriptCommands() - -```javascript -// packages/core/application/commands/admin-script-commands.js -const { createAdminApiKeyRepository } = require('../../admin-scripts/repositories/admin-api-key-repository-factory'); -const { createScriptExecutionRepository } = require('../../admin-scripts/repositories/script-execution-repository-factory'); -const bcrypt = require('bcryptjs'); - -const ERROR_CODE_MAP = { - INVALID_API_KEY: 401, - EXPIRED_API_KEY: 401, - SCRIPT_NOT_FOUND: 404, - EXECUTION_NOT_FOUND: 404, - UNAUTHORIZED_SCOPE: 403, -}; - -function mapErrorToResponse(error) { - const status = ERROR_CODE_MAP[error?.code] || 500; - return { error: status, reason: error?.message, code: error?.code }; -} - -function createAdminScriptCommands() { - const apiKeyRepository = createAdminApiKeyRepository(); - const executionRepository = createScriptExecutionRepository(); - - return { - // API Key Management - async createAdminApiKey({ name, scopes, expiresAt, createdBy }) { - try { - const rawKey = require('uuid').v4(); - const keyHash = await bcrypt.hash(rawKey, 10); - const keyLast4 = rawKey.slice(-4); - - const record = await apiKeyRepository.createApiKey({ - keyHash, keyLast4, name, scopes, expiresAt, createdBy - }); - - return { - id: record.id, - rawKey, // Only returned once! - name: record.name, - keyLast4: record.keyLast4, - scopes: record.scopes, - expiresAt: record.expiresAt, - }; - } catch (error) { - return mapErrorToResponse(error); - } - }, - - async validateAdminApiKey(rawKey) { - try { - // Find all active keys and compare hashes - const activeKeys = await apiKeyRepository.findActiveApiKeys(); - for (const key of activeKeys) { - const isMatch = await bcrypt.compare(rawKey, key.keyHash); - if (isMatch) { - if (key.expiresAt && new Date(key.expiresAt) < new Date()) { - const error = new Error('API key has expired'); - error.code = 'EXPIRED_API_KEY'; - return mapErrorToResponse(error); - } - await apiKeyRepository.updateApiKeyLastUsed(key.id); - return { valid: true, apiKey: key }; - } - } - const error = new Error('Invalid API key'); - error.code = 'INVALID_API_KEY'; - return mapErrorToResponse(error); - } catch (error) { - return mapErrorToResponse(error); - } - }, - - // Execution Management - async createScriptExecution({ scriptName, scriptVersion, trigger, input, audit }) { - try { - return await executionRepository.createExecution({ - scriptName, scriptVersion, trigger, input, audit - }); - } catch (error) { - return mapErrorToResponse(error); - } - }, - - async updateScriptExecutionStatus(executionId, status) { - try { - return await executionRepository.updateExecutionStatus(executionId, status); - } catch (error) { - return mapErrorToResponse(error); - } - }, - - async findScriptExecutionById(executionId) { - try { - const execution = await executionRepository.findExecutionById(executionId); - if (!execution) { - const error = new Error('Execution not found'); - error.code = 'EXECUTION_NOT_FOUND'; - return mapErrorToResponse(error); - } - return execution; - } catch (error) { - return mapErrorToResponse(error); - } - }, - - async findScriptExecutionsByName(scriptName, options = {}) { - try { - return await executionRepository.findExecutionsByScriptName(scriptName, options); - } catch (error) { - return []; - } - }, - - async appendScriptExecutionLog(executionId, logEntry) { - try { - return await executionRepository.appendExecutionLog(executionId, logEntry); - } catch (error) { - return mapErrorToResponse(error); - } - }, - - async completeScriptExecution(executionId, { status, output, error, metrics }) { - try { - if (status) await executionRepository.updateExecutionStatus(executionId, status); - if (output) await executionRepository.updateExecutionOutput(executionId, output); - if (error) await executionRepository.updateExecutionError(executionId, error); - if (metrics) await executionRepository.updateExecutionMetrics(executionId, metrics); - return { success: true }; - } catch (err) { - return mapErrorToResponse(err); - } - }, - }; -} - -module.exports = { createAdminScriptCommands }; -``` - ---- - -## Security Requirements (Phased) - -### Phase 1: MVP - BLOCKING Requirements - -| Requirement | Implementation | Priority | -|------------|----------------|----------| -| Admin API Key Auth | `AdminApiKey` model with bcrypt hash | P0 | -| Execution Timeout | Reuse `TimeoutCatcher` (5-15 min max) | P0 | -| Basic Audit Log | `ScriptExecution` model stores all runs | P0 | -| Input Validation | JSON Schema validation on params | P0 | -| Result Size Limits | Max 1MB output, truncate if exceeded | P0 | - -### Phase 2: Production Hardening - -| Requirement | Implementation | Priority | -|------------|----------------|----------| -| Rate Limiting | Per-key: 10/min, Global: 100/min | P1 | -| Tenant Scoping | Query interceptor for tenant filter | P1 | -| Credential Proxy | Scripts request by name, not raw values | P1 | -| Dry-Run Mode | Preview affected records before execution | P1 | - -### Phase 3: Enterprise Features - -| Requirement | Implementation | Priority | -|------------|----------------|----------| -| Approval Workflow | Two-admin approval for production scripts | P2 | -| VM Sandbox | `isolated-vm` for untrusted scripts | P2 | -| Rollback | Pre-execution snapshots, auto-revert on error | P2 | - ---- - -## Package Structure (Updated for `next`) - -``` -packages/core/ # Models & Repositories in core -├── admin-scripts/ -│ ├── repositories/ -│ │ ├── admin-api-key-repository-interface.js -│ │ ├── admin-api-key-repository-factory.js -│ │ ├── admin-api-key-repository-mongo.js -│ │ ├── admin-api-key-repository-postgres.js -│ │ ├── admin-api-key-repository-documentdb.js -│ │ ├── script-execution-repository-interface.js -│ │ ├── script-execution-repository-factory.js -│ │ ├── script-execution-repository-mongo.js -│ │ ├── script-execution-repository-postgres.js -│ │ ├── script-execution-repository-documentdb.js -│ │ ├── script-schedule-repository-interface.js # NEW: For hybrid scheduling -│ │ ├── script-schedule-repository-factory.js -│ │ ├── script-schedule-repository-mongo.js -│ │ ├── script-schedule-repository-postgres.js -│ │ └── script-schedule-repository-documentdb.js -│ └── index.js -├── application/ -│ └── commands/ -│ └── admin-script-commands.js # Command factory -├── prisma-mongodb/ -│ └── schema.prisma # Add AdminApiKey, ScriptExecution, ScriptSchedule -└── prisma-postgresql/ - └── schema.prisma # Add AdminApiKey, ScriptExecution, ScriptSchedule - -packages/devtools/ # Infrastructure builders -└── infrastructure/ - └── domains/ - └── admin-scripts/ # NEW: Admin script infrastructure - ├── admin-script-builder.js - └── admin-script-builder.test.js - -packages/admin-scripts/ # Application logic & builtins -├── package.json -├── index.js -├── src/ -│ ├── application/ -│ │ ├── script-factory.js # Script registration -│ │ ├── script-context.js # Execution context -│ │ ├── script-runner.js # Orchestrates execution -│ │ ├── admin-frigg-commands.js # Helper API for scripts -│ │ ├── dry-run-repository-wrapper.js # Phase 3: DB write interception -│ │ └── dry-run-http-interceptor.js # Phase 3: HTTP call interception -│ ├── infrastructure/ -│ │ ├── admin-script-router.js # Express router -│ │ ├── script-executor-handler.js # Lambda handler for async -│ │ ├── script-queue-worker.js # SQS worker -│ │ └── admin-auth-middleware.js # API key auth -│ ├── adapters/ # Hexagonal adapters (Phase 2) -│ │ ├── scheduler-adapter.js # Port interface (abstract) -│ │ ├── aws-scheduler-adapter.js # AWS EventBridge implementation -│ │ └── local-scheduler-adapter.js # Dev/test implementation -│ └── builtins/ -│ ├── oauth-token-refresh.js -│ ├── integration-health-check.js -│ └── index.js -├── test/ -│ ├── script-factory.test.js -│ ├── script-runner.test.js -│ ├── admin-script-commands.test.js -│ ├── adapters/ -│ │ ├── aws-scheduler-adapter.test.js -│ │ └── local-scheduler-adapter.test.js -│ └── integration/ -│ └── execute-script.test.js -└── types/ - └── index.d.ts -``` - ---- - -## AdminFriggCommands (Helper API for Scripts) - -**Reference**: Uses repository pattern from `/home/user/frigg/packages/core/application/commands/` - -Scripts receive a `frigg` object with database access. Integration factory is **OPTIONAL**. - -```javascript -// packages/core/admin-scripts/admin-frigg-commands.js - -const { createIntegrationRepository } = require('../integrations/repositories/integration-repository-factory'); -const { createUserRepository } = require('../user/repositories/user-repository-factory'); -const { createModuleRepository } = require('../modules/repositories/module-repository-factory'); -const { createCredentialRepository } = require('../credential/repositories/credential-repository-factory'); -const { createScriptExecutionRepository } = require('./repositories/script-execution-repository-factory'); - -class AdminFriggCommands { - // Repositories created via factories (no args, like other commands) - integrationRepository = createIntegrationRepository(); - userRepository = createUserRepository(); - moduleRepository = createModuleRepository(); - credentialRepository = createCredentialRepository(); - scriptExecutionRepository = createScriptExecutionRepository(); - - constructor(params = {}) { - this.executionId = params.executionId || null; - this.logs = []; - - // OPTIONAL: Integration factory for scripts that need to instantiate integrations - this.integrationFactory = params.integrationFactory || null; - } - - // ==================== ALWAYS AVAILABLE (Database Access) ==================== - - // Integration queries (no instantiation) - async listIntegrations(filter = {}) { - return this.integrationRepository.findIntegrations(filter); - } - - async findIntegrationById(id) { - return this.integrationRepository.findIntegrationById(id); - } - - async findIntegrationsByUserId(userId) { - return this.integrationRepository.findIntegrationsByUserId(userId); - } - - async updateIntegrationConfig(integrationId, config) { - return this.integrationRepository.updateIntegrationConfig(integrationId, config); - } - - async updateIntegrationStatus(integrationId, status) { - return this.integrationRepository.updateIntegrationStatus(integrationId, status); - } - - // User queries - async listUsers(filter = {}) { - // Implement based on filter - if (filter.appUserId) return this.userRepository.findIndividualUserByAppUserId(filter.appUserId); - if (filter.username) return this.userRepository.findIndividualUserByUsername(filter.username); - return null; - } - - async findUserById(userId) { - return this.userRepository.findIndividualUserById(userId); - } - - // Entity queries - async listEntities(filter = {}) { - if (filter.userId) { - return this.moduleRepository.findEntitiesByUserId(filter.userId); - } - return this.moduleRepository.findEntity(filter); - } - - async findEntityById(entityId) { - return this.moduleRepository.findEntityById(entityId); - } - - // Credential queries - async findCredential(filter) { - return this.credentialRepository.findCredential(filter); - } - - async updateCredential(credentialId, updates) { - return this.credentialRepository.updateCredential(credentialId, updates); - } - - // ==================== REQUIRES integrationFactory ==================== - - /** - * Instantiate an integration instance (for calling external APIs) - * REQUIRES: integrationFactory in constructor - */ - async instantiate(integrationId) { - if (!this.integrationFactory) { - throw new Error( - 'instantiate() requires integrationFactory. ' + - 'Set Definition.config.requiresIntegrationFactory = true' - ); - } - return this.integrationFactory.getInstanceFromIntegrationId({ - integrationId, - _isAdminContext: true, // Bypass user ownership check - }); - } - - // ==================== LOGGING & EXECUTION ==================== - - log(level, message, data = {}) { - const entry = { - level, - message, - data, - timestamp: new Date().toISOString(), - }; - this.logs.push(entry); - - // Persist to execution record if we have an executionId - if (this.executionId) { - this.scriptExecutionRepository.appendExecutionLog(this.executionId, entry) - .catch(err => console.error('Failed to persist log:', err)); - } - - return entry; - } - - getExecutionId() { - return this.executionId; - } - - getLogs() { - return this.logs; - } -} - -module.exports = { AdminFriggCommands }; -``` - -**Key Design Points**: -1. **Repository instances as class properties** (matches IntegrationBase pattern) -2. **No-arg repository factories** (matches existing pattern) -3. **integrationFactory is optional** - only needed for `instantiate()` -4. **Clear error message** when trying to instantiate without factory - ---- - -## Implementation Phases (Updated) - -### Phase 1: MVP (Sync & Async Execution) - -**Scope**: -1. Add Prisma schema for `AdminApiKey`, `ScriptExecution` -2. Create repository interfaces and factories (MongoDB, PostgreSQL, DocumentDB) -3. Implement `createAdminScriptCommands()` command factory -4. Build `ScriptFactory` for registration/loading -5. Build `AdminFriggCommands` helper API -6. Create admin router with `/execute` endpoint -7. **Sync execution mode** - Direct Lambda invocation with response -8. **Async execution mode** - SQS queue + worker Lambda (default) -9. Lambda handler with TimeoutCatcher -10. 2 built-in scripts (oauth-refresh, health-check) -11. `AdminScriptBuilder` for infrastructure generation - -**Execution Modes**: -- `POST /admin/scripts/:scriptName/execute { mode: "sync" }` - Direct execution, response includes result -- `POST /admin/scripts/:scriptName/execute { mode: "async" }` - Queued, returns execution ID immediately - -**Deliverables**: -1. Prisma schema additions + migrations -2. Repository implementations for all 3 DBs -3. Command factory -4. `@friggframework/admin-scripts` package -5. `AdminScriptBuilder` in devtools -6. Test suite - -### Phase 2: Hybrid Scheduling - -**Scope**: -1. Add `ScriptSchedule` Prisma model -2. Create `script-schedule-repository-*` implementations -3. Create `SchedulerAdapter` port interface (hexagonal) -4. Implement `AWSSchedulerAdapter` (EventBridge Scheduler) -5. Implement `LocalSchedulerAdapter` (for dev/test) -6. Schedule management API endpoints: - - `GET /admin/scripts/:scriptName/schedule` - Get schedule (Definition defaults + DB overrides) - - `PUT /admin/scripts/:scriptName/schedule` - Create/update schedule - - `DELETE /admin/scripts/:scriptName/schedule` - Remove schedule override -7. Zoho webhook refresher script (recurring) - -**Hybrid Scheduling Logic**: -```javascript -// Priority: DB override > Definition default -async function getEffectiveSchedule(scriptName, scriptClass) { - const dbSchedule = await scheduleRepository.findScheduleByScriptName(scriptName); - const definitionSchedule = scriptClass.Definition?.schedule; - - if (dbSchedule) { - return { source: 'database', ...dbSchedule }; - } - if (definitionSchedule?.enabled) { - return { source: 'definition', ...definitionSchedule }; - } - return null; -} -``` - -### Phase 3: Production Hardening - -**Scope**: -- Rate limiting middleware -- Tenant isolation -- Dry-run mode (ADR-10) - -### ADR-10: Dry Run Mode - -**Decision**: Implement dry run via adapter interception (repositories + HTTP client). - -**Components**: -1. **Repository Wrapper** - Intercepts DB writes, logs operations, returns unchanged data -2. **HTTP Client Interceptor** - Intercepts external API calls, logs requests, returns mock responses - -**Repository Wrapper**: -```javascript -// packages/admin-scripts/src/application/dry-run-repository-wrapper.js - -class DryRunRepositoryWrapper { - constructor(realRepository, operationLog) { - this.realRepo = realRepository; - this.log = operationLog; - } - - // READ operations pass through - async findIntegrationById(id) { - return this.realRepo.findIntegrationById(id); - } - - async listIntegrations(filter) { - return this.realRepo.listIntegrations(filter); - } - - // WRITE operations are logged, not executed - async updateIntegrationConfig(integrationId, config) { - const existing = await this.realRepo.findIntegrationById(integrationId); - this.log.push({ - operation: 'UPDATE', - model: 'Integration', - id: integrationId, - field: 'config', - before: existing?.config, - after: config, - wouldAffect: 1 - }); - return existing; // Return unchanged - } - - async deleteIntegration(integrationId) { - const existing = await this.realRepo.findIntegrationById(integrationId); - this.log.push({ - operation: 'DELETE', - model: 'Integration', - id: integrationId, - record: existing, - wouldAffect: existing ? 1 : 0 - }); - return { deleted: false, dryRun: true }; - } -} -``` - -**HTTP Client Interceptor** (for external API calls): -```javascript -// packages/admin-scripts/src/application/dry-run-http-interceptor.js - -function createDryRunAxiosInstance(operationLog) { - const sanitizeHeaders = (headers) => { - const safe = { ...headers }; - delete safe.Authorization; - delete safe['x-api-key']; - return safe; - }; - - const detectService = (baseURL) => { - if (baseURL?.includes('hubspot')) return 'HubSpot'; - if (baseURL?.includes('salesforce')) return 'Salesforce'; - if (baseURL?.includes('attio')) return 'Attio'; - if (baseURL?.includes('zoho')) return 'Zoho'; - return 'unknown'; - }; - - const mockRequest = async (config) => { - operationLog.push({ - operation: 'HTTP_REQUEST', - method: config.method?.toUpperCase() || 'GET', - url: config.url, - baseURL: config.baseURL, - data: config.data, - headers: sanitizeHeaders(config.headers || {}), - service: detectService(config.baseURL), - }); - - // Return mock response - return { - status: 200, - data: { _dryRun: true, _wouldHaveExecuted: config.url } - }; - }; - - return { - request: mockRequest, - get: (url, config = {}) => mockRequest({ ...config, method: 'GET', url }), - post: (url, data, config = {}) => mockRequest({ ...config, method: 'POST', url, data }), - put: (url, data, config = {}) => mockRequest({ ...config, method: 'PUT', url, data }), - patch: (url, data, config = {}) => mockRequest({ ...config, method: 'PATCH', url, data }), - delete: (url, config = {}) => mockRequest({ ...config, method: 'DELETE', url }), - }; -} - -module.exports = { createDryRunAxiosInstance }; -``` - -**Script Runner Integration**: -```javascript -// In script-runner.js - -async executeScript(scriptClass, params, options = {}) { - const { dryRun = false } = options; - const operationLog = []; - - // Create frigg commands with real or dry-run adapters - const frigg = dryRun - ? this.createDryRunFriggCommands(operationLog) - : this.createFriggCommands(); - - const script = new scriptClass({ executionId: this.executionId }); - const result = await script.execute(frigg, params); - - if (dryRun) { - return { - dryRun: true, - preview: { - operations: operationLog, - summary: this.summarizeOperations(operationLog), - scriptOutput: result - } - }; - } - - return result; -} - -createDryRunFriggCommands(operationLog) { - const realCommands = this.createFriggCommands(); - const dryRunHttpClient = createDryRunAxiosInstance(operationLog); - - return { - ...realCommands, - // Wrap repositories with dry-run versions - integrationRepository: new DryRunRepositoryWrapper( - realCommands.integrationRepository, operationLog - ), - // Override instantiate to inject dry-run HTTP client - instantiate: async (integrationId) => { - const instance = await realCommands.instantiate(integrationId); - // Replace HTTP clients in API modules - if (instance.primary?.api?._httpClient) { - instance.primary.api._httpClient = dryRunHttpClient; - } - if (instance.target?.api?._httpClient) { - instance.target.api._httpClient = dryRunHttpClient; - } - return instance; - } - }; -} - -summarizeOperations(log) { - const summary = { dbUpdates: 0, dbDeletes: 0, dbCreates: 0, httpRequests: 0, byModel: {}, byService: {} }; - - for (const op of log) { - if (op.operation === 'UPDATE') summary.dbUpdates += op.wouldAffect || 1; - if (op.operation === 'DELETE') summary.dbDeletes += op.wouldAffect || 1; - if (op.operation === 'CREATE') summary.dbCreates += op.wouldAffect || 1; - if (op.operation === 'HTTP_REQUEST') { - summary.httpRequests++; - summary.byService[op.service] = (summary.byService[op.service] || 0) + 1; - } - - if (op.model) { - summary.byModel[op.model] = summary.byModel[op.model] || []; - summary.byModel[op.model].push(op); - } - } - - return summary; -} -``` - -**API Usage**: -```javascript -// POST /admin/scripts/attio-healing/execute -{ - "params": { "integrationIds": ["abc"] }, - "dryRun": true -} - -// Response -{ - "dryRun": true, - "preview": { - "summary": { - "dbUpdates": 1, - "dbDeletes": 0, - "httpRequests": 2, - "byService": { "Attio": 2 } - }, - "operations": [ - { "operation": "HTTP_REQUEST", "method": "GET", "service": "Attio", - "url": "/v2/objects/people/abc" }, - { "operation": "UPDATE", "model": "Integration", "id": "abc", - "field": "config", "before": {...}, "after": {...} }, - { "operation": "HTTP_REQUEST", "method": "PATCH", "service": "Attio", - "url": "/v2/objects/people/abc", "data": {...} } - ], - "scriptOutput": { "fixed": 1, "failed": 0 } - } -} -``` - -**Rationale**: -- Script code unchanged - same script works in normal and dry-run mode -- Hexagonal pattern - adapters are swapped at infrastructure layer -- Full visibility - see before/after for DB ops, full request details for HTTP -- Safe testing - test healing scripts against production data without risk - -### ADR-11: Self-Queuing for Long-Running Scripts - -**Decision**: Scripts self-manage long-running work by chunking and re-queuing. - -**Pattern**: -```javascript -class LargeScaleHealingScript extends AdminScriptBase { - static Definition = { - name: 'large-scale-healing', - config: { - timeout: 840000, // 14 min (leave 1 min buffer) - } - }; - - async execute(frigg, params) { - const { cursor = null, batchSize = 100, processedTotal = 0 } = params; - - const integrations = await frigg.listIntegrations({ - cursor, - limit: batchSize, - filter: { status: 'ERROR' } - }); - - let processed = 0; - for (const int of integrations.data) { - await this.healIntegration(frigg, int); - processed++; - } - - const newTotal = processedTotal + processed; - - // More work? Re-queue with cursor - if (integrations.nextCursor) { - await frigg.queueScript(this.constructor.Definition.name, { - ...params, - cursor: integrations.nextCursor, - processedTotal: newTotal - }); - - return { - status: 'CONTINUING', - processedThisBatch: processed, - processedTotal: newTotal, - hasMore: true - }; - } - - return { - status: 'COMPLETED', - processedThisBatch: processed, - processedTotal: newTotal, - hasMore: false - }; - } -} -``` - -**AdminFriggCommands.queueScript()**: -```javascript -// In admin-frigg-commands.js - -const { QueuerUtil } = require('@friggframework/core/queues'); - -async queueScript(scriptName, params) { - await QueuerUtil.send( - { - scriptName, - trigger: 'QUEUE', // Self-queued continuation - params, - parentExecutionId: this.executionId, // Track lineage - }, - process.env.ADMIN_SCRIPT_QUEUE_URL - ); - - this.log('info', `Queued continuation for ${scriptName}`, { params }); -} - -// For batch operations (e.g., queuing multiple scripts) -async queueScriptBatch(entries) { - // entries = [{ scriptName, params }, ...] - const messages = entries.map(entry => ({ - scriptName: entry.scriptName, - trigger: 'QUEUE', - params: entry.params, - parentExecutionId: this.executionId, - })); - - await QueuerUtil.batchSend(messages, process.env.ADMIN_SCRIPT_QUEUE_URL); - this.log('info', `Queued ${entries.length} script continuations`); -} -``` - -**Reference**: `/home/user/frigg/packages/core/queues/queuer-util.js` - -**Benefits**: -- No Lambda timeout issues (each batch < 15 min) -- Progress tracking via execution records -- Fault tolerance (failed batch can retry independently) -- No Step Functions complexity - -### Phase 4: Enterprise Features - -**Scope**: -- Approval workflow (two-admin sign-off for production scripts) -- Rollback mechanism (pre-execution snapshots) - ---- - -## Example Scripts (Updated for Command Pattern) - -### Healing Script (Attio Config Fix) - -```javascript -class AttioConfigHealingScript { - static name = 'attio-config-healing'; - static version = '1.0.0'; - static description = 'Fix broken Attio integrations with corrupted config'; - - static inputSchema = { - type: 'object', - properties: { - dryRun: { type: 'boolean', default: true }, - integrationIds: { type: 'array', items: { type: 'string' } } - } - }; - - async execute(frigg, params) { - const { dryRun = true, integrationIds } = params; - - // Use command pattern to find integrations - const allIntegrations = await frigg.listIntegrations({}); - const brokenIntegrations = allIntegrations.filter(int => - int.config?.type === 'attio' && int.status === 'ERROR' - ); - - frigg.log('info', `Found ${brokenIntegrations.length} broken Attio integrations`); - - const results = { fixed: 0, failed: 0, skipped: 0 }; - - for (const int of brokenIntegrations) { - try { - if (dryRun) { - frigg.log('info', `[DRY RUN] Would fix integration ${int.id}`); - results.skipped++; - continue; - } - - const instance = await frigg.instantiate(int.id); - - // Rebuild config from API state - const apiConfig = await instance.primary.api.getConnectionConfig(); - - // Use command to update - await frigg.commands.updateIntegrationConfig({ - integrationId: int.id, - config: { - ...int.config, - ...apiConfig, - _healedAt: new Date().toISOString() - } - }); - - frigg.log('info', `Fixed integration ${int.id}`); - results.fixed++; - } catch (error) { - frigg.log('error', `Failed to fix ${int.id}`, { error: error.message }); - results.failed++; - } - } - - return results; - } -} - -module.exports = AttioConfigHealingScript; -``` - ---- - -## Testing Strategy - -### Unit Tests -- Repository implementations (mock Prisma) -- Command factory (mock repositories) -- ScriptFactory registration/lookup -- AdminFriggCommands methods - -### Integration Tests -- Full execution flow with test database -- Router endpoints with auth -- Multi-database compatibility (MongoDB, PostgreSQL) - -### E2E Tests -- Manual trigger via API -- Error handling and timeout behavior - ---- - -## Files to Create/Modify - -### New Files in `packages/core/` -``` -packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js -packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js -packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js -packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js -packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js -packages/core/admin-scripts/repositories/script-execution-repository-interface.js -packages/core/admin-scripts/repositories/script-execution-repository-factory.js -packages/core/admin-scripts/repositories/script-execution-repository-mongo.js -packages/core/admin-scripts/repositories/script-execution-repository-postgres.js -packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js -packages/core/admin-scripts/repositories/script-schedule-repository-interface.js # Phase 2 -packages/core/admin-scripts/repositories/script-schedule-repository-factory.js # Phase 2 -packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js # Phase 2 -packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js # Phase 2 -packages/core/admin-scripts/repositories/script-schedule-repository-documentdb.js # Phase 2 -packages/core/admin-scripts/index.js -packages/core/application/commands/admin-script-commands.js -``` - -### Modify in `packages/core/` -``` -packages/core/prisma-mongodb/schema.prisma (add AdminApiKey, ScriptExecution, ScriptSchedule) -packages/core/prisma-postgresql/schema.prisma (add AdminApiKey, ScriptExecution, ScriptSchedule) -packages/core/application/index.js (export createAdminScriptCommands) -``` - -### New Files in `packages/devtools/` -``` -packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.js -packages/devtools/infrastructure/domains/admin-scripts/admin-script-builder.test.js -``` - -### Modify in `packages/devtools/` -``` -packages/devtools/infrastructure/domains/shared/builder-orchestrator.js (register AdminScriptBuilder) -packages/devtools/infrastructure/domains/shared/types/app-definition.js (add adminScripts[], admin config) -``` - -### New Package `packages/admin-scripts/` -``` -packages/admin-scripts/package.json -packages/admin-scripts/index.js -packages/admin-scripts/src/application/script-factory.js -packages/admin-scripts/src/application/script-context.js -packages/admin-scripts/src/application/script-runner.js -packages/admin-scripts/src/application/admin-frigg-commands.js -packages/admin-scripts/src/infrastructure/admin-script-router.js -packages/admin-scripts/src/infrastructure/script-executor-handler.js -packages/admin-scripts/src/infrastructure/script-queue-worker.js -packages/admin-scripts/src/infrastructure/admin-auth-middleware.js -packages/admin-scripts/src/adapters/scheduler-adapter.js # Phase 2: Port interface -packages/admin-scripts/src/adapters/aws-scheduler-adapter.js # Phase 2: AWS implementation -packages/admin-scripts/src/adapters/local-scheduler-adapter.js # Phase 2: Dev/test -packages/admin-scripts/src/builtins/oauth-token-refresh.js -packages/admin-scripts/src/builtins/integration-health-check.js -packages/admin-scripts/src/builtins/index.js -``` - ---- - -## Next Steps - -1. [x] Review and approve plan -2. [x] Update plan for `next` branch architecture -3. [ ] Add Prisma schema for AdminApiKey, ScriptExecution -4. [ ] Implement repository interfaces -5. [ ] Implement repository factories (MongoDB, PostgreSQL, DocumentDB) -6. [ ] Implement `createAdminScriptCommands()` command factory -7. [ ] Build ScriptFactory + AdminFriggCommands -8. [ ] Create router + handler -9. [ ] Write tests -10. [ ] Implement built-in scripts -11. [ ] Documentation From 8ead86b167e810b7c6f15a0f06393fbcaf77db9e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 21:51:39 +0000 Subject: [PATCH 12/33] chore(admin-scripts): clean up TODOs and commented code - Remove commented-out domain model placeholders (not needed with repository pattern) - Remove commented-out factory function placeholder - Clarify EventBridge Scheduler integration comments as optional enhancement --- packages/admin-scripts/index.js | 37 ------------------- .../src/infrastructure/admin-script-router.js | 17 +++++---- 2 files changed, 9 insertions(+), 45 deletions(-) diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index 8ce179f6c..9589f8e20 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -5,11 +5,6 @@ * in hosted environments with VPC/KMS secured database connections. */ -// Domain Models (TODO: implement these) -// const { AdminApiKey } = require('./src/domain/admin-api-key'); -// const { ScriptExecution } = require('./src/domain/script-execution'); -// const { ScheduleSpec } = require('./src/domain/schedule-spec'); - // Application Services const { ScriptFactory, getScriptFactory, createScriptFactory } = require('./src/application/script-factory'); const { AdminScriptBase } = require('./src/application/admin-script-base'); @@ -38,38 +33,6 @@ const { detectSchedulerAdapterType, } = require('./src/adapters/scheduler-adapter-factory'); -// Factory function for creating the admin backend (TODO: implement when infrastructure is ready) -// function createAdminBackend(params) { -// const { -// scripts = [], -// integrationFactory, -// options = {} -// } = params; -// -// // Merge user scripts with builtins if enabled -// const allScripts = options.includeBuiltins !== false -// ? [...builtinScripts, ...scripts] -// : scripts; -// -// const scriptFactory = new ScriptFactory(allScripts); -// -// return { -// scriptFactory, -// integrationFactory, -// createRouter: (routerOptions = {}) => createAdminScriptRouter({ -// scriptFactory, -// integrationFactory, -// ...routerOptions -// }), -// createHandler: (handlerOptions = {}) => createScriptHandler({ -// scriptFactory, -// integrationFactory, -// ...handlerOptions -// }), -// createWorker: () => new ScriptQueueWorker(scriptFactory, integrationFactory) -// }; -// } - module.exports = { // Application layer AdminScriptBase, diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index 0a9aae31f..a1345502c 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -300,10 +300,12 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { timezone: timezone || 'UTC', }); - // 4. TODO (Phase 3): Create/update EventBridge schedule if enabled + // Optional: Provision EventBridge Scheduler rule for automatic triggering + // Currently schedules are stored in DB only - polling or manual triggers required + // To enable automatic execution, wire AWSSchedulerAdapter here: + // const adapter = createSchedulerAdapter(); // if (enabled && cronExpression) { - // const awsInfo = await provisionEventBridgeSchedule(scriptName, cronExpression, timezone); - // await commands.updateScheduleAwsRule(scriptName, awsInfo); + // await adapter.createOrUpdateSchedule(scriptName, cronExpression, timezone); // } res.json({ @@ -347,12 +349,11 @@ router.delete('/scripts/:scriptName/schedule', async (req, res) => { // 2. Delete schedule from database const result = await commands.deleteSchedule(scriptName); - // 3. TODO (Phase 3): Delete EventBridge schedule if exists - // if (result.deleted?.awsRuleArn) { - // await deleteEventBridgeSchedule(result.deleted.awsRuleName); - // } + // Optional: Delete EventBridge Scheduler rule if using automatic triggering + // const adapter = createSchedulerAdapter(); + // await adapter.deleteSchedule(scriptName); - // 4. Check if Definition default exists + // 3. Check if Definition default exists const scriptClass = factory.get(scriptName); const definitionSchedule = scriptClass.Definition?.schedule; From 8d2bbc383dddcdfdf54537c9b8497326d43a6d96 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Dec 2025 21:58:37 +0000 Subject: [PATCH 13/33] feat(admin-scripts): wire EventBridge Scheduler into schedule endpoints - Integrate createSchedulerAdapter into PUT /schedule endpoint - Provision EventBridge rule when schedule is enabled with cron expression - Delete EventBridge rule when schedule is disabled or deleted - Store AWS rule ARN/name in database for tracking - Handle scheduler errors gracefully (non-fatal, with warning in response) - Add 6 new tests for scheduler integration --- .../__tests__/admin-script-router.test.js | 171 ++++++++++++++++++ .../src/infrastructure/admin-script-router.js | 61 +++++-- 2 files changed, 221 insertions(+), 11 deletions(-) diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js index 93e5d1918..9f85a88c5 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -19,16 +19,19 @@ jest.mock('../../application/script-factory'); jest.mock('../../application/script-runner'); jest.mock('@friggframework/core/application/commands/admin-script-commands'); jest.mock('@friggframework/core/queues'); +jest.mock('../../adapters/scheduler-adapter-factory'); const { getScriptFactory } = require('../../application/script-factory'); const { createScriptRunner } = require('../../application/script-runner'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); const { QueuerUtil } = require('@friggframework/core/queues'); +const { createSchedulerAdapter } = require('../../adapters/scheduler-adapter-factory'); describe('Admin Script Router', () => { let mockFactory; let mockRunner; let mockCommands; + let mockSchedulerAdapter; class TestScript extends AdminScriptBase { static Definition = { @@ -61,9 +64,16 @@ describe('Admin Script Router', () => { findRecentExecutions: jest.fn(), }; + mockSchedulerAdapter = { + createSchedule: jest.fn(), + deleteSchedule: jest.fn(), + setScheduleEnabled: jest.fn(), + }; + getScriptFactory.mockReturnValue(mockFactory); createScriptRunner.mockReturnValue(mockRunner); createAdminScriptCommands.mockReturnValue(mockCommands); + createSchedulerAdapter.mockReturnValue(mockSchedulerAdapter); QueuerUtil.send = jest.fn().mockResolvedValue({}); // Default mock implementations @@ -447,6 +457,102 @@ describe('Admin Script Router', () => { expect(response.status).toBe(404); expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); }); + + it('should provision EventBridge schedule when enabled', async () => { + const newSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + lastTriggeredAt: null, + nextTriggerAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); + mockCommands.updateScheduleAwsRule = jest.fn().mockResolvedValue(newSchedule); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + ruleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + ruleName: 'frigg-script-test-script', + }); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + expect(mockCommands.updateScheduleAwsRule).toHaveBeenCalledWith('test-script', { + awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + awsRuleName: 'frigg-script-test-script', + }); + expect(response.body.schedule.awsRuleArn).toBe('arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script'); + }); + + it('should delete EventBridge schedule when disabling existing schedule', async () => { + const existingSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + awsRuleName: 'frigg-script-test-script', + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(existingSchedule); + mockCommands.updateScheduleAwsRule = jest.fn().mockResolvedValue(existingSchedule); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: false, + }); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + expect(mockCommands.updateScheduleAwsRule).toHaveBeenCalledWith('test-script', { + awsRuleArn: null, + awsRuleName: null, + }); + }); + + it('should handle scheduler errors gracefully (non-fatal)', async () => { + const newSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); + mockSchedulerAdapter.createSchedule.mockRejectedValue(new Error('AWS Scheduler API error')); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + }); + + // Request should succeed despite scheduler error + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedulerWarning).toBe('AWS Scheduler API error'); + }); }); describe('DELETE /admin/scripts/:scriptName/schedule', () => { @@ -526,5 +632,70 @@ describe('Admin Script Router', () => { expect(response.status).toBe(404); expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); }); + + it('should delete EventBridge schedule when AWS rule exists', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + awsRuleName: 'frigg-script-test-script', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should not call scheduler when no AWS rule exists', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + // No awsRuleArn + }, + }); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled(); + }); + + it('should handle scheduler delete errors gracefully (non-fatal)', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockRejectedValue(new Error('AWS Scheduler delete failed')); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + // Request should succeed despite scheduler error + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedulerWarning).toBe('AWS Scheduler delete failed'); + }); }); }); diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index a1345502c..23883940f 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -5,6 +5,7 @@ const { getScriptFactory } = require('../application/script-factory'); const { createScriptRunner } = require('../application/script-runner'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); const { QueuerUtil } = require('@friggframework/core/queues'); +const { createSchedulerAdapter } = require('../adapters/scheduler-adapter-factory'); const router = express.Router(); @@ -300,13 +301,38 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { timezone: timezone || 'UTC', }); - // Optional: Provision EventBridge Scheduler rule for automatic triggering - // Currently schedules are stored in DB only - polling or manual triggers required - // To enable automatic execution, wire AWSSchedulerAdapter here: - // const adapter = createSchedulerAdapter(); - // if (enabled && cronExpression) { - // await adapter.createOrUpdateSchedule(scriptName, cronExpression, timezone); - // } + // 4. Provision EventBridge Scheduler rule for automatic triggering + let awsScheduleInfo = null; + let schedulerError = null; + try { + const adapter = createSchedulerAdapter(); + if (enabled && cronExpression) { + // Create or update the EventBridge schedule + awsScheduleInfo = await adapter.createSchedule({ + scriptName, + cronExpression, + timezone: timezone || 'UTC', + }); + // Store AWS rule info in database + if (awsScheduleInfo?.ruleArn) { + await commands.updateScheduleAwsRule(scriptName, { + awsRuleArn: awsScheduleInfo.ruleArn, + awsRuleName: awsScheduleInfo.ruleName, + }); + } + } else if (!enabled && schedule.awsRuleArn) { + // Disable: delete the EventBridge schedule + await adapter.deleteSchedule(scriptName); + await commands.updateScheduleAwsRule(scriptName, { + awsRuleArn: null, + awsRuleName: null, + }); + } + } catch (error) { + // Log but don't fail - DB schedule is saved, AWS provisioning can be retried + console.error('EventBridge Scheduler error (non-fatal):', error.message); + schedulerError = error.message; + } res.json({ success: true, @@ -320,7 +346,10 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { nextTriggerAt: schedule.nextTriggerAt, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt, + awsRuleArn: awsScheduleInfo?.ruleArn || schedule.awsRuleArn, + awsRuleName: awsScheduleInfo?.ruleName || schedule.awsRuleName, }, + ...(schedulerError && { schedulerWarning: schedulerError }), }); } catch (error) { console.error('Error updating schedule:', error); @@ -349,11 +378,20 @@ router.delete('/scripts/:scriptName/schedule', async (req, res) => { // 2. Delete schedule from database const result = await commands.deleteSchedule(scriptName); - // Optional: Delete EventBridge Scheduler rule if using automatic triggering - // const adapter = createSchedulerAdapter(); - // await adapter.deleteSchedule(scriptName); + // 3. Delete EventBridge Scheduler rule if it exists + let schedulerError = null; + if (result.deleted?.awsRuleArn) { + try { + const adapter = createSchedulerAdapter(); + await adapter.deleteSchedule(scriptName); + } catch (error) { + // Log but don't fail - DB schedule is deleted, AWS cleanup can be retried + console.error('EventBridge Scheduler delete error (non-fatal):', error.message); + schedulerError = error.message; + } + } - // 3. Check if Definition default exists + // 4. Check if Definition default exists const scriptClass = factory.get(scriptName); const definitionSchedule = scriptClass.Definition?.schedule; @@ -372,6 +410,7 @@ router.delete('/scripts/:scriptName/schedule', async (req, res) => { timezone: definitionSchedule.timezone || 'UTC', } : { source: 'none', enabled: false }, + ...(schedulerError && { schedulerWarning: schedulerError }), }); } catch (error) { console.error('Error deleting schedule:', error); From 731ab5a85febf2d6616b331b16b69e476cdec9e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 00:21:30 +0000 Subject: [PATCH 14/33] refactor(admin-scripts): rename awsRule* to awsSchedule* for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align naming with AWS EventBridge Scheduler terminology: - awsRuleArn → awsScheduleArn - awsRuleName → awsScheduleName - updateScheduleAwsRule → updateScheduleAwsInfo - ruleArn → scheduleArn (adapter return values) - ruleName → scheduleName (adapter return values) This reflects that we use EventBridge Scheduler (the newer service), not EventBridge Rules (the older approach). --- .../__tests__/aws-scheduler-adapter.test.js | 4 +- .../__tests__/local-scheduler-adapter.test.js | 8 ++-- .../__tests__/scheduler-adapter.test.js | 2 +- .../src/adapters/aws-scheduler-adapter.js | 4 +- .../src/adapters/local-scheduler-adapter.js | 4 +- .../src/adapters/scheduler-adapter.js | 2 +- .../__tests__/admin-script-router.test.js | 38 +++++++++---------- .../src/infrastructure/admin-script-router.js | 30 +++++++-------- ...ript-schedule-repository-interface.test.js | 22 +++++------ .../script-schedule-repository-interface.js | 18 ++++----- .../script-schedule-repository-mongo.js | 24 ++++++------ .../script-schedule-repository-postgres.js | 24 ++++++------ .../commands/admin-script-commands.js | 16 ++++---- packages/core/prisma-mongodb/schema.prisma | 6 +-- packages/core/prisma-postgresql/schema.prisma | 6 +-- 15 files changed, 104 insertions(+), 104 deletions(-) diff --git a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js index 2fb518682..9ccd461ff 100644 --- a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js @@ -104,8 +104,8 @@ describe('AWSSchedulerAdapter', () => { }); expect(result).toEqual({ - ruleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - ruleName: 'frigg-script-test-script', + scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + scheduleName: 'frigg-script-test-script', }); expect(mockSend).toHaveBeenCalledTimes(1); diff --git a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js index 3719c0731..732bd48ef 100644 --- a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js @@ -32,8 +32,8 @@ describe('LocalSchedulerAdapter', () => { const result = await adapter.createSchedule(config); expect(result).toEqual({ - ruleName: 'test-script', - ruleArn: 'local:schedule:test-script', + scheduleName: 'test-script', + scheduleArn: 'local:schedule:test-script', }); expect(adapter.size).toBe(1); }); @@ -49,8 +49,8 @@ describe('LocalSchedulerAdapter', () => { const result = await adapter.createSchedule(config); expect(result).toEqual({ - ruleName: 'test-script', - ruleArn: 'local:schedule:test-script', + scheduleName: 'test-script', + scheduleArn: 'local:schedule:test-script', }); const schedule = await adapter.getSchedule('test-script'); diff --git a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js index eff2756d5..a93c56669 100644 --- a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js @@ -53,7 +53,7 @@ describe('SchedulerAdapter', () => { } async createSchedule(config) { - return { ruleName: config.scriptName }; + return { scheduleName: config.scriptName }; } async deleteSchedule(scriptName) { diff --git a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js index 7f69ed0d6..2717b21e4 100644 --- a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js @@ -73,8 +73,8 @@ class AWSSchedulerAdapter extends SchedulerAdapter { const response = await client.send(command); return { - ruleArn: response.ScheduleArn, - ruleName: scheduleName, + scheduleArn: response.ScheduleArn, + scheduleName: scheduleName, }; } diff --git a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js index c5c8dc46a..cc9640ee8 100644 --- a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js @@ -33,8 +33,8 @@ class LocalSchedulerAdapter extends SchedulerAdapter { }); return { - ruleName: scriptName, - ruleArn: `local:schedule:${scriptName}`, + scheduleName: scriptName, + scheduleArn: `local:schedule:${scriptName}`, }; } diff --git a/packages/admin-scripts/src/adapters/scheduler-adapter.js b/packages/admin-scripts/src/adapters/scheduler-adapter.js index 04f7db126..4a2ad3ae6 100644 --- a/packages/admin-scripts/src/adapters/scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/scheduler-adapter.js @@ -18,7 +18,7 @@ class SchedulerAdapter { * @param {string} config.cronExpression - Cron expression * @param {string} [config.timezone] - Timezone (default UTC) * @param {Object} [config.input] - Optional input params - * @returns {Promise} Created schedule { ruleArn, ruleName } + * @returns {Promise} Created schedule { scheduleArn, scheduleName } */ async createSchedule(config) { throw new Error('SchedulerAdapter.createSchedule() must be implemented'); diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js index 9f85a88c5..5f802475c 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -294,8 +294,8 @@ describe('Admin Script Router', () => { timezone: 'America/New_York', lastTriggeredAt: new Date('2025-01-01T09:00:00Z'), nextTriggerAt: new Date('2025-01-02T09:00:00Z'), - awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', - awsRuleName: 'test-script-schedule', + awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', + awsScheduleName: 'test-script-schedule', createdAt: new Date('2025-01-01T00:00:00Z'), updatedAt: new Date('2025-01-01T00:00:00Z'), }; @@ -471,10 +471,10 @@ describe('Admin Script Router', () => { }; mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); - mockCommands.updateScheduleAwsRule = jest.fn().mockResolvedValue(newSchedule); + mockCommands.updateScheduleAwsInfo = jest.fn().mockResolvedValue(newSchedule); mockSchedulerAdapter.createSchedule.mockResolvedValue({ - ruleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - ruleName: 'frigg-script-test-script', + scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + scheduleName: 'frigg-script-test-script', }); const response = await request(app) @@ -491,11 +491,11 @@ describe('Admin Script Router', () => { cronExpression: '0 12 * * *', timezone: 'America/Los_Angeles', }); - expect(mockCommands.updateScheduleAwsRule).toHaveBeenCalledWith('test-script', { - awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - awsRuleName: 'frigg-script-test-script', + expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalledWith('test-script', { + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + awsScheduleName: 'frigg-script-test-script', }); - expect(response.body.schedule.awsRuleArn).toBe('arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script'); + expect(response.body.schedule.awsScheduleArn).toBe('arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script'); }); it('should delete EventBridge schedule when disabling existing schedule', async () => { @@ -504,14 +504,14 @@ describe('Admin Script Router', () => { enabled: false, cronExpression: null, timezone: 'UTC', - awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - awsRuleName: 'frigg-script-test-script', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + awsScheduleName: 'frigg-script-test-script', createdAt: new Date(), updatedAt: new Date(), }; mockCommands.upsertSchedule = jest.fn().mockResolvedValue(existingSchedule); - mockCommands.updateScheduleAwsRule = jest.fn().mockResolvedValue(existingSchedule); + mockCommands.updateScheduleAwsInfo = jest.fn().mockResolvedValue(existingSchedule); mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); const response = await request(app) @@ -522,9 +522,9 @@ describe('Admin Script Router', () => { expect(response.status).toBe(200); expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); - expect(mockCommands.updateScheduleAwsRule).toHaveBeenCalledWith('test-script', { - awsRuleArn: null, - awsRuleName: null, + expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalledWith('test-script', { + awsScheduleArn: null, + awsScheduleName: null, }); }); @@ -641,8 +641,8 @@ describe('Admin Script Router', () => { scriptName: 'test-script', enabled: true, cronExpression: '0 12 * * *', - awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - awsRuleName: 'frigg-script-test-script', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + awsScheduleName: 'frigg-script-test-script', }, }); mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); @@ -663,7 +663,7 @@ describe('Admin Script Router', () => { scriptName: 'test-script', enabled: true, cronExpression: '0 12 * * *', - // No awsRuleArn + // No awsScheduleArn }, }); @@ -683,7 +683,7 @@ describe('Admin Script Router', () => { scriptName: 'test-script', enabled: true, cronExpression: '0 12 * * *', - awsRuleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', }, }); mockSchedulerAdapter.deleteSchedule.mockRejectedValue(new Error('AWS Scheduler delete failed')); diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index 23883940f..f61ff76f6 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -229,8 +229,8 @@ router.get('/scripts/:scriptName/schedule', async (req, res) => { timezone: dbSchedule.timezone, lastTriggeredAt: dbSchedule.lastTriggeredAt, nextTriggerAt: dbSchedule.nextTriggerAt, - awsRuleArn: dbSchedule.awsRuleArn, - awsRuleName: dbSchedule.awsRuleName, + awsScheduleArn: dbSchedule.awsScheduleArn, + awsScheduleName: dbSchedule.awsScheduleName, createdAt: dbSchedule.createdAt, updatedAt: dbSchedule.updatedAt, }); @@ -313,19 +313,19 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { cronExpression, timezone: timezone || 'UTC', }); - // Store AWS rule info in database - if (awsScheduleInfo?.ruleArn) { - await commands.updateScheduleAwsRule(scriptName, { - awsRuleArn: awsScheduleInfo.ruleArn, - awsRuleName: awsScheduleInfo.ruleName, + // Store AWS schedule info in database + if (awsScheduleInfo?.scheduleArn) { + await commands.updateScheduleAwsInfo(scriptName, { + awsScheduleArn: awsScheduleInfo.scheduleArn, + awsScheduleName: awsScheduleInfo.scheduleName, }); } - } else if (!enabled && schedule.awsRuleArn) { + } else if (!enabled && schedule.awsScheduleArn) { // Disable: delete the EventBridge schedule await adapter.deleteSchedule(scriptName); - await commands.updateScheduleAwsRule(scriptName, { - awsRuleArn: null, - awsRuleName: null, + await commands.updateScheduleAwsInfo(scriptName, { + awsScheduleArn: null, + awsScheduleName: null, }); } } catch (error) { @@ -346,8 +346,8 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { nextTriggerAt: schedule.nextTriggerAt, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt, - awsRuleArn: awsScheduleInfo?.ruleArn || schedule.awsRuleArn, - awsRuleName: awsScheduleInfo?.ruleName || schedule.awsRuleName, + awsScheduleArn: awsScheduleInfo?.scheduleArn || schedule.awsScheduleArn, + awsScheduleName: awsScheduleInfo?.scheduleName || schedule.awsScheduleName, }, ...(schedulerError && { schedulerWarning: schedulerError }), }); @@ -378,9 +378,9 @@ router.delete('/scripts/:scriptName/schedule', async (req, res) => { // 2. Delete schedule from database const result = await commands.deleteSchedule(scriptName); - // 3. Delete EventBridge Scheduler rule if it exists + // 3. Delete EventBridge Scheduler if it exists let schedulerError = null; - if (result.deleted?.awsRuleArn) { + if (result.deleted?.awsScheduleArn) { try { const adapter = createSchedulerAdapter(); await adapter.deleteSchedule(scriptName); diff --git a/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js index 5e73f5c11..9268fd556 100644 --- a/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js +++ b/packages/core/admin-scripts/repositories/__tests__/script-schedule-repository-interface.test.js @@ -31,13 +31,13 @@ describe('ScriptScheduleRepositoryInterface', () => { ).rejects.toThrow('Method deleteSchedule must be implemented by subclass'); }); - it('should throw error when updateScheduleAwsRule is not implemented', async () => { + it('should throw error when updateScheduleAwsInfo is not implemented', async () => { await expect( - repository.updateScheduleAwsRule('test-script', { - awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test-rule', - awsRuleName: 'test-rule', + repository.updateScheduleAwsInfo('test-script', { + awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test-rule', + awsScheduleName: 'test-rule', }) - ).rejects.toThrow('Method updateScheduleAwsRule must be implemented by subclass'); + ).rejects.toThrow('Method updateScheduleAwsInfo must be implemented by subclass'); }); it('should throw error when updateScheduleLastTriggered is not implemented', async () => { @@ -72,8 +72,8 @@ describe('ScriptScheduleRepositoryInterface', () => { enabled: true, cronExpression: '0 0 * * *', timezone: 'America/New_York', - awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', - awsRuleName: 'test-rule', + awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', + awsScheduleName: 'test-rule', }; await expect(repository.upsertSchedule(params)).rejects.toThrow(); @@ -85,11 +85,11 @@ describe('ScriptScheduleRepositoryInterface', () => { ).rejects.toThrow(); }); - it('should accept scriptName and awsInfo in updateScheduleAwsRule', async () => { + it('should accept scriptName and awsInfo in updateScheduleAwsInfo', async () => { await expect( - repository.updateScheduleAwsRule('test-script', { - awsRuleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', - awsRuleName: 'test-rule', + repository.updateScheduleAwsInfo('test-script', { + awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', + awsScheduleName: 'test-rule', }) ).rejects.toThrow(); }); diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js b/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js index 5da7a946e..1d8edad1d 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js @@ -34,12 +34,12 @@ class ScriptScheduleRepositoryInterface { * @param {boolean} params.enabled - Whether schedule is enabled * @param {string} params.cronExpression - Cron expression * @param {string} [params.timezone] - Timezone (default 'UTC') - * @param {string} [params.awsRuleArn] - AWS EventBridge rule ARN - * @param {string} [params.awsRuleName] - AWS EventBridge rule name + * @param {string} [params.awsScheduleArn] - AWS EventBridge Scheduler ARN + * @param {string} [params.awsScheduleName] - AWS EventBridge Scheduler name * @returns {Promise} Created or updated schedule record * @abstract */ - async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsRuleArn, awsRuleName }) { + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsScheduleArn, awsScheduleName }) { throw new Error('Method upsertSchedule must be implemented by subclass'); } @@ -55,17 +55,17 @@ class ScriptScheduleRepositoryInterface { } /** - * Update AWS EventBridge rule information + * Update AWS EventBridge Scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS rule information - * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN - * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @param {Object} awsInfo - AWS schedule information + * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN + * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name * @returns {Promise} Updated schedule record * @abstract */ - async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { - throw new Error('Method updateScheduleAwsRule must be implemented by subclass'); + async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { + throw new Error('Method updateScheduleAwsInfo must be implemented by subclass'); } /** diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js b/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js index 05ff62b1b..76e4f6445 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js @@ -40,11 +40,11 @@ class ScriptScheduleRepositoryMongo extends ScriptScheduleRepositoryInterface { * @param {boolean} params.enabled - Whether schedule is enabled * @param {string} params.cronExpression - Cron expression * @param {string} [params.timezone] - Timezone (default 'UTC') - * @param {string} [params.awsRuleArn] - AWS EventBridge rule ARN - * @param {string} [params.awsRuleName] - AWS EventBridge rule name + * @param {string} [params.awsScheduleArn] - AWS EventBridge Scheduler ARN + * @param {string} [params.awsScheduleName] - AWS EventBridge Scheduler name * @returns {Promise} Created or updated schedule record */ - async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsRuleArn, awsRuleName }) { + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsScheduleArn, awsScheduleName }) { const data = { enabled, cronExpression, @@ -52,8 +52,8 @@ class ScriptScheduleRepositoryMongo extends ScriptScheduleRepositoryInterface { }; // Only set AWS fields if provided - if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; - if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; + if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; const schedule = await this.prisma.scriptSchedule.upsert({ where: { scriptName }, @@ -97,18 +97,18 @@ class ScriptScheduleRepositoryMongo extends ScriptScheduleRepositoryInterface { } /** - * Update AWS EventBridge rule information + * Update AWS EventBridge Scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS rule information - * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN - * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @param {Object} awsInfo - AWS schedule information + * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN + * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name * @returns {Promise} Updated schedule record */ - async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { + async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { const data = {}; - if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; - if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; + if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; const schedule = await this.prisma.scriptSchedule.update({ where: { scriptName }, diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js index 481cea2f1..071aacda3 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js @@ -71,11 +71,11 @@ class ScriptScheduleRepositoryPostgres extends ScriptScheduleRepositoryInterface * @param {boolean} params.enabled - Whether schedule is enabled * @param {string} params.cronExpression - Cron expression * @param {string} [params.timezone] - Timezone (default 'UTC') - * @param {string} [params.awsRuleArn] - AWS EventBridge rule ARN - * @param {string} [params.awsRuleName] - AWS EventBridge rule name + * @param {string} [params.awsScheduleArn] - AWS EventBridge Scheduler ARN + * @param {string} [params.awsScheduleName] - AWS EventBridge Scheduler name * @returns {Promise} Created or updated schedule record with string ID */ - async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsRuleArn, awsRuleName }) { + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsScheduleArn, awsScheduleName }) { const data = { enabled, cronExpression, @@ -83,8 +83,8 @@ class ScriptScheduleRepositoryPostgres extends ScriptScheduleRepositoryInterface }; // Only set AWS fields if provided - if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; - if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; + if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; const schedule = await this.prisma.scriptSchedule.upsert({ where: { scriptName }, @@ -128,18 +128,18 @@ class ScriptScheduleRepositoryPostgres extends ScriptScheduleRepositoryInterface } /** - * Update AWS EventBridge rule information + * Update AWS EventBridge Scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS rule information - * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN - * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @param {Object} awsInfo - AWS schedule information + * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN + * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name * @returns {Promise} Updated schedule record with string ID */ - async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { + async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { const data = {}; - if (awsRuleArn !== undefined) data.awsRuleArn = awsRuleArn; - if (awsRuleName !== undefined) data.awsRuleName = awsRuleName; + if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; + if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; const schedule = await this.prisma.scriptSchedule.update({ where: { scriptName }, diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js index fc9024bd7..893d0be06 100644 --- a/packages/core/application/commands/admin-script-commands.js +++ b/packages/core/application/commands/admin-script-commands.js @@ -396,19 +396,19 @@ function createAdminScriptCommands() { }, /** - * Update AWS EventBridge rule information + * Update AWS EventBridge Scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS rule information - * @param {string} [awsInfo.awsRuleArn] - AWS EventBridge rule ARN - * @param {string} [awsInfo.awsRuleName] - AWS EventBridge rule name + * @param {Object} awsInfo - AWS schedule information + * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN + * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name * @returns {Promise} Updated schedule */ - async updateScheduleAwsRule(scriptName, { awsRuleArn, awsRuleName }) { + async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { try { - const schedule = await scheduleRepository.updateScheduleAwsRule(scriptName, { - awsRuleArn, - awsRuleName, + const schedule = await scheduleRepository.updateScheduleAwsInfo(scriptName, { + awsScheduleArn, + awsScheduleName, }); return schedule; } catch (error) { diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 91dc02cce..52fe5d60a 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -439,9 +439,9 @@ model ScriptSchedule { lastTriggeredAt DateTime? nextTriggerAt DateTime? - // AWS EventBridge Rule (if provisioned) - awsRuleArn String? - awsRuleName String? + // AWS EventBridge Schedule (if provisioned) + awsScheduleArn String? + awsScheduleName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index caf4853a6..c2afd8e78 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -420,9 +420,9 @@ model ScriptSchedule { lastTriggeredAt DateTime? nextTriggerAt DateTime? - // AWS EventBridge Rule (if provisioned) - awsRuleArn String? - awsRuleName String? + // AWS EventBridge Schedule (if provisioned) + awsScheduleArn String? + awsScheduleName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 5d4125b485a1c0885344e60d584423b5782ad622 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 00:24:14 +0000 Subject: [PATCH 15/33] docs(adr): add ADR-005 for Admin Script Runner service Document the design decisions for the Admin Script Runner: - Entry point via appDefinition.adminScripts - Script base class pattern following IntegrationBase - Infrastructure components (builder, repositories, handlers) - Execution modes (sync/async) - Hybrid scheduling with EventBridge Scheduler - Dry-run mode for safe testing - Security model with admin API keys Also update README to include ADR-004 and ADR-005. --- .../005-admin-script-runner.md | 184 ++++++++++++++++++ docs/architecture-decisions/README.md | 2 + 2 files changed, 186 insertions(+) create mode 100644 docs/architecture-decisions/005-admin-script-runner.md diff --git a/docs/architecture-decisions/005-admin-script-runner.md b/docs/architecture-decisions/005-admin-script-runner.md new file mode 100644 index 000000000..5c88041ee --- /dev/null +++ b/docs/architecture-decisions/005-admin-script-runner.md @@ -0,0 +1,184 @@ +# Architecture Decision Record: Admin Script Runner Service + +## Status +Accepted (Implemented) + +## Context + +Frigg adopters need to execute administrative scripts in hosted environments with access to VPC/KMS-secured database connections. Common use cases include: + +1. **Healing Scripts** - Fix broken integrations (e.g., Attio config corruption) +2. **Recurring Maintenance** - Webhook refreshers (e.g., Zoho channel expiry) +3. **Built-in Utilities** - OAuth token refresh, integration health checks + +This is a high-risk, high-value feature requiring careful security controls. The implementation must align with the `next` branch architecture: + +| Aspect | Pattern Used | +|--------|--------------| +| ORM | Prisma | +| Data Access | Command Pattern (`createAdminScriptCommands()`) | +| DB Support | MongoDB, PostgreSQL, DocumentDB | +| Repository | Interface + Factory Pattern | +| Encryption | Field-level KMS/AES encryption | +| Scheduling | AWS EventBridge Scheduler | + +## Decision + +### Entry Point: appDefinition Extension + +Scripts are registered via `adminScripts` array in the app definition: + +```javascript +const Definition = { + name: 'my-app', + integrations: [HubSpotIntegration, SalesforceIntegration], + + // Admin scripts (optional) + adminScripts: [ + AttioHealingScript, + ZohoWebhookRefreshScript, + ], + + admin: { + includeBuiltinScripts: true, + }, +}; +``` + +### Script Base Class Pattern + +Following `IntegrationBase` conventions: + +```javascript +class MyScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'What this script does', + config: { timeout: 300000 }, + schedule: { enabled: true, cronExpression: 'cron(0 12 * * ? *)' }, + }; + + async execute(frigg, params) { + // frigg provides: log(), getIntegrations(), getCredentials(), etc. + return { success: true }; + } +} +``` + +### Infrastructure Components + +1. **AdminScriptBuilder** - Generates serverless.yml resources: + - SQS queue for async execution + - Lambda functions (router + queue worker) + - EventBridge Scheduler resources + +2. **Repository Layer** (Phase 1): + - `AdminApiKeyRepository` - API key management + - `ScriptExecutionRepository` - Execution history + - `ScriptScheduleRepository` - Schedule overrides (Phase 2) + +3. **Application Layer**: + - `ScriptFactory` - Script registration/instantiation + - `ScriptRunner` - Execution orchestration + - `AdminFriggCommands` - Helper API for scripts + +4. **Infrastructure Layer**: + - `admin-script-router.js` - HTTP endpoints + - `script-executor-handler.js` - SQS queue worker + - `admin-auth-middleware.js` - API key authentication + +### Execution Modes + +- **Sync** (`mode: 'sync'`): Immediate execution, response contains result +- **Async** (`mode: 'async'`): Queued to SQS, returns execution ID for polling + +### Scheduling Architecture (Phase 2) + +Hybrid scheduling with database override capability: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Schedule Resolution (Priority Order) │ +├─────────────────────────────────────────────────────────┤ +│ 1. Database ScriptSchedule (runtime override) │ +│ 2. Script Definition schedule (code default) │ +│ 3. No schedule (manual execution only) │ +└─────────────────────────────────────────────────────────┘ +``` + +AWS EventBridge Scheduler (not EventBridge Rules) provides: +- Native timezone support +- Scale to millions of schedules +- Schedule groups for organization +- Flexible time windows + +### Dry-Run Mode (Phase 3) + +Scripts can be executed in dry-run mode for testing: + +```javascript +POST /admin/scripts/:name/execute +{ "params": {...}, "mode": "sync", "dryRun": true } +``` + +Dry-run wraps repositories to intercept writes and mocks HTTP calls. + +### Security Model + +- **Admin API Keys**: Separate from OAuth credentials +- **VPC Deployment**: Lambda functions in private subnets +- **Encryption**: Sensitive fields encrypted via Prisma extension +- **Audit Logging**: All executions tracked with API key info + +### API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/admin/scripts` | List registered scripts | +| GET | `/admin/scripts/:name` | Get script details | +| POST | `/admin/scripts/:name/execute` | Execute script | +| GET | `/admin/executions` | List recent executions | +| GET | `/admin/executions/:id` | Get execution details | +| GET | `/admin/scripts/:name/schedule` | Get effective schedule | +| PUT | `/admin/scripts/:name/schedule` | Set schedule override | +| DELETE | `/admin/scripts/:name/schedule` | Remove override | + +### Built-in Scripts + +1. **oauth-token-refresh** - Refresh OAuth tokens nearing expiration +2. **integration-health-check** - Verify integration connectivity + +## Consequences + +### Positive +- Enables runtime maintenance without redeployment +- Built-in scripts reduce boilerplate for common operations +- Hybrid scheduling allows runtime adjustments +- Dry-run mode enables safe testing +- Follows established Frigg patterns (Command, Repository, Factory) + +### Negative +- Additional infrastructure (SQS queue, Lambda functions) +- API key management complexity +- EventBridge Scheduler has regional limits +- Dry-run mode can't capture all side effects + +### Risks Mitigated +- **Privilege Escalation**: Admin API keys are separate from user OAuth +- **Resource Exhaustion**: Timeout limits, async execution for long scripts +- **Data Corruption**: Dry-run mode for testing, execution logging + +## Implementation Phases + +1. **Phase 1 (MVP)**: Core execution, repositories, built-in scripts ✅ +2. **Phase 2 (Scheduling)**: ScriptSchedule model, EventBridge integration ✅ +3. **Phase 3 (Dry-Run)**: Repository wrapper, HTTP interceptor ✅ +4. **Phase 4 (Future)**: Management UI, advanced observability + +## Related + +- [Integration Base Pattern](/packages/core/integrations/integration-base.js) +- [Command Pattern](/packages/core/application/commands/) +- [Repository Factory Pattern](/packages/core/database/) +- [AWS EventBridge Scheduler](https://docs.aws.amazon.com/scheduler/latest/UserGuide/what-is-scheduler.html) diff --git a/docs/architecture-decisions/README.md b/docs/architecture-decisions/README.md index a570c9d43..7504a2b94 100644 --- a/docs/architecture-decisions/README.md +++ b/docs/architecture-decisions/README.md @@ -20,6 +20,8 @@ An ADR documents a significant architectural decision made in the project, inclu | [001](./001-use-vite-for-management-ui.md) | Use Vite + React for Management UI | Accepted | 2025-01-25 | | [002](./002-no-database-for-local-dev.md) | No Database for Local Development Tools | Accepted | 2025-01-25 | | [003](./003-runtime-state-only.md) | Runtime State Only for Management GUI | Accepted | 2025-01-25 | +| [004](./004-migration-tool-design.md) | Migration Tool Design | Proposed | 2025-01-25 | +| [005](./005-admin-script-runner.md) | Admin Script Runner Service | Accepted | 2025-12-10 | ## ADR Template From 010b7bde86a651b95a4918df7a130a262d8f0983 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 01:15:51 +0000 Subject: [PATCH 16/33] fix(admin-scripts): add missing express and serverless-http dependencies The admin-script-router requires express and serverless-http but they were not declared in package.json, causing the release to fail. --- packages/admin-scripts/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json index 9f389b54b..e20c83d34 100644 --- a/packages/admin-scripts/package.json +++ b/packages/admin-scripts/package.json @@ -7,8 +7,10 @@ "@aws-sdk/client-scheduler": "^3.588.0", "@friggframework/core": "^2.0.0-next.0", "bcryptjs": "^2.4.3", + "express": "^4.18.2", "lodash": "4.17.21", "mongoose": "6.11.6", + "serverless-http": "^3.2.0", "uuid": "^9.0.1" }, "devDependencies": { From 2656248d56d0a2663fe08a94116f3ff03cee6bfb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 01:20:55 +0000 Subject: [PATCH 17/33] chore: update package-lock.json for admin-scripts dependencies --- package-lock.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index 2b099ed1b..b0a1ee121 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39751,8 +39751,10 @@ "@aws-sdk/client-scheduler": "^3.588.0", "@friggframework/core": "^2.0.0-next.0", "bcryptjs": "^2.4.3", + "express": "^4.18.2", "lodash": "4.17.21", "mongoose": "6.11.6", + "serverless-http": "^3.2.0", "uuid": "^9.0.1" }, "devDependencies": { @@ -39767,6 +39769,15 @@ "supertest": "^7.1.4" } }, + "packages/admin-scripts/node_modules/serverless-http": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/serverless-http/-/serverless-http-3.2.0.tgz", + "integrity": "sha512-QvSyZXljRLIGqwcJ4xsKJXwkZnAVkse1OajepxfjkBXV0BMvRS5R546Z4kCBI8IygDzkQY0foNPC/rnipaE9pQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "packages/admin-scripts/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", From efa3a78736c0983b5c32f20ab20a85ab7af9019d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 01:27:12 +0000 Subject: [PATCH 18/33] fix(prisma): remove duplicate keyHash index from AdminApiKey The @unique constraint on keyHash already creates an index, so the explicit @@index([keyHash]) was causing a "Index already exists" error. --- packages/core/prisma-mongodb/schema.prisma | 1 - packages/core/prisma-postgresql/schema.prisma | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 52fe5d60a..252fa96b3 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -396,7 +396,6 @@ model AdminApiKey { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@index([keyHash]) @@index([isActive]) @@map("AdminApiKey") } diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index c2afd8e78..73f1d3a03 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -379,7 +379,6 @@ model AdminApiKey { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@index([keyHash]) @@index([isActive]) } From 20ae7de1adbf2582bf6b02ae31c755ac48706ef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 02:30:31 +0000 Subject: [PATCH 19/33] fix(admin-scripts): use Number.parseInt and Number.isNaN per SonarCloud Replace global parseInt/isNaN with Number.parseInt/Number.isNaN to follow JavaScript best practices and pass SonarCloud analysis. --- .../admin-scripts/src/infrastructure/admin-script-router.js | 2 +- .../repositories/admin-api-key-repository-postgres.js | 4 ++-- .../repositories/script-execution-repository-postgres.js | 4 ++-- .../repositories/script-schedule-repository-postgres.js | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index f61ff76f6..018539907 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -183,7 +183,7 @@ router.get('/executions', async (req, res) => { const executions = await commands.findRecentExecutions({ scriptName, status, - limit: parseInt(limit, 10), + limit: Number.parseInt(limit, 10), }); res.json({ executions }); diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js index a86f72b64..2062db415 100644 --- a/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js +++ b/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js @@ -27,8 +27,8 @@ class AdminApiKeyRepositoryPostgres extends AdminApiKeyRepositoryInterface { */ _convertId(id) { if (id === null || id === undefined) return id; - const parsed = parseInt(id, 10); - if (isNaN(parsed)) { + const parsed = Number.parseInt(id, 10); + if (Number.isNaN(parsed)) { throw new Error(`Invalid ID: ${id} cannot be converted to integer`); } return parsed; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js b/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js index 8e88e1328..fcb557ac5 100644 --- a/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js +++ b/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js @@ -28,8 +28,8 @@ class ScriptExecutionRepositoryPostgres extends ScriptExecutionRepositoryInterfa */ _convertId(id) { if (id === null || id === undefined) return id; - const parsed = parseInt(id, 10); - if (isNaN(parsed)) { + const parsed = Number.parseInt(id, 10); + if (Number.isNaN(parsed)) { throw new Error(`Invalid ID: ${id} cannot be converted to integer`); } return parsed; diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js index 071aacda3..8dca9c2ea 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js @@ -28,8 +28,8 @@ class ScriptScheduleRepositoryPostgres extends ScriptScheduleRepositoryInterface */ _convertId(id) { if (id === null || id === undefined) return id; - const parsed = parseInt(id, 10); - if (isNaN(parsed)) { + const parsed = Number.parseInt(id, 10); + if (Number.isNaN(parsed)) { throw new Error(`Invalid ID: ${id} cannot be converted to integer`); } return parsed; From 4a558c96d6b45181aabd608b4c8d3d1008e40a26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 05:31:43 +0000 Subject: [PATCH 20/33] refactor(admin-scripts): reduce cognitive complexity for SonarCloud - Extract ScheduleManagementUseCase from admin-script-router - Encapsulates schedule business logic (get/upsert/delete) - Handles EventBridge sync with graceful error handling - 14 unit tests with full coverage - Refactor oauth-token-refresh.js - Extract _checkRefreshPrerequisites() for token validation - Extract _performTokenRefresh() for actual refresh logic - Extract _createResult() helper for consistent response format - Refactor integration-health-check.js - Extract _createCheckResult() for initial result structure - Extract _runChecks() to orchestrate all checks - Extract _addCheckResult() to track issues - Extract _determineOverallStatus() for status logic - Extract _handleCheckError() for error handling All 297 tests passing. --- .../schedule-management-use-case.test.js | 276 ++++++++++++++++++ .../schedule-management-use-case.js | 230 +++++++++++++++ .../src/builtins/integration-health-check.js | 89 ++++-- .../src/builtins/oauth-token-refresh.js | 102 ++++--- .../src/infrastructure/admin-script-router.js | 220 ++++---------- 5 files changed, 669 insertions(+), 248 deletions(-) create mode 100644 packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js create mode 100644 packages/admin-scripts/src/application/schedule-management-use-case.js diff --git a/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js b/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js new file mode 100644 index 000000000..0dc88cc33 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js @@ -0,0 +1,276 @@ +const { ScheduleManagementUseCase } = require('../schedule-management-use-case'); + +describe('ScheduleManagementUseCase', () => { + let useCase; + let mockCommands; + let mockSchedulerAdapter; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + getScheduleByScriptName: jest.fn(), + upsertSchedule: jest.fn(), + updateScheduleAwsInfo: jest.fn(), + deleteSchedule: jest.fn(), + }; + + mockSchedulerAdapter = { + createSchedule: jest.fn(), + deleteSchedule: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new ScheduleManagementUseCase({ + commands: mockCommands, + schedulerAdapter: mockSchedulerAdapter, + scriptFactory: mockScriptFactory, + }); + }); + + describe('getEffectiveSchedule', () => { + it('should return database schedule when override exists', async () => { + const dbSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 9 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule); + + const result = await useCase.getEffectiveSchedule('test-script'); + + expect(result.source).toBe('database'); + expect(result.schedule).toEqual(dbSchedule); + }); + + it('should return definition schedule when no database override', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/New_York', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.getEffectiveSchedule('test-script'); + + expect(result.source).toBe('definition'); + expect(result.schedule.enabled).toBe(true); + expect(result.schedule.cronExpression).toBe('0 12 * * *'); + }); + + it('should return none when no schedule configured', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.getEffectiveSchedule('test-script'); + + expect(result.source).toBe('none'); + expect(result.schedule.enabled).toBe(false); + }); + + it('should throw error when script not found', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.getEffectiveSchedule('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + }); + }); + + describe('upsertSchedule', () => { + it('should create schedule and provision EventBridge when enabled', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + scheduleName: 'frigg-script-test-script', + }); + mockCommands.updateScheduleAwsInfo.mockResolvedValue({ + ...savedSchedule, + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }); + + const result = await useCase.upsertSchedule('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + + expect(result.success).toBe(true); + expect(result.schedule.scriptName).toBe('test-script'); + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalled(); + }); + + it('should delete EventBridge schedule when disabling', async () => { + const existingSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(existingSchedule); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + mockCommands.updateScheduleAwsInfo.mockResolvedValue({ + ...existingSchedule, + awsScheduleArn: null, + }); + + const result = await useCase.upsertSchedule('test-script', { + enabled: false, + }); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should handle scheduler errors gracefully', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockRejectedValue( + new Error('AWS Scheduler API error') + ); + + const result = await useCase.upsertSchedule('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + }); + + // Should succeed with warning, not fail + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('AWS Scheduler API error'); + }); + + it('should throw error when script not found', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.upsertSchedule('non-existent', { enabled: true })) + .rejects.toThrow('Script "non-existent" not found'); + }); + + it('should throw error when enabled without cronExpression', async () => { + mockScriptFactory.has.mockReturnValue(true); + + await expect(useCase.upsertSchedule('test-script', { enabled: true })) + .rejects.toThrow('cronExpression is required when enabled is true'); + }); + }); + + describe('deleteSchedule', () => { + it('should delete schedule and EventBridge rule', async () => { + const deletedSchedule = { + scriptName: 'test-script', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: deletedSchedule, + }); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should not call scheduler when no AWS rule exists', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, // No awsScheduleArn + }); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled(); + }); + + it('should handle scheduler delete errors gracefully', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { + scriptName: 'test-script', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockRejectedValue( + new Error('AWS delete failed') + ); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('AWS delete failed'); + }); + + it('should return effective schedule after deletion', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 6 * * *', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.effectiveSchedule.source).toBe('definition'); + expect(result.effectiveSchedule.enabled).toBe(true); + }); + + it('should throw error when script not found', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.deleteSchedule('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/schedule-management-use-case.js b/packages/admin-scripts/src/application/schedule-management-use-case.js new file mode 100644 index 000000000..e3fba9442 --- /dev/null +++ b/packages/admin-scripts/src/application/schedule-management-use-case.js @@ -0,0 +1,230 @@ +/** + * Schedule Management Use Case + * + * Application Layer - Hexagonal Architecture + * + * Orchestrates schedule management operations: + * - Get effective schedule (DB override > Definition > none) + * - Upsert schedule with EventBridge provisioning + * - Delete schedule with EventBridge cleanup + * + * This use case encapsulates the business logic that was previously + * embedded in the router, reducing cognitive complexity and improving testability. + */ +class ScheduleManagementUseCase { + constructor({ commands, schedulerAdapter, scriptFactory }) { + this.commands = commands; + this.schedulerAdapter = schedulerAdapter; + this.scriptFactory = scriptFactory; + } + + /** + * Validate that a script exists + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * Get the definition schedule from a script class + * @private + */ + _getDefinitionSchedule(scriptName) { + const scriptClass = this.scriptFactory.get(scriptName); + return scriptClass.Definition?.schedule || null; + } + + /** + * Get effective schedule (DB override > Definition default > none) + */ + async getEffectiveSchedule(scriptName) { + this._validateScriptExists(scriptName); + + // Check database override first + const dbSchedule = await this.commands.getScheduleByScriptName(scriptName); + if (dbSchedule) { + return { + source: 'database', + schedule: dbSchedule, + }; + } + + // Check definition default + const definitionSchedule = this._getDefinitionSchedule(scriptName); + if (definitionSchedule?.enabled) { + return { + source: 'definition', + schedule: { + scriptName, + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + }, + }; + } + + // No schedule configured + return { + source: 'none', + schedule: { + scriptName, + enabled: false, + }, + }; + } + + /** + * Create or update schedule with EventBridge provisioning + */ + async upsertSchedule(scriptName, { enabled, cronExpression, timezone }) { + this._validateScriptExists(scriptName); + this._validateScheduleInput(enabled, cronExpression); + + // Save to database + const schedule = await this.commands.upsertSchedule({ + scriptName, + enabled, + cronExpression: cronExpression || null, + timezone: timezone || 'UTC', + }); + + // Provision/deprovision EventBridge + const schedulerResult = await this._syncEventBridgeSchedule( + scriptName, + enabled, + cronExpression, + timezone, + schedule.awsScheduleArn + ); + + return { + success: true, + schedule: { + ...schedule, + awsScheduleArn: schedulerResult.awsScheduleArn || schedule.awsScheduleArn, + awsScheduleName: schedulerResult.awsScheduleName || schedule.awsScheduleName, + }, + ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }), + }; + } + + /** + * Validate schedule input + * @private + */ + _validateScheduleInput(enabled, cronExpression) { + if (typeof enabled !== 'boolean') { + const error = new Error('enabled must be a boolean'); + error.code = 'INVALID_INPUT'; + throw error; + } + + if (enabled && !cronExpression) { + const error = new Error('cronExpression is required when enabled is true'); + error.code = 'INVALID_INPUT'; + throw error; + } + } + + /** + * Sync EventBridge schedule based on enabled state + * @private + */ + async _syncEventBridgeSchedule(scriptName, enabled, cronExpression, timezone, existingArn) { + const result = { awsScheduleArn: null, awsScheduleName: null, warning: null }; + + try { + if (enabled && cronExpression) { + // Create/update EventBridge schedule + const awsInfo = await this.schedulerAdapter.createSchedule({ + scriptName, + cronExpression, + timezone: timezone || 'UTC', + }); + + if (awsInfo?.scheduleArn) { + await this.commands.updateScheduleAwsInfo(scriptName, { + awsScheduleArn: awsInfo.scheduleArn, + awsScheduleName: awsInfo.scheduleName, + }); + result.awsScheduleArn = awsInfo.scheduleArn; + result.awsScheduleName = awsInfo.scheduleName; + } + } else if (!enabled && existingArn) { + // Delete EventBridge schedule + await this.schedulerAdapter.deleteSchedule(scriptName); + await this.commands.updateScheduleAwsInfo(scriptName, { + awsScheduleArn: null, + awsScheduleName: null, + }); + } + } catch (error) { + // Non-fatal: DB schedule is saved, AWS can be retried + result.warning = error.message; + } + + return result; + } + + /** + * Delete schedule override and cleanup EventBridge + */ + async deleteSchedule(scriptName) { + this._validateScriptExists(scriptName); + + // Delete from database + const deleteResult = await this.commands.deleteSchedule(scriptName); + + // Cleanup EventBridge if needed + const schedulerWarning = await this._cleanupEventBridgeSchedule( + scriptName, + deleteResult.deleted?.awsScheduleArn + ); + + // Get effective schedule after deletion + const definitionSchedule = this._getDefinitionSchedule(scriptName); + const effectiveSchedule = definitionSchedule?.enabled + ? { + source: 'definition', + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + } + : { source: 'none', enabled: false }; + + return { + success: true, + deletedCount: deleteResult.deletedCount, + message: deleteResult.deletedCount > 0 + ? 'Schedule override removed' + : 'No schedule override found', + effectiveSchedule, + ...(schedulerWarning && { schedulerWarning }), + }; + } + + /** + * Cleanup EventBridge schedule if it exists + * @private + */ + async _cleanupEventBridgeSchedule(scriptName, awsScheduleArn) { + if (!awsScheduleArn) { + return null; + } + + try { + await this.schedulerAdapter.deleteSchedule(scriptName); + return null; + } catch (error) { + // Non-fatal: DB is cleaned up, AWS can be retried + return error.message; + } + } +} + +module.exports = { ScheduleManagementUseCase }; diff --git a/packages/admin-scripts/src/builtins/integration-health-check.js b/packages/admin-scripts/src/builtins/integration-health-check.js index 7ab904ae1..147c7dc9e 100644 --- a/packages/admin-scripts/src/builtins/integration-health-check.js +++ b/packages/admin-scripts/src/builtins/integration-health-check.js @@ -149,50 +149,77 @@ class IntegrationHealthCheckScript extends AdminScriptBase { async checkIntegration(frigg, integration, options) { const { checkCredentials, checkConnectivity } = options; + const result = this._createCheckResult(integration); - const result = { + try { + await this._runChecks(frigg, integration, result, { checkCredentials, checkConnectivity }); + this._determineOverallStatus(result); + } catch (error) { + this._handleCheckError(frigg, integration, result, error); + } + + return result; + } + + /** + * Create initial check result object + * @private + */ + _createCheckResult(integration) { + return { integrationId: integration.id, integrationType: integration.config?.type || 'unknown', status: 'unknown', checks: {}, issues: [] }; + } - try { - // Check credentials - if (checkCredentials) { - const credCheck = this.checkCredentialValidity(integration); - result.checks.credentials = credCheck; - if (!credCheck.valid) { - result.issues.push(credCheck.issue); - } - } + /** + * Run all requested checks + * @private + */ + async _runChecks(frigg, integration, result, options) { + const { checkCredentials, checkConnectivity } = options; - // Check connectivity - if (checkConnectivity) { - const connCheck = await this.checkApiConnectivity(frigg, integration); - result.checks.connectivity = connCheck; - if (!connCheck.valid) { - result.issues.push(connCheck.issue); - } - } + if (checkCredentials) { + this._addCheckResult(result, 'credentials', this.checkCredentialValidity(integration)); + } - // Determine overall status - if (result.issues.length === 0) { - result.status = 'healthy'; - } else { - result.status = 'unhealthy'; - } + if (checkConnectivity) { + this._addCheckResult(result, 'connectivity', await this.checkApiConnectivity(frigg, integration)); + } + } - } catch (error) { - frigg.log('error', `Error checking integration ${integration.id}`, { - error: error.message - }); - result.status = 'unknown'; - result.issues.push(`Check failed: ${error.message}`); + /** + * Add a check result and track any issues + * @private + */ + _addCheckResult(result, checkName, checkResult) { + result.checks[checkName] = checkResult; + if (!checkResult.valid) { + result.issues.push(checkResult.issue); } + } - return result; + /** + * Determine overall health status from issues + * @private + */ + _determineOverallStatus(result) { + result.status = result.issues.length === 0 ? 'healthy' : 'unhealthy'; + } + + /** + * Handle check error and update result + * @private + */ + _handleCheckError(frigg, integration, result, error) { + frigg.log('error', `Error checking integration ${integration.id}`, { + error: error.message + }); + result.status = 'unknown'; + result.issues.push(`Check failed: ${error.message}`); } checkCredentialValidity(integration) { diff --git a/packages/admin-scripts/src/builtins/oauth-token-refresh.js b/packages/admin-scripts/src/builtins/oauth-token-refresh.js index ecc321769..6586e8267 100644 --- a/packages/admin-scripts/src/builtins/oauth-token-refresh.js +++ b/packages/admin-scripts/src/builtins/oauth-token-refresh.js @@ -138,78 +138,84 @@ class OAuthTokenRefreshScript extends AdminScriptBase { async processIntegration(frigg, integration, options) { const { expiryThresholdHours, dryRun } = options; - // Check if integration has OAuth credentials + // Check prerequisites + const skipReason = this._checkRefreshPrerequisites(integration, expiryThresholdHours); + if (skipReason) { + return this._createResult(integration.id, 'skipped', skipReason); + } + + // Handle dry run + if (dryRun) { + frigg.log('info', `[DRY RUN] Would refresh token for ${integration.id}`); + return this._createResult(integration.id, 'skipped', 'Dry run - would have refreshed'); + } + + // Perform refresh + return this._performTokenRefresh(frigg, integration); + } + + /** + * Check if integration meets prerequisites for token refresh + * @private + * @returns {string|null} Skip reason or null if eligible + */ + _checkRefreshPrerequisites(integration, expiryThresholdHours) { if (!integration.config?.credentials?.access_token) { - return { - integrationId: integration.id, - action: 'skipped', - reason: 'No OAuth credentials found' - }; + return 'No OAuth credentials found'; } - // Check token expiry const expiresAt = integration.config?.credentials?.expires_at; if (!expiresAt) { - return { - integrationId: integration.id, - action: 'skipped', - reason: 'No expiry time found' - }; + return 'No expiry time found'; } const expiryTime = new Date(expiresAt); const thresholdTime = new Date(Date.now() + (expiryThresholdHours * 60 * 60 * 1000)); if (expiryTime > thresholdTime) { - return { - integrationId: integration.id, - action: 'skipped', - reason: 'Token not near expiry', - expiresAt: expiresAt - }; + return 'Token not near expiry'; } - if (dryRun) { - frigg.log('info', `[DRY RUN] Would refresh token for ${integration.id}`); - return { - integrationId: integration.id, - action: 'skipped', - reason: 'Dry run - would have refreshed' - }; - } + return null; + } + + /** + * Perform the actual token refresh + * @private + */ + async _performTokenRefresh(frigg, integration) { + const expiresAt = integration.config?.credentials?.expires_at; - // Refresh the token try { const instance = await frigg.instantiate(integration.id); - // Call refresh on the primary API - if (instance.primary?.api?.refreshAccessToken) { - await instance.primary.api.refreshAccessToken(); - - frigg.log('info', `Refreshed token for integration ${integration.id}`); - return { - integrationId: integration.id, - action: 'refreshed', - previousExpiry: expiresAt - }; - } else { - return { - integrationId: integration.id, - action: 'skipped', - reason: 'API does not support token refresh' - }; + if (!instance.primary?.api?.refreshAccessToken) { + return this._createResult(integration.id, 'skipped', 'API does not support token refresh'); } + + await instance.primary.api.refreshAccessToken(); + frigg.log('info', `Refreshed token for integration ${integration.id}`); + + return { + integrationId: integration.id, + action: 'refreshed', + previousExpiry: expiresAt + }; } catch (error) { frigg.log('error', `Failed to refresh token for ${integration.id}`, { error: error.message }); - return { - integrationId: integration.id, - action: 'failed', - reason: error.message - }; + return this._createResult(integration.id, 'failed', error.message); } } + + /** + * Create a result object + * @private + */ + _createResult(integrationId, action, reason) { + return { integrationId, action, reason }; + } } module.exports = { OAuthTokenRefreshScript }; diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index 018539907..05d70521a 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -6,12 +6,25 @@ const { createScriptRunner } = require('../application/script-runner'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); const { QueuerUtil } = require('@friggframework/core/queues'); const { createSchedulerAdapter } = require('../adapters/scheduler-adapter-factory'); +const { ScheduleManagementUseCase } = require('../application/schedule-management-use-case'); const router = express.Router(); // Apply auth middleware to all admin routes router.use(adminAuthMiddleware); +/** + * Create ScheduleManagementUseCase instance + * @private + */ +function createScheduleManagementUseCase() { + return new ScheduleManagementUseCase({ + commands: createAdminScriptCommands(), + schedulerAdapter: createSchedulerAdapter(), + scriptFactory: getScriptFactory(), + }); +} + /** * GET /admin/scripts * List all registered scripts @@ -200,60 +213,22 @@ router.get('/executions', async (req, res) => { router.get('/scripts/:scriptName/schedule', async (req, res) => { try { const { scriptName } = req.params; - const factory = getScriptFactory(); - const commands = createAdminScriptCommands(); - - // 1. Validate script exists - if (!factory.has(scriptName)) { - return res.status(404).json({ - error: `Script "${scriptName}" not found`, - code: 'SCRIPT_NOT_FOUND', - }); - } - - // 2. Get script class to access Definition - const scriptClass = factory.get(scriptName); - const definitionSchedule = scriptClass.Definition?.schedule; - - // 3. Get database schedule (if exists) - const dbSchedule = await commands.getScheduleByScriptName(scriptName); - - // 4. Apply hybrid schedule logic: DB override > Definition default > none - if (dbSchedule) { - // Database override exists - return res.json({ - source: 'database', - scriptName, - enabled: dbSchedule.enabled, - cronExpression: dbSchedule.cronExpression, - timezone: dbSchedule.timezone, - lastTriggeredAt: dbSchedule.lastTriggeredAt, - nextTriggerAt: dbSchedule.nextTriggerAt, - awsScheduleArn: dbSchedule.awsScheduleArn, - awsScheduleName: dbSchedule.awsScheduleName, - createdAt: dbSchedule.createdAt, - updatedAt: dbSchedule.updatedAt, - }); - } + const useCase = createScheduleManagementUseCase(); - if (definitionSchedule?.enabled) { - // Definition default exists - return res.json({ - source: 'definition', - scriptName, - enabled: definitionSchedule.enabled, - cronExpression: definitionSchedule.cronExpression, - timezone: definitionSchedule.timezone || 'UTC', - }); - } + const result = await useCase.getEffectiveSchedule(scriptName); - // No schedule configured - return res.json({ - source: 'none', + res.json({ + source: result.source, scriptName, - enabled: false, + ...result.schedule, }); } catch (error) { + if (error.code === 'SCRIPT_NOT_FOUND') { + return res.status(404).json({ + error: error.message, + code: error.code, + }); + } console.error('Error getting schedule:', error); res.status(500).json({ error: 'Failed to get schedule' }); } @@ -267,91 +242,35 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { try { const { scriptName } = req.params; const { enabled, cronExpression, timezone } = req.body; - const factory = getScriptFactory(); - const commands = createAdminScriptCommands(); - - // 1. Validate script exists - if (!factory.has(scriptName)) { - return res.status(404).json({ - error: `Script "${scriptName}" not found`, - code: 'SCRIPT_NOT_FOUND', - }); - } + const useCase = createScheduleManagementUseCase(); - // 2. Validate required fields - if (typeof enabled !== 'boolean') { - return res.status(400).json({ - error: 'Field "enabled" is required and must be a boolean', - code: 'INVALID_INPUT', - }); - } - - if (enabled && !cronExpression) { - return res.status(400).json({ - error: 'Field "cronExpression" is required when enabled is true', - code: 'INVALID_INPUT', - }); - } - - // 3. Upsert schedule to database - const schedule = await commands.upsertSchedule({ - scriptName, + const result = await useCase.upsertSchedule(scriptName, { enabled, - cronExpression: cronExpression || null, - timezone: timezone || 'UTC', + cronExpression, + timezone, }); - // 4. Provision EventBridge Scheduler rule for automatic triggering - let awsScheduleInfo = null; - let schedulerError = null; - try { - const adapter = createSchedulerAdapter(); - if (enabled && cronExpression) { - // Create or update the EventBridge schedule - awsScheduleInfo = await adapter.createSchedule({ - scriptName, - cronExpression, - timezone: timezone || 'UTC', - }); - // Store AWS schedule info in database - if (awsScheduleInfo?.scheduleArn) { - await commands.updateScheduleAwsInfo(scriptName, { - awsScheduleArn: awsScheduleInfo.scheduleArn, - awsScheduleName: awsScheduleInfo.scheduleName, - }); - } - } else if (!enabled && schedule.awsScheduleArn) { - // Disable: delete the EventBridge schedule - await adapter.deleteSchedule(scriptName); - await commands.updateScheduleAwsInfo(scriptName, { - awsScheduleArn: null, - awsScheduleName: null, - }); - } - } catch (error) { - // Log but don't fail - DB schedule is saved, AWS provisioning can be retried - console.error('EventBridge Scheduler error (non-fatal):', error.message); - schedulerError = error.message; - } - res.json({ - success: true, + success: result.success, schedule: { source: 'database', - scriptName: schedule.scriptName, - enabled: schedule.enabled, - cronExpression: schedule.cronExpression, - timezone: schedule.timezone, - lastTriggeredAt: schedule.lastTriggeredAt, - nextTriggerAt: schedule.nextTriggerAt, - createdAt: schedule.createdAt, - updatedAt: schedule.updatedAt, - awsScheduleArn: awsScheduleInfo?.scheduleArn || schedule.awsScheduleArn, - awsScheduleName: awsScheduleInfo?.scheduleName || schedule.awsScheduleName, + ...result.schedule, }, - ...(schedulerError && { schedulerWarning: schedulerError }), + ...(result.schedulerWarning && { schedulerWarning: result.schedulerWarning }), }); } catch (error) { + if (error.code === 'SCRIPT_NOT_FOUND') { + return res.status(404).json({ + error: error.message, + code: error.code, + }); + } + if (error.code === 'INVALID_INPUT') { + return res.status(400).json({ + error: error.message, + code: error.code, + }); + } console.error('Error updating schedule:', error); res.status(500).json({ error: 'Failed to update schedule' }); } @@ -364,55 +283,18 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { router.delete('/scripts/:scriptName/schedule', async (req, res) => { try { const { scriptName } = req.params; - const factory = getScriptFactory(); - const commands = createAdminScriptCommands(); + const useCase = createScheduleManagementUseCase(); - // 1. Validate script exists - if (!factory.has(scriptName)) { + const result = await useCase.deleteSchedule(scriptName); + + res.json(result); + } catch (error) { + if (error.code === 'SCRIPT_NOT_FOUND') { return res.status(404).json({ - error: `Script "${scriptName}" not found`, - code: 'SCRIPT_NOT_FOUND', + error: error.message, + code: error.code, }); } - - // 2. Delete schedule from database - const result = await commands.deleteSchedule(scriptName); - - // 3. Delete EventBridge Scheduler if it exists - let schedulerError = null; - if (result.deleted?.awsScheduleArn) { - try { - const adapter = createSchedulerAdapter(); - await adapter.deleteSchedule(scriptName); - } catch (error) { - // Log but don't fail - DB schedule is deleted, AWS cleanup can be retried - console.error('EventBridge Scheduler delete error (non-fatal):', error.message); - schedulerError = error.message; - } - } - - // 4. Check if Definition default exists - const scriptClass = factory.get(scriptName); - const definitionSchedule = scriptClass.Definition?.schedule; - - res.json({ - success: true, - deletedCount: result.deletedCount, - message: - result.deletedCount > 0 - ? 'Schedule override removed' - : 'No schedule override found', - effectiveSchedule: definitionSchedule?.enabled - ? { - source: 'definition', - enabled: definitionSchedule.enabled, - cronExpression: definitionSchedule.cronExpression, - timezone: definitionSchedule.timezone || 'UTC', - } - : { source: 'none', enabled: false }, - ...(schedulerError && { schedulerWarning: schedulerError }), - }); - } catch (error) { console.error('Error deleting schedule:', error); res.status(500).json({ error: 'Failed to delete schedule' }); } From 78063d750d49a201bc9a276b6dc407541a9fc38b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 05:51:14 +0000 Subject: [PATCH 21/33] ci(release): configure npm trusted publishers with OIDC - Add id-token: write permission for OIDC authentication - Add contents: write permission for release commits - Upgrade npm to latest for trusted publishing support (requires 11.5.1+) - Remove NPM_TOKEN/NODE_AUTH_TOKEN (OIDC replaces token auth) Requires configuring trusted publishers on npmjs.com for each package. --- .github/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e49f08dd2..81743251c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,10 @@ on: - gitbook-updates paths-ignore: - docs/** +permissions: + contents: write + id-token: write + jobs: release: runs-on: ubuntu-latest @@ -26,10 +30,9 @@ jobs: - name: Auto release env: GH_TOKEN: ${{ secrets.GH_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} run: | + npm install -g npm@latest npm ci cd packages/ui npm run build From 3b65a73aa8494f7d24931cfd789680204a477031 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Dec 2025 05:57:46 +0000 Subject: [PATCH 22/33] ci(release): use hybrid OIDC + token auth for npm publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep both OIDC permissions and NPM_TOKEN for flexibility: - Packages with trusted publisher configured → use OIDC - New packages (e.g., admin-scripts) → fall back to token This allows gradual migration to OIDC while supporting new package publishes. --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81743251c..e6536a6c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,8 @@ jobs: - name: Auto release env: GH_TOKEN: ${{ secrets.GH_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} run: | npm install -g npm@latest From 9cb7d7d29877d04c029188d6daf4324aa673de16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 03:06:32 +0000 Subject: [PATCH 23/33] refactor(admin): simplify auth and replace models per PR feedback Phase 1 of admin refactoring based on Daniel's PR review: 1. Auth simplification (ENV-based like db-migrate): - Add shared validateAdminApiKey middleware in core/handlers/middleware - Delete AdminApiKey model, repositories, and tests - Remove API key commands from admin-script-commands.js 2. Schema changes: - Replace AdminApiKey + ScriptExecution with AdminProcess model - AdminProcess mirrors Process but without user/integration FK - Supports hierarchy (parentProcessId, childProcesses) - Used for: admin scripts, db migrations, system tasks 3. Files deleted: - admin-api-key-repository-*.js (all variants) - admin-api-key tests Next steps: Create AdminProcess repository, refactor routes to /admin/scripts/:name convention, unify db-migrate under /admin. --- packages/core/admin-scripts/index.js | 16 +- ...admin-api-key-repository-interface.test.js | 109 -------- .../admin-api-key-repository-mongo.test.js | 254 ------------------ .../admin-api-key-repository-documentdb.js | 21 -- .../admin-api-key-repository-factory.js | 51 ---- .../admin-api-key-repository-interface.js | 104 ------- .../admin-api-key-repository-mongo.js | 151 ----------- .../admin-api-key-repository-postgres.js | 185 ------------- .../commands/admin-script-commands.js | 135 +--------- .../middleware/__tests__/admin-auth.test.js | 90 +++++++ .../core/handlers/middleware/admin-auth.js | 53 ++++ packages/core/prisma-mongodb/schema.prisma | 79 +++--- packages/core/prisma-postgresql/schema.prisma | 77 +++--- 13 files changed, 211 insertions(+), 1114 deletions(-) delete mode 100644 packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js delete mode 100644 packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js delete mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js delete mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js delete mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js delete mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js delete mode 100644 packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js create mode 100644 packages/core/handlers/middleware/__tests__/admin-auth.test.js create mode 100644 packages/core/handlers/middleware/admin-auth.js diff --git a/packages/core/admin-scripts/index.js b/packages/core/admin-scripts/index.js index f6f94810b..cb5aae0ad 100644 --- a/packages/core/admin-scripts/index.js +++ b/packages/core/admin-scripts/index.js @@ -9,20 +9,17 @@ * - Enable dependency injection * - Allow testing with mocks * - Support multiple database implementations + * + * Authentication: + * - Uses ENV-based ADMIN_API_KEY (see handlers/middleware/admin-auth.js) + * - No database-backed API keys (simplified from original design) */ // Repository Interfaces -const { AdminApiKeyRepositoryInterface } = require('./repositories/admin-api-key-repository-interface'); const { ScriptExecutionRepositoryInterface } = require('./repositories/script-execution-repository-interface'); const { ScriptScheduleRepositoryInterface } = require('./repositories/script-schedule-repository-interface'); // Repository Factories -const { - createAdminApiKeyRepository, - AdminApiKeyRepositoryMongo, - AdminApiKeyRepositoryPostgres, - AdminApiKeyRepositoryDocumentDB, -} = require('./repositories/admin-api-key-repository-factory'); const { createScriptExecutionRepository, ScriptExecutionRepositoryMongo, @@ -38,19 +35,14 @@ const { module.exports = { // Repository Interfaces - AdminApiKeyRepositoryInterface, ScriptExecutionRepositoryInterface, ScriptScheduleRepositoryInterface, // Repository Factories (primary exports for use cases) - createAdminApiKeyRepository, createScriptExecutionRepository, createScriptScheduleRepository, // Concrete Implementations (for testing) - AdminApiKeyRepositoryMongo, - AdminApiKeyRepositoryPostgres, - AdminApiKeyRepositoryDocumentDB, ScriptExecutionRepositoryMongo, ScriptExecutionRepositoryPostgres, ScriptExecutionRepositoryDocumentDB, diff --git a/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js deleted file mode 100644 index e90fe8660..000000000 --- a/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-interface.test.js +++ /dev/null @@ -1,109 +0,0 @@ -const { AdminApiKeyRepositoryInterface } = require('../admin-api-key-repository-interface'); - -describe('AdminApiKeyRepositoryInterface', () => { - let repository; - - beforeEach(() => { - repository = new AdminApiKeyRepositoryInterface(); - }); - - describe('Interface contract', () => { - it('should throw error when createApiKey is not implemented', async () => { - await expect( - repository.createApiKey({ - name: 'test-key', - keyHash: 'hash123', - keyLast4: '1234', - scopes: ['scripts:execute'], - expiresAt: new Date(), - createdBy: 'admin@example.com', - }) - ).rejects.toThrow('Method createApiKey must be implemented by subclass'); - }); - - it('should throw error when findApiKeyByHash is not implemented', async () => { - await expect( - repository.findApiKeyByHash('hash123') - ).rejects.toThrow('Method findApiKeyByHash must be implemented by subclass'); - }); - - it('should throw error when findApiKeyById is not implemented', async () => { - await expect( - repository.findApiKeyById('key123') - ).rejects.toThrow('Method findApiKeyById must be implemented by subclass'); - }); - - it('should throw error when findActiveApiKeys is not implemented', async () => { - await expect( - repository.findActiveApiKeys() - ).rejects.toThrow('Method findActiveApiKeys must be implemented by subclass'); - }); - - it('should throw error when updateApiKeyLastUsed is not implemented', async () => { - await expect( - repository.updateApiKeyLastUsed('key123') - ).rejects.toThrow('Method updateApiKeyLastUsed must be implemented by subclass'); - }); - - it('should throw error when deactivateApiKey is not implemented', async () => { - await expect( - repository.deactivateApiKey('key123') - ).rejects.toThrow('Method deactivateApiKey must be implemented by subclass'); - }); - - it('should throw error when deleteApiKey is not implemented', async () => { - await expect( - repository.deleteApiKey('key123') - ).rejects.toThrow('Method deleteApiKey must be implemented by subclass'); - }); - }); - - describe('Method signatures', () => { - it('should accept all required parameters in createApiKey', async () => { - const params = { - name: 'test-key', - keyHash: 'hash123', - keyLast4: '1234', - scopes: ['scripts:execute', 'scripts:read'], - expiresAt: new Date('2025-12-31'), - createdBy: 'admin@example.com', - }; - - await expect(repository.createApiKey(params)).rejects.toThrow(); - }); - - it('should accept string parameter in findApiKeyByHash', async () => { - await expect( - repository.findApiKeyByHash('some-hash') - ).rejects.toThrow(); - }); - - it('should accept string parameter in findApiKeyById', async () => { - await expect( - repository.findApiKeyById('some-id') - ).rejects.toThrow(); - }); - - it('should accept no parameters in findActiveApiKeys', async () => { - await expect(repository.findActiveApiKeys()).rejects.toThrow(); - }); - - it('should accept string parameter in updateApiKeyLastUsed', async () => { - await expect( - repository.updateApiKeyLastUsed('some-id') - ).rejects.toThrow(); - }); - - it('should accept string parameter in deactivateApiKey', async () => { - await expect( - repository.deactivateApiKey('some-id') - ).rejects.toThrow(); - }); - - it('should accept string parameter in deleteApiKey', async () => { - await expect( - repository.deleteApiKey('some-id') - ).rejects.toThrow(); - }); - }); -}); diff --git a/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js b/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js deleted file mode 100644 index 68b6bab25..000000000 --- a/packages/core/admin-scripts/repositories/__tests__/admin-api-key-repository-mongo.test.js +++ /dev/null @@ -1,254 +0,0 @@ -const { AdminApiKeyRepositoryMongo } = require('../admin-api-key-repository-mongo'); - -describe('AdminApiKeyRepositoryMongo', () => { - let repository; - let mockPrisma; - - beforeEach(() => { - mockPrisma = { - adminApiKey: { - create: jest.fn(), - findUnique: jest.fn(), - findMany: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - }, - }; - - repository = new AdminApiKeyRepositoryMongo(); - repository.prisma = mockPrisma; - }); - - describe('createApiKey()', () => { - it('should create a new API key with all fields', async () => { - const params = { - name: 'Test Key', - keyHash: 'hash123', - keyLast4: '1234', - scopes: ['scripts:execute', 'scripts:read'], - expiresAt: new Date('2025-12-31'), - createdBy: 'admin@example.com', - }; - - const mockApiKey = { - id: '507f1f77bcf86cd799439011', - ...params, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockPrisma.adminApiKey.create.mockResolvedValue(mockApiKey); - - const result = await repository.createApiKey(params); - - expect(result).toEqual(mockApiKey); - expect(mockPrisma.adminApiKey.create).toHaveBeenCalledWith({ - data: params, - }); - }); - - it('should create API key without optional fields', async () => { - const params = { - name: 'Test Key', - keyHash: 'hash123', - keyLast4: '1234', - scopes: ['scripts:execute'], - }; - - const mockApiKey = { - id: '507f1f77bcf86cd799439011', - ...params, - expiresAt: null, - createdBy: null, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockPrisma.adminApiKey.create.mockResolvedValue(mockApiKey); - - const result = await repository.createApiKey(params); - - expect(result).toEqual(mockApiKey); - }); - }); - - describe('findApiKeyByHash()', () => { - it('should find API key by hash', async () => { - const keyHash = 'hash123'; - const mockApiKey = { - id: '507f1f77bcf86cd799439011', - name: 'Test Key', - keyHash, - keyLast4: '1234', - scopes: ['scripts:execute'], - isActive: true, - }; - - mockPrisma.adminApiKey.findUnique.mockResolvedValue(mockApiKey); - - const result = await repository.findApiKeyByHash(keyHash); - - expect(result).toEqual(mockApiKey); - expect(mockPrisma.adminApiKey.findUnique).toHaveBeenCalledWith({ - where: { keyHash }, - }); - }); - - it('should return null if API key not found', async () => { - mockPrisma.adminApiKey.findUnique.mockResolvedValue(null); - - const result = await repository.findApiKeyByHash('nonexistent'); - - expect(result).toBeNull(); - }); - }); - - describe('findApiKeyById()', () => { - it('should find API key by ID', async () => { - const id = '507f1f77bcf86cd799439011'; - const mockApiKey = { - id, - name: 'Test Key', - keyHash: 'hash123', - keyLast4: '1234', - scopes: ['scripts:execute'], - isActive: true, - }; - - mockPrisma.adminApiKey.findUnique.mockResolvedValue(mockApiKey); - - const result = await repository.findApiKeyById(id); - - expect(result).toEqual(mockApiKey); - expect(mockPrisma.adminApiKey.findUnique).toHaveBeenCalledWith({ - where: { id }, - }); - }); - - it('should return null if API key not found', async () => { - mockPrisma.adminApiKey.findUnique.mockResolvedValue(null); - - const result = await repository.findApiKeyById('nonexistent'); - - expect(result).toBeNull(); - }); - }); - - describe('findActiveApiKeys()', () => { - it('should find all active non-expired keys', async () => { - const now = new Date(); - const mockApiKeys = [ - { - id: '507f1f77bcf86cd799439011', - name: 'Key 1', - isActive: true, - expiresAt: null, - }, - { - id: '507f1f77bcf86cd799439012', - name: 'Key 2', - isActive: true, - expiresAt: new Date(Date.now() + 86400000), // tomorrow - }, - ]; - - mockPrisma.adminApiKey.findMany.mockResolvedValue(mockApiKeys); - - const result = await repository.findActiveApiKeys(); - - expect(result).toEqual(mockApiKeys); - expect(mockPrisma.adminApiKey.findMany).toHaveBeenCalledWith({ - where: { - isActive: true, - OR: [ - { expiresAt: null }, - { expiresAt: { gt: expect.any(Date) } }, - ], - }, - }); - }); - - it('should return empty array if no active keys', async () => { - mockPrisma.adminApiKey.findMany.mockResolvedValue([]); - - const result = await repository.findActiveApiKeys(); - - expect(result).toEqual([]); - }); - }); - - describe('updateApiKeyLastUsed()', () => { - it('should update lastUsedAt timestamp', async () => { - const id = '507f1f77bcf86cd799439011'; - const mockApiKey = { - id, - name: 'Test Key', - lastUsedAt: new Date(), - }; - - mockPrisma.adminApiKey.update.mockResolvedValue(mockApiKey); - - const result = await repository.updateApiKeyLastUsed(id); - - expect(result).toEqual(mockApiKey); - expect(mockPrisma.adminApiKey.update).toHaveBeenCalledWith({ - where: { id }, - data: { - lastUsedAt: expect.any(Date), - }, - }); - }); - }); - - describe('deactivateApiKey()', () => { - it('should set isActive to false', async () => { - const id = '507f1f77bcf86cd799439011'; - const mockApiKey = { - id, - name: 'Test Key', - isActive: false, - }; - - mockPrisma.adminApiKey.update.mockResolvedValue(mockApiKey); - - const result = await repository.deactivateApiKey(id); - - expect(result).toEqual(mockApiKey); - expect(mockPrisma.adminApiKey.update).toHaveBeenCalledWith({ - where: { id }, - data: { - isActive: false, - }, - }); - }); - }); - - describe('deleteApiKey()', () => { - it('should delete API key and return result', async () => { - const id = '507f1f77bcf86cd799439011'; - - mockPrisma.adminApiKey.delete.mockResolvedValue({}); - - const result = await repository.deleteApiKey(id); - - expect(result).toEqual({ - acknowledged: true, - deletedCount: 1, - }); - expect(mockPrisma.adminApiKey.delete).toHaveBeenCalledWith({ - where: { id }, - }); - }); - - it('should propagate error if delete fails', async () => { - const id = '507f1f77bcf86cd799439011'; - const error = new Error('Not found'); - - mockPrisma.adminApiKey.delete.mockRejectedValue(error); - - await expect(repository.deleteApiKey(id)).rejects.toThrow('Not found'); - }); - }); -}); diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js deleted file mode 100644 index cdac6761f..000000000 --- a/packages/core/admin-scripts/repositories/admin-api-key-repository-documentdb.js +++ /dev/null @@ -1,21 +0,0 @@ -const { - AdminApiKeyRepositoryMongo, -} = require('./admin-api-key-repository-mongo'); - -/** - * DocumentDB Admin API Key Repository Adapter - * Extends MongoDB implementation since DocumentDB uses the same Prisma client - * - * DocumentDB-specific characteristics: - * - Uses MongoDB-compatible API - * - Prisma client handles the connection - * - IDs are strings with ObjectId format - * - All operations identical to MongoDB implementation - */ -class AdminApiKeyRepositoryDocumentDB extends AdminApiKeyRepositoryMongo { - constructor() { - super(); - } -} - -module.exports = { AdminApiKeyRepositoryDocumentDB }; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js deleted file mode 100644 index eac03479c..000000000 --- a/packages/core/admin-scripts/repositories/admin-api-key-repository-factory.js +++ /dev/null @@ -1,51 +0,0 @@ -const { AdminApiKeyRepositoryMongo } = require('./admin-api-key-repository-mongo'); -const { AdminApiKeyRepositoryPostgres } = require('./admin-api-key-repository-postgres'); -const { - AdminApiKeyRepositoryDocumentDB, -} = require('./admin-api-key-repository-documentdb'); -const config = require('../../database/config'); - -/** - * Admin API Key Repository Factory - * Creates the appropriate repository adapter based on database type - * - * This implements the Factory pattern for Hexagonal Architecture: - * - Reads database type from app definition (backend/index.js) - * - Returns correct adapter (MongoDB, DocumentDB, or PostgreSQL) - * - Provides clear error for unsupported databases - * - * Usage: - * ```javascript - * const repository = createAdminApiKeyRepository(); - * ``` - * - * @returns {AdminApiKeyRepositoryInterface} Configured repository adapter - * @throws {Error} If database type is not supported - */ -function createAdminApiKeyRepository() { - const dbType = config.DB_TYPE; - - switch (dbType) { - case 'mongodb': - return new AdminApiKeyRepositoryMongo(); - - case 'postgresql': - return new AdminApiKeyRepositoryPostgres(); - - case 'documentdb': - return new AdminApiKeyRepositoryDocumentDB(); - - default: - throw new Error( - `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'` - ); - } -} - -module.exports = { - createAdminApiKeyRepository, - // Export adapters for direct testing - AdminApiKeyRepositoryMongo, - AdminApiKeyRepositoryPostgres, - AdminApiKeyRepositoryDocumentDB, -}; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js deleted file mode 100644 index 7ef9a7545..000000000 --- a/packages/core/admin-scripts/repositories/admin-api-key-repository-interface.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Admin API Key Repository Interface - * Abstract base class defining the contract for admin API key persistence adapters - * - * This follows the Port in Hexagonal Architecture: - * - Domain layer depends on this abstraction - * - Concrete adapters implement this interface - * - Use cases receive repositories via dependency injection - * - * Admin API keys provide authentication for script execution and management endpoints. - * Keys are bcrypt-hashed for security and support scoping and expiration. - * - * @abstract - */ -class AdminApiKeyRepositoryInterface { - /** - * Create a new admin API key - * - * @param {Object} params - API key creation parameters - * @param {string} params.name - Human-readable name for the key - * @param {string} params.keyHash - bcrypt hash of the raw key - * @param {string} params.keyLast4 - Last 4 characters of key (for display) - * @param {string[]} params.scopes - Array of permission scopes (e.g., ['scripts:execute', 'scripts:read']) - * @param {Date} [params.expiresAt] - Optional expiration date - * @param {string} [params.createdBy] - Optional identifier of creator (user/admin) - * @returns {Promise} The created API key record - * @abstract - */ - async createApiKey({ name, keyHash, keyLast4, scopes, expiresAt, createdBy }) { - throw new Error('Method createApiKey must be implemented by subclass'); - } - - /** - * Find an API key by its bcrypt hash - * Used during authentication to validate incoming keys - * - * @param {string} keyHash - The bcrypt hash to search for - * @returns {Promise} The API key record or null if not found - * @abstract - */ - async findApiKeyByHash(keyHash) { - throw new Error('Method findApiKeyByHash must be implemented by subclass'); - } - - /** - * Find an API key by its ID - * - * @param {string|number} id - The API key ID - * @returns {Promise} The API key record or null if not found - * @abstract - */ - async findApiKeyById(id) { - throw new Error('Method findApiKeyById must be implemented by subclass'); - } - - /** - * Find all active (non-expired, non-deactivated) API keys - * Used during authentication to check all valid keys - * - * @returns {Promise} Array of active API key records - * @abstract - */ - async findActiveApiKeys() { - throw new Error('Method findActiveApiKeys must be implemented by subclass'); - } - - /** - * Update the lastUsedAt timestamp for an API key - * Called after successful authentication - * - * @param {string|number} id - The API key ID - * @returns {Promise} Updated API key record - * @abstract - */ - async updateApiKeyLastUsed(id) { - throw new Error('Method updateApiKeyLastUsed must be implemented by subclass'); - } - - /** - * Deactivate an API key (soft delete) - * Sets isActive to false, preventing further use - * - * @param {string|number} id - The API key ID - * @returns {Promise} Updated API key record - * @abstract - */ - async deactivateApiKey(id) { - throw new Error('Method deactivateApiKey must be implemented by subclass'); - } - - /** - * Delete an API key (hard delete) - * Permanently removes the key from the database - * - * @param {string|number} id - The API key ID - * @returns {Promise} Deletion result - * @abstract - */ - async deleteApiKey(id) { - throw new Error('Method deleteApiKey must be implemented by subclass'); - } -} - -module.exports = { AdminApiKeyRepositoryInterface }; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js deleted file mode 100644 index 3398b495e..000000000 --- a/packages/core/admin-scripts/repositories/admin-api-key-repository-mongo.js +++ /dev/null @@ -1,151 +0,0 @@ -const { prisma } = require('../../database/prisma'); -const { - AdminApiKeyRepositoryInterface, -} = require('./admin-api-key-repository-interface'); - -/** - * MongoDB Admin API Key Repository Adapter - * Handles admin API key persistence using Prisma with MongoDB - * - * MongoDB-specific characteristics: - * - IDs are strings with @db.ObjectId - * - Supports bcrypt hashed keys - * - Scopes stored as String[] array - */ -class AdminApiKeyRepositoryMongo extends AdminApiKeyRepositoryInterface { - constructor() { - super(); - this.prisma = prisma; - } - - /** - * Create a new admin API key - * - * @param {Object} params - API key creation parameters - * @param {string} params.name - Human-readable name for the key - * @param {string} params.keyHash - bcrypt hash of the raw key - * @param {string} params.keyLast4 - Last 4 characters of key (for display) - * @param {string[]} params.scopes - Array of permission scopes - * @param {Date} [params.expiresAt] - Optional expiration date - * @param {string} [params.createdBy] - Optional identifier of creator - * @returns {Promise} The created API key record - */ - async createApiKey({ name, keyHash, keyLast4, scopes, expiresAt, createdBy }) { - const apiKey = await this.prisma.adminApiKey.create({ - data: { - name, - keyHash, - keyLast4, - scopes, - expiresAt, - createdBy, - }, - }); - - return apiKey; - } - - /** - * Find an API key by its bcrypt hash - * Used during authentication to validate incoming keys - * - * @param {string} keyHash - The bcrypt hash to search for - * @returns {Promise} The API key record or null if not found - */ - async findApiKeyByHash(keyHash) { - const apiKey = await this.prisma.adminApiKey.findUnique({ - where: { keyHash }, - }); - - return apiKey; - } - - /** - * Find an API key by its ID - * - * @param {string} id - The API key ID (MongoDB ObjectId as string) - * @returns {Promise} The API key record or null if not found - */ - async findApiKeyById(id) { - const apiKey = await this.prisma.adminApiKey.findUnique({ - where: { id }, - }); - - return apiKey; - } - - /** - * Find all active (non-expired, non-deactivated) API keys - * Used during authentication to check all valid keys - * - * @returns {Promise} Array of active API key records - */ - async findActiveApiKeys() { - const now = new Date(); - const apiKeys = await this.prisma.adminApiKey.findMany({ - where: { - isActive: true, - OR: [ - { expiresAt: null }, - { expiresAt: { gt: now } }, - ], - }, - }); - - return apiKeys; - } - - /** - * Update the lastUsedAt timestamp for an API key - * Called after successful authentication - * - * @param {string} id - The API key ID - * @returns {Promise} Updated API key record - */ - async updateApiKeyLastUsed(id) { - const apiKey = await this.prisma.adminApiKey.update({ - where: { id }, - data: { - lastUsedAt: new Date(), - }, - }); - - return apiKey; - } - - /** - * Deactivate an API key (soft delete) - * Sets isActive to false, preventing further use - * - * @param {string} id - The API key ID - * @returns {Promise} Updated API key record - */ - async deactivateApiKey(id) { - const apiKey = await this.prisma.adminApiKey.update({ - where: { id }, - data: { - isActive: false, - }, - }); - - return apiKey; - } - - /** - * Delete an API key (hard delete) - * Permanently removes the key from the database - * - * @param {string} id - The API key ID - * @returns {Promise} Deletion result - */ - async deleteApiKey(id) { - await this.prisma.adminApiKey.delete({ - where: { id }, - }); - - // Return Mongoose-compatible result - return { acknowledged: true, deletedCount: 1 }; - } -} - -module.exports = { AdminApiKeyRepositoryMongo }; diff --git a/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js b/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js deleted file mode 100644 index 2062db415..000000000 --- a/packages/core/admin-scripts/repositories/admin-api-key-repository-postgres.js +++ /dev/null @@ -1,185 +0,0 @@ -const { prisma } = require('../../database/prisma'); -const { - AdminApiKeyRepositoryInterface, -} = require('./admin-api-key-repository-interface'); - -/** - * PostgreSQL Admin API Key Repository Adapter - * Handles admin API key persistence using Prisma with PostgreSQL - * - * PostgreSQL-specific characteristics: - * - Uses Int IDs with autoincrement - * - Requires ID conversion: String (app layer) ↔ Int (database) - * - All returned IDs are converted to strings for application layer consistency - */ -class AdminApiKeyRepositoryPostgres extends AdminApiKeyRepositoryInterface { - constructor() { - super(); - this.prisma = prisma; - } - - /** - * Convert string ID to integer for PostgreSQL queries - * @private - * @param {string|number|null|undefined} id - ID to convert - * @returns {number|null|undefined} Integer ID or null/undefined - * @throws {Error} If ID cannot be converted to integer - */ - _convertId(id) { - if (id === null || id === undefined) return id; - const parsed = Number.parseInt(id, 10); - if (Number.isNaN(parsed)) { - throw new Error(`Invalid ID: ${id} cannot be converted to integer`); - } - return parsed; - } - - /** - * Convert API key object IDs to strings - * @private - * @param {Object|null} apiKey - API key object from database - * @returns {Object|null} API key with string IDs - */ - _convertApiKeyIds(apiKey) { - if (!apiKey) return apiKey; - return { - ...apiKey, - id: apiKey.id?.toString(), - }; - } - - /** - * Create a new admin API key - * - * @param {Object} params - API key creation parameters - * @param {string} params.name - Human-readable name for the key - * @param {string} params.keyHash - bcrypt hash of the raw key - * @param {string} params.keyLast4 - Last 4 characters of key (for display) - * @param {string[]} params.scopes - Array of permission scopes - * @param {Date} [params.expiresAt] - Optional expiration date - * @param {string} [params.createdBy] - Optional identifier of creator - * @returns {Promise} The created API key record with string ID - */ - async createApiKey({ name, keyHash, keyLast4, scopes, expiresAt, createdBy }) { - const apiKey = await this.prisma.adminApiKey.create({ - data: { - name, - keyHash, - keyLast4, - scopes, - expiresAt, - createdBy, - }, - }); - - return this._convertApiKeyIds(apiKey); - } - - /** - * Find an API key by its bcrypt hash - * Used during authentication to validate incoming keys - * - * @param {string} keyHash - The bcrypt hash to search for - * @returns {Promise} The API key record with string ID or null if not found - */ - async findApiKeyByHash(keyHash) { - const apiKey = await this.prisma.adminApiKey.findUnique({ - where: { keyHash }, - }); - - return this._convertApiKeyIds(apiKey); - } - - /** - * Find an API key by its ID - * - * @param {string|number} id - The API key ID - * @returns {Promise} The API key record with string ID or null if not found - */ - async findApiKeyById(id) { - const intId = this._convertId(id); - const apiKey = await this.prisma.adminApiKey.findUnique({ - where: { id: intId }, - }); - - return this._convertApiKeyIds(apiKey); - } - - /** - * Find all active (non-expired, non-deactivated) API keys - * Used during authentication to check all valid keys - * - * @returns {Promise} Array of active API key records with string IDs - */ - async findActiveApiKeys() { - const now = new Date(); - const apiKeys = await this.prisma.adminApiKey.findMany({ - where: { - isActive: true, - OR: [ - { expiresAt: null }, - { expiresAt: { gt: now } }, - ], - }, - }); - - return apiKeys.map((apiKey) => this._convertApiKeyIds(apiKey)); - } - - /** - * Update the lastUsedAt timestamp for an API key - * Called after successful authentication - * - * @param {string|number} id - The API key ID - * @returns {Promise} Updated API key record with string ID - */ - async updateApiKeyLastUsed(id) { - const intId = this._convertId(id); - const apiKey = await this.prisma.adminApiKey.update({ - where: { id: intId }, - data: { - lastUsedAt: new Date(), - }, - }); - - return this._convertApiKeyIds(apiKey); - } - - /** - * Deactivate an API key (soft delete) - * Sets isActive to false, preventing further use - * - * @param {string|number} id - The API key ID - * @returns {Promise} Updated API key record with string ID - */ - async deactivateApiKey(id) { - const intId = this._convertId(id); - const apiKey = await this.prisma.adminApiKey.update({ - where: { id: intId }, - data: { - isActive: false, - }, - }); - - return this._convertApiKeyIds(apiKey); - } - - /** - * Delete an API key (hard delete) - * Permanently removes the key from the database - * - * @param {string|number} id - The API key ID - * @returns {Promise} Deletion result - */ - async deleteApiKey(id) { - const intId = this._convertId(id); - await this.prisma.adminApiKey.delete({ - where: { id: intId }, - }); - - // Return Mongoose-compatible result - return { acknowledged: true, deletedCount: 1 }; - } -} - -module.exports = { AdminApiKeyRepositoryPostgres }; diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js index 893d0be06..25231896e 100644 --- a/packages/core/application/commands/admin-script-commands.js +++ b/packages/core/application/commands/admin-script-commands.js @@ -1,12 +1,6 @@ -const bcrypt = require('bcryptjs'); -const { v4: uuid } = require('uuid'); - const ERROR_CODE_MAP = { - INVALID_API_KEY: 401, - EXPIRED_API_KEY: 401, SCRIPT_NOT_FOUND: 404, EXECUTION_NOT_FOUND: 404, - UNAUTHORIZED_SCOPE: 403, }; function mapErrorToResponse(error) { @@ -23,142 +17,21 @@ function mapErrorToResponse(error) { * - Maps errors to HTTP-friendly responses * - Returns data or error objects (never throws) * + * Authentication: + * - Uses ENV-based ADMIN_API_KEY (see handlers/middleware/admin-auth.js) + * - No database-backed API keys (simplified from original design) + * * @returns {Object} Command methods for admin scripts */ function createAdminScriptCommands() { // Lazy-load repository factories to avoid circular dependencies - const { createAdminApiKeyRepository } = require('../../admin-scripts/repositories/admin-api-key-repository-factory'); const { createScriptExecutionRepository } = require('../../admin-scripts/repositories/script-execution-repository-factory'); const { createScriptScheduleRepository } = require('../../admin-scripts/repositories/script-schedule-repository-factory'); - const apiKeyRepository = createAdminApiKeyRepository(); const executionRepository = createScriptExecutionRepository(); const scheduleRepository = createScriptScheduleRepository(); return { - // ==================== API Key Management Commands ==================== - - /** - * Create a new admin API key - * Generates a UUID, hashes it with bcrypt, stores in database - * - * @param {Object} params - Key creation parameters - * @param {string} params.name - Human-readable name for the key - * @param {string[]} params.scopes - Permission scopes (e.g., ['scripts:execute']) - * @param {Date} [params.expiresAt] - Optional expiration date - * @param {string} [params.createdBy] - Optional creator identifier - * @returns {Promise} Created key with rawKey (only returned once!) - */ - async createAdminApiKey({ name, scopes, expiresAt, createdBy }) { - try { - // Generate raw key (UUID format) - const rawKey = uuid(); - - // Hash with bcrypt (cost factor 10) - const keyHash = await bcrypt.hash(rawKey, 10); - - // Store last 4 characters for display - const keyLast4 = rawKey.slice(-4); - - // Create via repository - const record = await apiKeyRepository.createApiKey({ - name, - keyHash, - keyLast4, - scopes, - expiresAt, - createdBy, - }); - - // Return record with rawKey (ONLY TIME IT'S RETURNED!) - return { - id: record.id, - rawKey, // User must save this - we never show it again - name: record.name, - keyLast4: record.keyLast4, - scopes: record.scopes, - expiresAt: record.expiresAt, - }; - } catch (error) { - return mapErrorToResponse(error); - } - }, - - /** - * Validate an admin API key - * Compares bcrypt hash, checks expiration, updates lastUsedAt - * - * @param {string} rawKey - The raw API key to validate - * @returns {Promise} { valid: true, apiKey } or error response - */ - async validateAdminApiKey(rawKey) { - try { - // Find all active keys - const activeKeys = await apiKeyRepository.findActiveApiKeys(); - - // Compare bcrypt hash for each key - for (const key of activeKeys) { - const isMatch = await bcrypt.compare(rawKey, key.keyHash); - if (isMatch) { - // Check expiration - if (key.expiresAt && new Date(key.expiresAt) < new Date()) { - const error = new Error('API key has expired'); - error.code = 'EXPIRED_API_KEY'; - return mapErrorToResponse(error); - } - - // Update lastUsedAt on success - await apiKeyRepository.updateApiKeyLastUsed(key.id); - - return { valid: true, apiKey: key }; - } - } - - // No match found - const error = new Error('Invalid API key'); - error.code = 'INVALID_API_KEY'; - return mapErrorToResponse(error); - } catch (error) { - return mapErrorToResponse(error); - } - }, - - /** - * List all active admin API keys - * Returns keys without keyHash (security) - * - * @returns {Promise} Array of API key records (without keyHash) - */ - async listAdminApiKeys() { - try { - const keys = await apiKeyRepository.findActiveApiKeys(); - - // Remove keyHash from response (security) - return keys.map((key) => { - const { keyHash, ...safeKey } = key; - return safeKey; - }); - } catch (error) { - return mapErrorToResponse(error); - } - }, - - /** - * Deactivate an admin API key - * Soft delete - sets isActive to false - * - * @param {string|number} id - The API key ID - * @returns {Promise} Updated record or error - */ - async deactivateAdminApiKey(id) { - try { - const result = await apiKeyRepository.deactivateApiKey(id); - return result; - } catch (error) { - return mapErrorToResponse(error); - } - }, - // ==================== Execution Management Commands ==================== /** diff --git a/packages/core/handlers/middleware/__tests__/admin-auth.test.js b/packages/core/handlers/middleware/__tests__/admin-auth.test.js new file mode 100644 index 000000000..417ba4e41 --- /dev/null +++ b/packages/core/handlers/middleware/__tests__/admin-auth.test.js @@ -0,0 +1,90 @@ +/** + * Admin Auth Middleware Tests + * + * Shared middleware for all admin endpoints (db-migrate, scripts, etc.) + */ + +describe('Admin Auth Middleware', () => { + let validateAdminApiKey; + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + jest.resetModules(); + process.env.ADMIN_API_KEY = 'test-admin-key-12345'; + + validateAdminApiKey = require('../admin-auth').validateAdminApiKey; + + mockReq = { + headers: {} + }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + mockNext = jest.fn(); + }); + + afterEach(() => { + delete process.env.ADMIN_API_KEY; + }); + + describe('validateAdminApiKey', () => { + it('should call next() when valid API key is provided', () => { + mockReq.headers['x-frigg-admin-api-key'] = 'test-admin-key-12345'; + + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + + it('should return 401 when API key header is missing', () => { + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'x-frigg-admin-api-key header required' + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 401 when API key is invalid', () => { + mockReq.headers['x-frigg-admin-api-key'] = 'wrong-key'; + + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'Invalid admin API key' + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 401 when ADMIN_API_KEY env var is not set', () => { + delete process.env.ADMIN_API_KEY; + mockReq.headers['x-frigg-admin-api-key'] = 'any-key'; + + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'Admin API key not configured' + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should return 401 when API key is empty string', () => { + mockReq.headers['x-frigg-admin-api-key'] = ''; + + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/handlers/middleware/admin-auth.js b/packages/core/handlers/middleware/admin-auth.js new file mode 100644 index 000000000..5fb3c44b9 --- /dev/null +++ b/packages/core/handlers/middleware/admin-auth.js @@ -0,0 +1,53 @@ +/** + * Admin Auth Middleware + * + * Shared authentication middleware for all admin endpoints: + * - /admin/db-migrate/* + * - /admin/scripts/* + * + * Uses simple ENV-based API key validation. + * Expects: x-frigg-admin-api-key header + */ + +/** + * Validate admin API key from request header + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function validateAdminApiKey(req, res, next) { + const expectedKey = process.env.ADMIN_API_KEY; + + // Check if admin API key is configured + if (!expectedKey) { + console.error('ADMIN_API_KEY environment variable not configured'); + return res.status(401).json({ + error: 'Unauthorized', + message: 'Admin API key not configured' + }); + } + + const apiKey = req.headers['x-frigg-admin-api-key']; + + // Check if header is present + if (!apiKey) { + console.error('Missing x-frigg-admin-api-key header'); + return res.status(401).json({ + error: 'Unauthorized', + message: 'x-frigg-admin-api-key header required' + }); + } + + // Validate key + if (apiKey !== expectedKey) { + console.error('Invalid admin API key provided'); + return res.status(401).json({ + error: 'Unauthorized', + message: 'Invalid admin API key' + }); + } + + next(); +} + +module.exports = { validateAdminApiKey }; diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 252fa96b3..4ff8ca340 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -362,70 +362,51 @@ model WebsocketConnection { } // ============================================================================ -// ADMIN SCRIPT RUNNER MODELS +// ADMIN PROCESS MODELS // ============================================================================ -enum ScriptExecutionStatus { +/// Admin process state machine +enum AdminProcessState { PENDING RUNNING COMPLETED FAILED - TIMEOUT - CANCELLED } -enum ScriptTrigger { +/// Admin trigger types +enum AdminTrigger { MANUAL SCHEDULED QUEUE WEBHOOK } -/// Admin API keys for script execution authentication -/// Key hashes stored with bcrypt -model AdminApiKey { - id String @id @default(auto()) @map("_id") @db.ObjectId - keyHash String @unique // bcrypt hashed - keyLast4 String // Last 4 chars for display - name String // Human-readable name - scopes String[] // ['scripts:execute', 'scripts:read'] - expiresAt DateTime? - createdBy String? // User/admin who created - lastUsedAt DateTime? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([isActive]) - @@map("AdminApiKey") -} +/// Admin process tracking (like Process but without user/integration FK) +/// Used for: admin scripts, db migrations, system maintenance tasks +model AdminProcess { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String // e.g., "oauth-token-refresh", "db-migration-xyz" + type String // e.g., "ADMIN_SCRIPT", "DB_MIGRATION" -/// Script execution tracking and audit log -model ScriptExecution { - id String @id @default(auto()) @map("_id") @db.ObjectId - scriptName String - scriptVersion String? - status ScriptExecutionStatus @default(PENDING) - trigger ScriptTrigger - mode String @default("async") // "sync" | "async" - input Json? - output Json? - logs Json[] // [{level, message, data, timestamp}] - metricsStartTime DateTime? - metricsEndTime DateTime? - metricsDurationMs Int? - errorName String? - errorMessage String? - errorStack String? - auditApiKeyName String? - auditApiKeyLast4 String? - auditIpAddress String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([scriptName, createdAt(sort: Desc)]) - @@index([status]) - @@map("ScriptExecution") + // State machine + state AdminProcessState @default(PENDING) + + // Flexible storage (mirrors Process model pattern) + context Json @default("{}") // input, trigger, audit info, script version + results Json @default("{}") // output, logs, metrics, errors + + // Hierarchy support + childProcesses String[] @db.ObjectId + parentProcessId String? @db.ObjectId + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name, createdAt(sort: Desc)]) + @@index([state]) + @@index([type]) + @@map("AdminProcess") } /// Script scheduling configuration for hybrid scheduling (SQS + EventBridge) diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index 73f1d3a03..b91ef8c13 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -345,68 +345,51 @@ model WebsocketConnection { } // ============================================================================ -// ADMIN SCRIPT RUNNER MODELS +// ADMIN PROCESS MODELS // ============================================================================ -enum ScriptExecutionStatus { +/// Admin process state machine +enum AdminProcessState { PENDING RUNNING COMPLETED FAILED - TIMEOUT - CANCELLED } -enum ScriptTrigger { +/// Admin trigger types +enum AdminTrigger { MANUAL SCHEDULED QUEUE WEBHOOK } -/// Admin API keys for script execution authentication -/// Key hashes stored with bcrypt -model AdminApiKey { - id Int @id @default(autoincrement()) - keyHash String @unique - keyLast4 String - name String - scopes String[] - expiresAt DateTime? - createdBy String? - lastUsedAt DateTime? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([isActive]) -} +/// Admin process tracking (like Process but without user/integration FK) +/// Used for: admin scripts, db migrations, system maintenance tasks +model AdminProcess { + id Int @id @default(autoincrement()) + name String // e.g., "oauth-token-refresh", "db-migration-xyz" + type String // e.g., "ADMIN_SCRIPT", "DB_MIGRATION" -/// Script execution tracking and audit log -model ScriptExecution { - id Int @id @default(autoincrement()) - scriptName String - scriptVersion String? - status ScriptExecutionStatus @default(PENDING) - trigger ScriptTrigger - mode String @default("async") - input Json? - output Json? - logs Json[] - metricsStartTime DateTime? - metricsEndTime DateTime? - metricsDurationMs Int? - errorName String? - errorMessage String? - errorStack String? - auditApiKeyName String? - auditApiKeyLast4 String? - auditIpAddress String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([scriptName, createdAt(sort: Desc)]) - @@index([status]) + // State machine + state AdminProcessState @default(PENDING) + + // Flexible storage (mirrors Process model pattern) + context Json @default("{}") // input, trigger, audit info, script version + results Json @default("{}") // output, logs, metrics, errors + + // Hierarchy support - self-referential relation + parentProcessId Int? + parentProcess AdminProcess? @relation("AdminProcessHierarchy", fields: [parentProcessId], references: [id], onDelete: SetNull) + childProcesses AdminProcess[] @relation("AdminProcessHierarchy") + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name, createdAt(sort: Desc)]) + @@index([state]) + @@index([type]) } /// Script scheduling configuration for hybrid scheduling (SQS + EventBridge) From 76b133c6a1a25ed3589d65161fd79e5069067255 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 03:40:17 +0000 Subject: [PATCH 24/33] refactor(admin): complete AdminProcess migration and consolidate under /admin Major refactoring based on PR feedback: 1. AdminProcess Repository (replaces ScriptExecution): - New: admin-process-repository-interface.js - New: admin-process-repository-mongo.js - New: admin-process-repository-postgres.js - New: admin-process-repository-documentdb.js - New: admin-process-repository-factory.js - Deleted: All script-execution-repository-* files 2. Updated admin-scripts package: - All files now use AdminProcess methods - Updated: admin-script-base.js, admin-frigg-commands.js, script-runner.js - Updated: admin-script-router.js, script-executor-handler.js - Fixed export: validateAdminApiKey (not adminAuthMiddleware) 3. Moved db-migrate under /admin path: - Routes: /admin/db-migrate/* - Uses shared validateAdminApiKey middleware - Updated use-cases and tests 4. Cleaned up obsolete code: - Removed AdminApiKey tests from admin-script-commands.test.js - Updated all tests for AdminProcess methods All 295 tests passing. --- .claude/skills/frigg/SKILL.md | 10 +- packages/admin-scripts/index.js | 4 +- .../__tests__/admin-frigg-commands.test.js | 36 +- .../__tests__/script-runner.test.js | 24 +- .../src/application/admin-frigg-commands.js | 14 +- .../src/application/admin-script-base.js | 6 +- .../src/application/script-runner.js | 8 +- .../__tests__/admin-auth-middleware.test.js | 127 +--- .../__tests__/admin-script-router.test.js | 49 +- .../infrastructure/admin-auth-middleware.js | 48 +- .../src/infrastructure/admin-script-router.js | 30 +- .../infrastructure/script-executor-handler.js | 4 +- packages/core/admin-scripts/index.js | 22 +- ...admin-process-repository-interface.test.js | 153 ++++ .../admin-process-repository-mongo.test.js | 432 +++++++++++ ...ipt-execution-repository-interface.test.js | 187 ----- .../script-execution-repository-mongo.test.js | 429 ----------- ...=> admin-process-repository-documentdb.js} | 10 +- ...js => admin-process-repository-factory.js} | 30 +- .../admin-process-repository-interface.js | 150 ++++ .../admin-process-repository-mongo.js | 213 ++++++ .../admin-process-repository-postgres.js | 251 +++++++ .../script-execution-repository-interface.js | 166 ---- .../script-execution-repository-mongo.js | 258 ------- .../script-execution-repository-postgres.js | 296 -------- .../__tests__/admin-script-commands.test.js | 709 +++++------------- .../commands/admin-script-commands.js | 116 +-- .../check-database-state-use-case.js | 4 +- .../check-database-state-use-case.test.js | 4 +- ...database-state-via-worker-use-case.test.js | 4 +- .../trigger-database-migration-use-case.js | 2 +- ...rigger-database-migration-use-case.test.js | 2 +- .../handlers/routers/db-migration.handler.js | 2 +- .../core/handlers/routers/db-migration.js | 51 +- .../handlers/routers/db-migration.test.js | 4 +- 35 files changed, 1645 insertions(+), 2210 deletions(-) create mode 100644 packages/core/admin-scripts/repositories/__tests__/admin-process-repository-interface.test.js create mode 100644 packages/core/admin-scripts/repositories/__tests__/admin-process-repository-mongo.test.js delete mode 100644 packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js delete mode 100644 packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js rename packages/core/admin-scripts/repositories/{script-execution-repository-documentdb.js => admin-process-repository-documentdb.js} (56%) rename packages/core/admin-scripts/repositories/{script-execution-repository-factory.js => admin-process-repository-factory.js} (50%) create mode 100644 packages/core/admin-scripts/repositories/admin-process-repository-interface.js create mode 100644 packages/core/admin-scripts/repositories/admin-process-repository-mongo.js create mode 100644 packages/core/admin-scripts/repositories/admin-process-repository-postgres.js delete mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-interface.js delete mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-mongo.js delete mode 100644 packages/core/admin-scripts/repositories/script-execution-repository-postgres.js diff --git a/.claude/skills/frigg/SKILL.md b/.claude/skills/frigg/SKILL.md index 9f469a311..2448a3af6 100644 --- a/.claude/skills/frigg/SKILL.md +++ b/.claude/skills/frigg/SKILL.md @@ -1004,7 +1004,7 @@ Response (200): { **Trigger Database Migration** ```bash -POST /db-migrate +POST /admin/db-migrate x-frigg-admin-api-key: ${ADMIN_API_KEY} Content-Type: application/json @@ -1018,7 +1018,7 @@ Response (202): { "success": true, "processId": "mig-1642512000-abc123", "state": "INITIALIZING", - "statusUrl": "/db-migrate/mig-1642512000-abc123", + "statusUrl": "/admin/db-migrate/mig-1642512000-abc123", "message": "Migration job queued successfully" } ``` @@ -1026,7 +1026,7 @@ Response (202): { **Check Migration Status** ```bash -GET /db-migrate/status?stage=production +GET /admin/db-migrate/status?stage=production x-frigg-admin-api-key: ${ADMIN_API_KEY} Response (200): { @@ -1042,14 +1042,14 @@ Response (200): { "pendingMigrations": 3, "dbType": "postgresql", "stage": "production", - "recommendation": "Run POST /db-migrate to apply pending migrations" + "recommendation": "Run POST /admin/db-migrate to apply pending migrations" } ``` **Get Migration Details** ```bash -GET /db-migrate/${MIGRATION_ID}?stage=production +GET /admin/db-migrate/${MIGRATION_ID}?stage=production x-frigg-admin-api-key: ${ADMIN_API_KEY} Response (200): { diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index 9589f8e20..e9884d14f 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -12,7 +12,7 @@ const { AdminFriggCommands, createAdminFriggCommands } = require('./src/applicat const { ScriptRunner, createScriptRunner } = require('./src/application/script-runner'); // Infrastructure -const { adminAuthMiddleware } = require('./src/infrastructure/admin-auth-middleware'); +const { validateAdminApiKey } = require('./src/infrastructure/admin-auth-middleware'); const { router, app, handler: routerHandler } = require('./src/infrastructure/admin-script-router'); const { handler: executorHandler } = require('./src/infrastructure/script-executor-handler'); @@ -45,7 +45,7 @@ module.exports = { createScriptRunner, // Infrastructure layer - adminAuthMiddleware, + validateAdminApiKey, router, app, routerHandler, diff --git a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js index c8966fadb..70dea5c36 100644 --- a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js +++ b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js @@ -5,7 +5,7 @@ jest.mock('@friggframework/core/integrations/repositories/integration-repository jest.mock('@friggframework/core/user/repositories/user-repository-factory'); jest.mock('@friggframework/core/modules/repositories/module-repository-factory'); jest.mock('@friggframework/core/credential/repositories/credential-repository-factory'); -jest.mock('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); +jest.mock('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); jest.mock('@friggframework/core/queues'); describe('AdminFriggCommands', () => { @@ -13,7 +13,7 @@ describe('AdminFriggCommands', () => { let mockUserRepo; let mockModuleRepo; let mockCredentialRepo; - let mockScriptExecutionRepo; + let mockAdminProcessRepo; let mockQueuerUtil; beforeEach(() => { @@ -46,8 +46,8 @@ describe('AdminFriggCommands', () => { updateCredential: jest.fn(), }; - mockScriptExecutionRepo = { - appendExecutionLog: jest.fn().mockResolvedValue(undefined), + mockAdminProcessRepo = { + appendProcessLog: jest.fn().mockResolvedValue(undefined), }; mockQueuerUtil = { @@ -60,14 +60,14 @@ describe('AdminFriggCommands', () => { const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); - const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); + const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); const { QueuerUtil } = require('@friggframework/core/queues'); createIntegrationRepository.mockReturnValue(mockIntegrationRepo); createUserRepository.mockReturnValue(mockUserRepo); createModuleRepository.mockReturnValue(mockModuleRepo); createCredentialRepository.mockReturnValue(mockCredentialRepo); - createScriptExecutionRepository.mockReturnValue(mockScriptExecutionRepo); + createAdminProcessRepository.mockReturnValue(mockAdminProcessRepo); // Mock QueuerUtil methods QueuerUtil.send = mockQueuerUtil.send; @@ -158,16 +158,16 @@ describe('AdminFriggCommands', () => { expect(repo).toBe(mockCredentialRepo); }); - it('creates scriptExecutionRepository on first access', () => { + it('creates adminProcessRepository on first access', () => { const commands = new AdminFriggCommands(); - const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); + const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); - expect(createScriptExecutionRepository).not.toHaveBeenCalled(); + expect(createAdminProcessRepository).not.toHaveBeenCalled(); - const repo = commands.scriptExecutionRepository; + const repo = commands.adminProcessRepository; - expect(createScriptExecutionRepository).toHaveBeenCalledTimes(1); - expect(repo).toBe(mockScriptExecutionRepo); + expect(createAdminProcessRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockAdminProcessRepo); }); }); @@ -551,15 +551,15 @@ describe('AdminFriggCommands', () => { it('log() persists if executionId set', async () => { const commands = new AdminFriggCommands({ executionId: 'exec_123' }); // Force repository creation - commands.scriptExecutionRepository; + commands.adminProcessRepository; commands.log('warn', 'Warning message', { detail: 'xyz' }); // Give async operation a chance to execute await new Promise(resolve => setImmediate(resolve)); - expect(mockScriptExecutionRepo.appendExecutionLog).toHaveBeenCalled(); - const callArgs = mockScriptExecutionRepo.appendExecutionLog.mock.calls[0]; + expect(mockAdminProcessRepo.appendProcessLog).toHaveBeenCalled(); + const callArgs = mockAdminProcessRepo.appendProcessLog.mock.calls[0]; expect(callArgs[0]).toBe('exec_123'); expect(callArgs[1].level).toBe('warn'); expect(callArgs[1].message).toBe('Warning message'); @@ -572,14 +572,14 @@ describe('AdminFriggCommands', () => { await new Promise(resolve => setImmediate(resolve)); - expect(mockScriptExecutionRepo.appendExecutionLog).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.appendProcessLog).not.toHaveBeenCalled(); }); it('log() handles persistence failure gracefully', async () => { const commands = new AdminFriggCommands({ executionId: 'exec_123' }); // Force repository creation - commands.scriptExecutionRepository; - mockScriptExecutionRepo.appendExecutionLog.mockRejectedValue(new Error('DB Error')); + commands.adminProcessRepository; + mockAdminProcessRepo.appendProcessLog.mockRejectedValue(new Error('DB Error')); // Should not throw expect(() => commands.log('error', 'Test error')).not.toThrow(); diff --git a/packages/admin-scripts/src/application/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js index 7cf30abe1..41a38dbb5 100644 --- a/packages/admin-scripts/src/application/__tests__/script-runner.test.js +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -36,9 +36,9 @@ describe('ScriptRunner', () => { scriptFactory = new ScriptFactory([TestScript]); mockCommands = { - createScriptExecution: jest.fn(), - updateScriptExecutionStatus: jest.fn(), - completeScriptExecution: jest.fn(), + createAdminProcess: jest.fn(), + updateAdminProcessState: jest.fn(), + completeAdminProcess: jest.fn(), }; mockFrigg = { @@ -49,11 +49,11 @@ describe('ScriptRunner', () => { createAdminScriptCommands.mockReturnValue(mockCommands); createAdminFriggCommands.mockReturnValue(mockFrigg); - mockCommands.createScriptExecution.mockResolvedValue({ + mockCommands.createAdminProcess.mockResolvedValue({ id: 'exec-123', }); - mockCommands.updateScriptExecutionStatus.mockResolvedValue({}); - mockCommands.completeScriptExecution.mockResolvedValue({ success: true }); + mockCommands.updateAdminProcessState.mockResolvedValue({}); + mockCommands.completeAdminProcess.mockResolvedValue({ success: true }); }); afterEach(() => { @@ -76,7 +76,7 @@ describe('ScriptRunner', () => { expect(result.executionId).toBe('exec-123'); expect(result.metrics.durationMs).toBeGreaterThanOrEqual(0); - expect(mockCommands.createScriptExecution).toHaveBeenCalledWith({ + expect(mockCommands.createAdminProcess).toHaveBeenCalledWith({ scriptName: 'test-script', scriptVersion: '1.0.0', trigger: 'MANUAL', @@ -85,12 +85,12 @@ describe('ScriptRunner', () => { audit: { apiKeyName: 'test-key' }, }); - expect(mockCommands.updateScriptExecutionStatus).toHaveBeenCalledWith( + expect(mockCommands.updateAdminProcessState).toHaveBeenCalledWith( 'exec-123', 'RUNNING' ); - expect(mockCommands.completeScriptExecution).toHaveBeenCalledWith( + expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith( 'exec-123', expect.objectContaining({ status: 'COMPLETED', @@ -128,7 +128,7 @@ describe('ScriptRunner', () => { expect(result.scriptName).toBe('failing-script'); expect(result.error.message).toBe('Script failed'); - expect(mockCommands.completeScriptExecution).toHaveBeenCalledWith( + expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith( 'exec-123', expect.objectContaining({ status: 'FAILED', @@ -178,8 +178,8 @@ describe('ScriptRunner', () => { }); expect(result.executionId).toBe('existing-exec-456'); - expect(mockCommands.createScriptExecution).not.toHaveBeenCalled(); - expect(mockCommands.updateScriptExecutionStatus).toHaveBeenCalledWith( + expect(mockCommands.createAdminProcess).not.toHaveBeenCalled(); + expect(mockCommands.updateAdminProcessState).toHaveBeenCalledWith( 'existing-exec-456', 'RUNNING' ); diff --git a/packages/admin-scripts/src/application/admin-frigg-commands.js b/packages/admin-scripts/src/application/admin-frigg-commands.js index df71f57c3..e22a5024d 100644 --- a/packages/admin-scripts/src/application/admin-frigg-commands.js +++ b/packages/admin-scripts/src/application/admin-frigg-commands.js @@ -25,7 +25,7 @@ class AdminFriggCommands { this._userRepository = null; this._moduleRepository = null; this._credentialRepository = null; - this._scriptExecutionRepository = null; + this._adminProcessRepository = null; } // ==================== LAZY-LOADED REPOSITORIES ==================== @@ -62,12 +62,12 @@ class AdminFriggCommands { return this._credentialRepository; } - get scriptExecutionRepository() { - if (!this._scriptExecutionRepository) { - const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); - this._scriptExecutionRepository = createScriptExecutionRepository(); + get adminProcessRepository() { + if (!this._adminProcessRepository) { + const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); + this._adminProcessRepository = createAdminProcessRepository(); } - return this._scriptExecutionRepository; + return this._adminProcessRepository; } // ==================== INTEGRATION QUERIES ==================== @@ -209,7 +209,7 @@ class AdminFriggCommands { // Persist to execution record if we have an executionId if (this.executionId) { - this.scriptExecutionRepository.appendExecutionLog(this.executionId, entry) + this.adminProcessRepository.appendProcessLog(this.executionId, entry) .catch(err => console.error('Failed to persist log:', err)); } diff --git a/packages/admin-scripts/src/application/admin-script-base.js b/packages/admin-scripts/src/application/admin-script-base.js index 93ead1af1..d7b228779 100644 --- a/packages/admin-scripts/src/application/admin-script-base.js +++ b/packages/admin-scripts/src/application/admin-script-base.js @@ -1,5 +1,4 @@ -const { createScriptExecutionRepository } = require('@friggframework/core/admin-scripts/repositories/script-execution-repository-factory'); -const { createAdminApiKeyRepository } = require('@friggframework/core/admin-scripts/repositories/admin-api-key-repository-factory'); +const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); /** * Admin Script Base Class @@ -87,8 +86,7 @@ class AdminScriptBase { this.integrationFactory = params.integrationFactory || null; // OPTIONAL: Injected repositories (for testing or custom implementations) - this.scriptExecutionRepository = params.scriptExecutionRepository || null; - this.adminApiKeyRepository = params.adminApiKeyRepository || null; + this.adminProcessRepository = params.adminProcessRepository || null; } /** diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js index 83dfa9e92..6eb46c7aa 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -50,7 +50,7 @@ class ScriptRunner { // Create execution record if not provided if (!executionId) { - const execution = await this.commands.createScriptExecution({ + const execution = await this.commands.createAdminProcess({ scriptName, scriptVersion: definition.version, trigger, @@ -66,7 +66,7 @@ class ScriptRunner { try { // Update status to RUNNING (skip in dry-run) if (!dryRun) { - await this.commands.updateScriptExecutionStatus(executionId, 'RUNNING'); + await this.commands.updateAdminProcessState(executionId, 'RUNNING'); } // Create frigg commands for the script @@ -99,7 +99,7 @@ class ScriptRunner { // Complete execution (skip in dry-run) if (!dryRun) { - await this.commands.completeScriptExecution(executionId, { + await this.commands.completeAdminProcess(executionId, { status: 'COMPLETED', output, metrics: { @@ -140,7 +140,7 @@ class ScriptRunner { // Record failure (skip in dry-run) if (!dryRun) { - await this.commands.completeScriptExecution(executionId, { + await this.commands.completeAdminProcess(executionId, { status: 'FAILED', error: { name: error.name, diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js index 7ba814396..ed551332f 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js @@ -1,22 +1,17 @@ -const { adminAuthMiddleware } = require('../admin-auth-middleware'); +const { validateAdminApiKey } = require('../admin-auth-middleware'); -// Mock the admin script commands -jest.mock('@friggframework/core/application/commands/admin-script-commands', () => ({ - createAdminScriptCommands: jest.fn(), -})); - -const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); - -describe('adminAuthMiddleware', () => { +describe('validateAdminApiKey', () => { let mockReq; let mockRes; let mockNext; - let mockCommands; + let originalEnv; beforeEach(() => { + originalEnv = process.env.ADMIN_API_KEY; + process.env.ADMIN_API_KEY = 'test-admin-key-123'; + mockReq = { headers: {}, - ip: '127.0.0.1', }; mockRes = { @@ -25,124 +20,66 @@ describe('adminAuthMiddleware', () => { }; mockNext = jest.fn(); - - mockCommands = { - validateAdminApiKey: jest.fn(), - }; - - createAdminScriptCommands.mockReturnValue(mockCommands); }); afterEach(() => { + if (originalEnv) { + process.env.ADMIN_API_KEY = originalEnv; + } else { + delete process.env.ADMIN_API_KEY; + } jest.clearAllMocks(); }); - describe('Authorization header validation', () => { - it('should reject request without Authorization header', async () => { - await adminAuthMiddleware(mockReq, mockRes, mockNext); - - expect(mockRes.status).toHaveBeenCalledWith(401); - expect(mockRes.json).toHaveBeenCalledWith({ - error: 'Missing or invalid Authorization header', - code: 'MISSING_AUTH', - }); - expect(mockNext).not.toHaveBeenCalled(); - }); - - it('should reject request with invalid Authorization format', async () => { - mockReq.headers.authorization = 'InvalidFormat key123'; + describe('Environment configuration', () => { + it('should reject when ADMIN_API_KEY not configured', () => { + delete process.env.ADMIN_API_KEY; - await adminAuthMiddleware(mockReq, mockRes, mockNext); + validateAdminApiKey(mockReq, mockRes, mockNext); expect(mockRes.status).toHaveBeenCalledWith(401); expect(mockRes.json).toHaveBeenCalledWith({ - error: 'Missing or invalid Authorization header', - code: 'MISSING_AUTH', + error: 'Unauthorized', + message: 'Admin API key not configured', }); expect(mockNext).not.toHaveBeenCalled(); }); }); - describe('API key validation', () => { - it('should reject request with invalid API key', async () => { - mockReq.headers.authorization = 'Bearer invalid-key'; - mockCommands.validateAdminApiKey.mockResolvedValue({ - error: 401, - reason: 'Invalid API key', - code: 'INVALID_API_KEY', - }); - - await adminAuthMiddleware(mockReq, mockRes, mockNext); + describe('Header validation', () => { + it('should reject request without x-frigg-admin-api-key header', () => { + validateAdminApiKey(mockReq, mockRes, mockNext); - expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('invalid-key'); expect(mockRes.status).toHaveBeenCalledWith(401); expect(mockRes.json).toHaveBeenCalledWith({ - error: 'Invalid API key', - code: 'INVALID_API_KEY', + error: 'Unauthorized', + message: 'x-frigg-admin-api-key header required', }); expect(mockNext).not.toHaveBeenCalled(); }); + }); - it('should reject request with expired API key', async () => { - mockReq.headers.authorization = 'Bearer expired-key'; - mockCommands.validateAdminApiKey.mockResolvedValue({ - error: 401, - reason: 'API key has expired', - code: 'EXPIRED_API_KEY', - }); + describe('API key validation', () => { + it('should reject request with invalid API key', () => { + mockReq.headers['x-frigg-admin-api-key'] = 'invalid-key'; - await adminAuthMiddleware(mockReq, mockRes, mockNext); + validateAdminApiKey(mockReq, mockRes, mockNext); - expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith('expired-key'); expect(mockRes.status).toHaveBeenCalledWith(401); expect(mockRes.json).toHaveBeenCalledWith({ - error: 'API key has expired', - code: 'EXPIRED_API_KEY', + error: 'Unauthorized', + message: 'Invalid admin API key', }); expect(mockNext).not.toHaveBeenCalled(); }); - it('should accept request with valid API key', async () => { - const validKey = 'valid-api-key-123'; - mockReq.headers.authorization = `Bearer ${validKey}`; - mockCommands.validateAdminApiKey.mockResolvedValue({ - valid: true, - apiKey: { - id: 'key-id-1', - name: 'test-key', - keyLast4: 'e123', - }, - }); + it('should accept request with valid API key', () => { + mockReq.headers['x-frigg-admin-api-key'] = 'test-admin-key-123'; - await adminAuthMiddleware(mockReq, mockRes, mockNext); + validateAdminApiKey(mockReq, mockRes, mockNext); - expect(mockCommands.validateAdminApiKey).toHaveBeenCalledWith(validKey); - expect(mockReq.adminApiKey).toBeDefined(); - expect(mockReq.adminApiKey.name).toBe('test-key'); - expect(mockReq.adminAudit).toBeDefined(); - expect(mockReq.adminAudit.apiKeyName).toBe('test-key'); - expect(mockReq.adminAudit.apiKeyLast4).toBe('e123'); - expect(mockReq.adminAudit.ipAddress).toBe('127.0.0.1'); expect(mockNext).toHaveBeenCalled(); expect(mockRes.status).not.toHaveBeenCalled(); }); }); - - describe('Error handling', () => { - it('should handle validation errors gracefully', async () => { - mockReq.headers.authorization = 'Bearer some-key'; - mockCommands.validateAdminApiKey.mockRejectedValue( - new Error('Database error') - ); - - await adminAuthMiddleware(mockReq, mockRes, mockNext); - - expect(mockRes.status).toHaveBeenCalledWith(500); - expect(mockRes.json).toHaveBeenCalledWith({ - error: 'Authentication failed', - code: 'AUTH_ERROR', - }); - expect(mockNext).not.toHaveBeenCalled(); - }); - }); }); diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js index 5f802475c..4c99f42d4 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -4,13 +4,8 @@ const { AdminScriptBase } = require('../../application/admin-script-base'); // Mock dependencies jest.mock('../admin-auth-middleware', () => ({ - adminAuthMiddleware: (req, res, next) => { - // Mock auth - attach admin audit info - req.adminAudit = { - apiKeyName: 'test-key', - apiKeyLast4: '1234', - ipAddress: '127.0.0.1', - }; + validateAdminApiKey: (req, res, next) => { + // Mock auth - no audit trail with simplified auth next(); }, })); @@ -59,8 +54,8 @@ describe('Admin Script Router', () => { }; mockCommands = { - createScriptExecution: jest.fn(), - findScriptExecutionById: jest.fn(), + createAdminProcess: jest.fn(), + findAdminProcessById: jest.fn(), findRecentExecutions: jest.fn(), }; @@ -143,7 +138,7 @@ describe('Admin Script Router', () => { }); }); - describe('POST /admin/scripts/:scriptName/execute', () => { + describe('POST /admin/scripts/:scriptName', () => { it('should execute script synchronously', async () => { mockRunner.execute.mockResolvedValue({ executionId: 'exec-123', @@ -154,7 +149,7 @@ describe('Admin Script Router', () => { }); const response = await request(app) - .post('/admin/scripts/test-script/execute') + .post('/admin/scripts/test-script') .send({ params: { foo: 'bar' }, mode: 'sync', @@ -174,12 +169,12 @@ describe('Admin Script Router', () => { }); it('should queue script for async execution', async () => { - mockCommands.createScriptExecution.mockResolvedValue({ + mockCommands.createAdminProcess.mockResolvedValue({ id: 'exec-456', }); const response = await request(app) - .post('/admin/scripts/test-script/execute') + .post('/admin/scripts/test-script') .send({ params: { foo: 'bar' }, mode: 'async', @@ -198,12 +193,12 @@ describe('Admin Script Router', () => { }); it('should default to async mode', async () => { - mockCommands.createScriptExecution.mockResolvedValue({ + mockCommands.createAdminProcess.mockResolvedValue({ id: 'exec-789', }); const response = await request(app) - .post('/admin/scripts/test-script/execute') + .post('/admin/scripts/test-script') .send({ params: { foo: 'bar' }, }); @@ -216,7 +211,7 @@ describe('Admin Script Router', () => { mockFactory.has.mockReturnValue(false); const response = await request(app) - .post('/admin/scripts/non-existent/execute') + .post('/admin/scripts/non-existent') .send({ params: {}, }); @@ -226,15 +221,15 @@ describe('Admin Script Router', () => { }); }); - describe('GET /admin/executions/:executionId', () => { + describe('GET /admin/scripts/:scriptName/executions/:executionId', () => { it('should return execution details', async () => { - mockCommands.findScriptExecutionById.mockResolvedValue({ + mockCommands.findAdminProcessById.mockResolvedValue({ id: 'exec-123', scriptName: 'test-script', status: 'COMPLETED', }); - const response = await request(app).get('/admin/executions/exec-123'); + const response = await request(app).get('/admin/scripts/test-script/executions/exec-123'); expect(response.status).toBe(200); expect(response.body.id).toBe('exec-123'); @@ -242,14 +237,14 @@ describe('Admin Script Router', () => { }); it('should return 404 for non-existent execution', async () => { - mockCommands.findScriptExecutionById.mockResolvedValue({ + mockCommands.findAdminProcessById.mockResolvedValue({ error: 404, reason: 'Execution not found', code: 'EXECUTION_NOT_FOUND', }); const response = await request(app).get( - '/admin/executions/non-existent' + '/admin/scripts/test-script/executions/non-existent' ); expect(response.status).toBe(404); @@ -257,24 +252,28 @@ describe('Admin Script Router', () => { }); }); - describe('GET /admin/executions', () => { - it('should list recent executions', async () => { + describe('GET /admin/scripts/:scriptName/executions', () => { + it('should list executions for specific script', async () => { mockCommands.findRecentExecutions.mockResolvedValue([ { id: 'exec-1', scriptName: 'test-script', status: 'COMPLETED' }, { id: 'exec-2', scriptName: 'test-script', status: 'RUNNING' }, ]); - const response = await request(app).get('/admin/executions'); + const response = await request(app).get('/admin/scripts/test-script/executions'); expect(response.status).toBe(200); expect(response.body.executions).toHaveLength(2); + expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({ + scriptName: 'test-script', + limit: 50, + }); }); it('should accept query parameters', async () => { mockCommands.findRecentExecutions.mockResolvedValue([]); await request(app).get( - '/admin/executions?scriptName=test-script&status=COMPLETED&limit=10' + '/admin/scripts/test-script/executions?status=COMPLETED&limit=10' ); expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({ diff --git a/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js b/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js index cf8080bf2..e3a1501a3 100644 --- a/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js +++ b/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js @@ -1,49 +1,11 @@ -const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); - /** * Admin API Key Authentication Middleware * - * Validates admin API keys for script endpoints. - * Expects: Authorization: Bearer + * Re-exports shared admin auth middleware from @friggframework/core. + * Uses simple ENV-based API key validation. + * Expects: x-frigg-admin-api-key header */ -async function adminAuthMiddleware(req, res, next) { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - error: 'Missing or invalid Authorization header', - code: 'MISSING_AUTH' - }); - } - - const apiKey = authHeader.substring(7); // Remove 'Bearer ' - const commands = createAdminScriptCommands(); - const result = await commands.validateAdminApiKey(apiKey); - - if (result.error) { - return res.status(result.error).json({ - error: result.reason, - code: result.code - }); - } - - // Attach validated key info to request for audit trail - req.adminApiKey = result.apiKey; - req.adminAudit = { - apiKeyName: result.apiKey.name, - apiKeyLast4: result.apiKey.keyLast4, - ipAddress: req.ip || req.connection?.remoteAddress || 'unknown' - }; - next(); - } catch (error) { - console.error('Admin auth middleware error:', error); - res.status(500).json({ - error: 'Authentication failed', - code: 'AUTH_ERROR' - }); - } -} +const { validateAdminApiKey } = require('@friggframework/core/handlers/middleware/admin-auth'); -module.exports = { adminAuthMiddleware }; +module.exports = { validateAdminApiKey }; diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index 05d70521a..c8a50d69a 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -1,6 +1,6 @@ const express = require('express'); const serverless = require('serverless-http'); -const { adminAuthMiddleware } = require('./admin-auth-middleware'); +const { validateAdminApiKey } = require('./admin-auth-middleware'); const { getScriptFactory } = require('../application/script-factory'); const { createScriptRunner } = require('../application/script-runner'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); @@ -11,7 +11,7 @@ const { ScheduleManagementUseCase } = require('../application/schedule-managemen const router = express.Router(); // Apply auth middleware to all admin routes -router.use(adminAuthMiddleware); +router.use(validateAdminApiKey); /** * Create ScheduleManagementUseCase instance @@ -87,10 +87,10 @@ router.get('/scripts/:scriptName', async (req, res) => { }); /** - * POST /admin/scripts/:scriptName/execute + * POST /admin/scripts/:scriptName * Execute a script (sync, async, or dry-run) */ -router.post('/scripts/:scriptName/execute', async (req, res) => { +router.post('/scripts/:scriptName', async (req, res) => { try { const { scriptName } = req.params; const { params = {}, mode = 'async', dryRun = false } = req.body; @@ -110,7 +110,6 @@ router.post('/scripts/:scriptName/execute', async (req, res) => { trigger: 'MANUAL', mode: 'sync', dryRun: true, - audit: req.adminAudit, }); return res.json(result); } @@ -121,20 +120,18 @@ router.post('/scripts/:scriptName/execute', async (req, res) => { const result = await runner.execute(scriptName, params, { trigger: 'MANUAL', mode: 'sync', - audit: req.adminAudit, }); return res.json(result); } // Async execution - queue and return immediately const commands = createAdminScriptCommands(); - const execution = await commands.createScriptExecution({ + const execution = await commands.createAdminProcess({ scriptName, scriptVersion: factory.get(scriptName).Definition.version, trigger: 'MANUAL', mode: 'async', input: params, - audit: req.adminAudit, }); // Queue the execution @@ -161,14 +158,14 @@ router.post('/scripts/:scriptName/execute', async (req, res) => { }); /** - * GET /admin/executions/:executionId - * Get execution status + * GET /admin/scripts/:scriptName/executions/:executionId + * Get execution status for specific script */ -router.get('/executions/:executionId', async (req, res) => { +router.get('/scripts/:scriptName/executions/:executionId', async (req, res) => { try { const { executionId } = req.params; const commands = createAdminScriptCommands(); - const execution = await commands.findScriptExecutionById(executionId); + const execution = await commands.findAdminProcessById(executionId); if (execution.error) { return res.status(execution.error).json({ @@ -185,12 +182,13 @@ router.get('/executions/:executionId', async (req, res) => { }); /** - * GET /admin/executions - * List recent executions + * GET /admin/scripts/:scriptName/executions + * List recent executions for specific script */ -router.get('/executions', async (req, res) => { +router.get('/scripts/:scriptName/executions', async (req, res) => { try { - const { scriptName, status, limit = 50 } = req.query; + const { scriptName } = req.params; + const { status, limit = 50 } = req.query; const commands = createAdminScriptCommands(); const executions = await commands.findRecentExecutions({ diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js index 41778571d..e9effd4a4 100644 --- a/packages/admin-scripts/src/infrastructure/script-executor-handler.js +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -21,7 +21,7 @@ async function handler(event) { // If executionId provided (async from API), update existing record if (executionId) { - await commands.updateScriptExecutionStatus(executionId, 'RUNNING'); + await commands.updateAdminProcessState(executionId, 'RUNNING'); } const result = await runner.execute(scriptName, params, { @@ -45,7 +45,7 @@ async function handler(event) { if (executionId) { const commands = createAdminScriptCommands(); await commands - .completeScriptExecution(executionId, { + .completeAdminProcess(executionId, { status: 'FAILED', error: { name: error.name, diff --git a/packages/core/admin-scripts/index.js b/packages/core/admin-scripts/index.js index cb5aae0ad..7da475971 100644 --- a/packages/core/admin-scripts/index.js +++ b/packages/core/admin-scripts/index.js @@ -16,16 +16,16 @@ */ // Repository Interfaces -const { ScriptExecutionRepositoryInterface } = require('./repositories/script-execution-repository-interface'); +const { AdminProcessRepositoryInterface } = require('./repositories/admin-process-repository-interface'); const { ScriptScheduleRepositoryInterface } = require('./repositories/script-schedule-repository-interface'); // Repository Factories const { - createScriptExecutionRepository, - ScriptExecutionRepositoryMongo, - ScriptExecutionRepositoryPostgres, - ScriptExecutionRepositoryDocumentDB, -} = require('./repositories/script-execution-repository-factory'); + createAdminProcessRepository, + AdminProcessRepositoryMongo, + AdminProcessRepositoryPostgres, + AdminProcessRepositoryDocumentDB, +} = require('./repositories/admin-process-repository-factory'); const { createScriptScheduleRepository, ScriptScheduleRepositoryMongo, @@ -35,17 +35,17 @@ const { module.exports = { // Repository Interfaces - ScriptExecutionRepositoryInterface, + AdminProcessRepositoryInterface, ScriptScheduleRepositoryInterface, // Repository Factories (primary exports for use cases) - createScriptExecutionRepository, + createAdminProcessRepository, createScriptScheduleRepository, // Concrete Implementations (for testing) - ScriptExecutionRepositoryMongo, - ScriptExecutionRepositoryPostgres, - ScriptExecutionRepositoryDocumentDB, + AdminProcessRepositoryMongo, + AdminProcessRepositoryPostgres, + AdminProcessRepositoryDocumentDB, ScriptScheduleRepositoryMongo, ScriptScheduleRepositoryPostgres, ScriptScheduleRepositoryDocumentDB, diff --git a/packages/core/admin-scripts/repositories/__tests__/admin-process-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/admin-process-repository-interface.test.js new file mode 100644 index 000000000..00cbecb60 --- /dev/null +++ b/packages/core/admin-scripts/repositories/__tests__/admin-process-repository-interface.test.js @@ -0,0 +1,153 @@ +const { AdminProcessRepositoryInterface } = require('../admin-process-repository-interface'); + +describe('AdminProcessRepositoryInterface', () => { + let repository; + + beforeEach(() => { + repository = new AdminProcessRepositoryInterface(); + }); + + describe('Interface contract', () => { + it('should throw error when createProcess is not implemented', async () => { + await expect( + repository.createProcess({ + name: 'test-script', + type: 'ADMIN_SCRIPT', + context: { + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param1: 'value1' }, + audit: { + apiKeyName: 'test-key', + apiKeyLast4: '1234', + ipAddress: '192.168.1.1', + }, + }, + }) + ).rejects.toThrow('Method createProcess must be implemented by subclass'); + }); + + it('should throw error when findProcessById is not implemented', async () => { + await expect( + repository.findProcessById('proc123') + ).rejects.toThrow('Method findProcessById must be implemented by subclass'); + }); + + it('should throw error when findProcessesByName is not implemented', async () => { + await expect( + repository.findProcessesByName('test-script', { limit: 10 }) + ).rejects.toThrow('Method findProcessesByName must be implemented by subclass'); + }); + + it('should throw error when findProcessesByState is not implemented', async () => { + await expect( + repository.findProcessesByState('PENDING', { limit: 10 }) + ).rejects.toThrow('Method findProcessesByState must be implemented by subclass'); + }); + + it('should throw error when updateProcessState is not implemented', async () => { + await expect( + repository.updateProcessState('proc123', 'RUNNING') + ).rejects.toThrow('Method updateProcessState must be implemented by subclass'); + }); + + it('should throw error when updateProcessResults is not implemented', async () => { + await expect( + repository.updateProcessResults('proc123', { output: { result: 'success' } }) + ).rejects.toThrow('Method updateProcessResults must be implemented by subclass'); + }); + + it('should throw error when appendProcessLog is not implemented', async () => { + await expect( + repository.appendProcessLog('proc123', { + level: 'info', + message: 'Log message', + data: {}, + timestamp: new Date().toISOString(), + }) + ).rejects.toThrow('Method appendProcessLog must be implemented by subclass'); + }); + + it('should throw error when deleteProcessesOlderThan is not implemented', async () => { + await expect( + repository.deleteProcessesOlderThan(new Date('2024-01-01')) + ).rejects.toThrow('Method deleteProcessesOlderThan must be implemented by subclass'); + }); + }); + + describe('Method signatures', () => { + it('should accept all required parameters in createProcess', async () => { + const params = { + name: 'test-script', + type: 'ADMIN_SCRIPT', + context: { + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param1: 'value1' }, + audit: { + apiKeyName: 'test-key', + apiKeyLast4: '1234', + ipAddress: '192.168.1.1', + }, + }, + }; + + await expect(repository.createProcess(params)).rejects.toThrow(); + }); + + it('should accept string parameter in findProcessById', async () => { + await expect( + repository.findProcessById('some-id') + ).rejects.toThrow(); + }); + + it('should accept name and options in findProcessesByName', async () => { + await expect( + repository.findProcessesByName('test-script', { + limit: 10, + offset: 0, + }) + ).rejects.toThrow(); + }); + + it('should accept state and options in findProcessesByState', async () => { + await expect( + repository.findProcessesByState('PENDING', { + limit: 10, + offset: 0, + }) + ).rejects.toThrow(); + }); + + it('should accept id and state in updateProcessState', async () => { + await expect( + repository.updateProcessState('proc123', 'COMPLETED') + ).rejects.toThrow(); + }); + + it('should accept id and results in updateProcessResults', async () => { + await expect( + repository.updateProcessResults('proc123', { output: { result: 'success' } }) + ).rejects.toThrow(); + }); + + it('should accept id and logEntry in appendProcessLog', async () => { + await expect( + repository.appendProcessLog('proc123', { + level: 'info', + message: 'Test log', + data: { key: 'value' }, + timestamp: new Date().toISOString(), + }) + ).rejects.toThrow(); + }); + + it('should accept Date parameter in deleteProcessesOlderThan', async () => { + await expect( + repository.deleteProcessesOlderThan(new Date('2024-01-01')) + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/core/admin-scripts/repositories/__tests__/admin-process-repository-mongo.test.js b/packages/core/admin-scripts/repositories/__tests__/admin-process-repository-mongo.test.js new file mode 100644 index 000000000..848af3cf5 --- /dev/null +++ b/packages/core/admin-scripts/repositories/__tests__/admin-process-repository-mongo.test.js @@ -0,0 +1,432 @@ +const { AdminProcessRepositoryMongo } = require('../admin-process-repository-mongo'); + +describe('AdminProcessRepositoryMongo', () => { + let repository; + let mockPrisma; + + beforeEach(() => { + mockPrisma = { + adminProcess: { + create: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + deleteMany: jest.fn(), + }, + }; + + repository = new AdminProcessRepositoryMongo(); + repository.prisma = mockPrisma; + }); + + describe('createProcess()', () => { + it('should create process with all fields', async () => { + const params = { + name: 'test-script', + type: 'ADMIN_SCRIPT', + context: { + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param1: 'value1' }, + audit: { + apiKeyName: 'Test Key', + apiKeyLast4: '1234', + ipAddress: '192.168.1.1', + }, + }, + }; + + const mockProcess = { + id: '507f1f77bcf86cd799439011', + name: params.name, + type: params.type, + state: 'PENDING', + context: params.context, + results: { logs: [] }, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.adminProcess.create.mockResolvedValue(mockProcess); + + const result = await repository.createProcess(params); + + expect(result).toEqual(mockProcess); + expect(mockPrisma.adminProcess.create).toHaveBeenCalledWith({ + data: { + name: params.name, + type: params.type, + context: params.context, + results: { logs: [] }, + }, + }); + }); + + it('should create process without optional fields', async () => { + const params = { + name: 'test-script', + type: 'ADMIN_SCRIPT', + context: { + trigger: 'SCHEDULED', + }, + }; + + const mockProcess = { + id: '507f1f77bcf86cd799439011', + name: params.name, + type: params.type, + state: 'PENDING', + context: params.context, + results: { logs: [] }, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.adminProcess.create.mockResolvedValue(mockProcess); + + const result = await repository.createProcess(params); + + expect(result).toEqual(mockProcess); + expect(mockPrisma.adminProcess.create).toHaveBeenCalledWith({ + data: { + name: params.name, + type: params.type, + context: params.context, + results: { logs: [] }, + }, + }); + }); + }); + + describe('findProcessById()', () => { + it('should find process by ID', async () => { + const id = '507f1f77bcf86cd799439011'; + const mockProcess = { + id, + name: 'test-script', + type: 'ADMIN_SCRIPT', + state: 'COMPLETED', + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(mockProcess); + + const result = await repository.findProcessById(id); + + expect(result).toEqual(mockProcess); + expect(mockPrisma.adminProcess.findUnique).toHaveBeenCalledWith({ + where: { id }, + }); + }); + + it('should return null if process not found', async () => { + mockPrisma.adminProcess.findUnique.mockResolvedValue(null); + + const result = await repository.findProcessById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('findProcessesByName()', () => { + it('should find processes by name with default options', async () => { + const name = 'test-script'; + const mockProcesses = [ + { id: '1', name, type: 'ADMIN_SCRIPT', state: 'COMPLETED' }, + { id: '2', name, type: 'ADMIN_SCRIPT', state: 'RUNNING' }, + ]; + + mockPrisma.adminProcess.findMany.mockResolvedValue(mockProcesses); + + const result = await repository.findProcessesByName(name); + + expect(result).toEqual(mockProcesses); + expect(mockPrisma.adminProcess.findMany).toHaveBeenCalledWith({ + where: { name }, + orderBy: { createdAt: 'desc' }, + take: undefined, + skip: undefined, + }); + }); + + it('should find processes with custom options', async () => { + const name = 'test-script'; + const options = { + limit: 10, + offset: 5, + sortBy: 'state', + sortOrder: 'asc', + }; + const mockProcesses = [{ id: '1', name, type: 'ADMIN_SCRIPT', state: 'COMPLETED' }]; + + mockPrisma.adminProcess.findMany.mockResolvedValue(mockProcesses); + + const result = await repository.findProcessesByName(name, options); + + expect(result).toEqual(mockProcesses); + expect(mockPrisma.adminProcess.findMany).toHaveBeenCalledWith({ + where: { name }, + orderBy: { state: 'asc' }, + take: 10, + skip: 5, + }); + }); + }); + + describe('findProcessesByState()', () => { + it('should find processes by state', async () => { + const state = 'RUNNING'; + const mockProcesses = [ + { id: '1', name: 'script1', type: 'ADMIN_SCRIPT', state }, + { id: '2', name: 'script2', type: 'ADMIN_SCRIPT', state }, + ]; + + mockPrisma.adminProcess.findMany.mockResolvedValue(mockProcesses); + + const result = await repository.findProcessesByState(state); + + expect(result).toEqual(mockProcesses); + expect(mockPrisma.adminProcess.findMany).toHaveBeenCalledWith({ + where: { state }, + orderBy: { createdAt: 'desc' }, + take: undefined, + skip: undefined, + }); + }); + }); + + describe('updateProcessState()', () => { + it('should update process state', async () => { + const id = '507f1f77bcf86cd799439011'; + const state = 'COMPLETED'; + const mockProcess = { id, state }; + + mockPrisma.adminProcess.update.mockResolvedValue(mockProcess); + + const result = await repository.updateProcessState(id, state); + + expect(result).toEqual(mockProcess); + expect(mockPrisma.adminProcess.update).toHaveBeenCalledWith({ + where: { id }, + data: { state }, + }); + }); + }); + + describe('updateProcessResults()', () => { + it('should merge new results with existing results', async () => { + const id = '507f1f77bcf86cd799439011'; + const existingProcess = { + id, + results: { logs: ['log1'] }, + }; + const newResults = { output: { result: 'success', data: [1, 2, 3] } }; + const mockProcess = { + id, + results: { logs: ['log1'], output: { result: 'success', data: [1, 2, 3] } }, + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(existingProcess); + mockPrisma.adminProcess.update.mockResolvedValue(mockProcess); + + const result = await repository.updateProcessResults(id, newResults); + + expect(result).toEqual(mockProcess); + expect(mockPrisma.adminProcess.update).toHaveBeenCalledWith({ + where: { id }, + data: { + results: { logs: ['log1'], output: { result: 'success', data: [1, 2, 3] } }, + }, + }); + }); + + it('should handle error information in results', async () => { + const id = '507f1f77bcf86cd799439011'; + const existingProcess = { + id, + results: { logs: [] }, + }; + const errorResults = { + error: { + name: 'ValidationError', + message: 'Invalid input', + stack: 'Error: Invalid input\n at validate(...)', + }, + }; + const mockProcess = { + id, + results: { logs: [], error: errorResults.error }, + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(existingProcess); + mockPrisma.adminProcess.update.mockResolvedValue(mockProcess); + + const result = await repository.updateProcessResults(id, errorResults); + + expect(result).toEqual(mockProcess); + }); + + it('should handle metrics in results', async () => { + const id = '507f1f77bcf86cd799439011'; + const existingProcess = { + id, + results: { logs: [] }, + }; + const metricsResults = { + metrics: { + startTime: new Date('2025-01-01T10:00:00Z'), + endTime: new Date('2025-01-01T10:05:00Z'), + durationMs: 300000, + }, + }; + const mockProcess = { + id, + results: { logs: [], metrics: metricsResults.metrics }, + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(existingProcess); + mockPrisma.adminProcess.update.mockResolvedValue(mockProcess); + + const result = await repository.updateProcessResults(id, metricsResults); + + expect(result).toEqual(mockProcess); + }); + }); + + describe('appendProcessLog()', () => { + it('should append log entry to existing logs in results', async () => { + const id = '507f1f77bcf86cd799439011'; + const logEntry = { + level: 'info', + message: 'Processing started', + data: { step: 1 }, + timestamp: new Date().toISOString(), + }; + const existingProcess = { + id, + results: { + logs: [ + { level: 'debug', message: 'Initialization', timestamp: new Date().toISOString() }, + ], + }, + }; + const updatedProcess = { + id, + results: { + logs: [...existingProcess.results.logs, logEntry], + }, + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(existingProcess); + mockPrisma.adminProcess.update.mockResolvedValue(updatedProcess); + + const result = await repository.appendProcessLog(id, logEntry); + + expect(result).toEqual(updatedProcess); + expect(mockPrisma.adminProcess.update).toHaveBeenCalledWith({ + where: { id }, + data: { results: { logs: [...existingProcess.results.logs, logEntry] } }, + }); + }); + + it('should append log entry to empty logs array', async () => { + const id = '507f1f77bcf86cd799439011'; + const logEntry = { + level: 'info', + message: 'First log', + timestamp: new Date().toISOString(), + }; + const existingProcess = { + id, + results: { logs: [] }, + }; + const updatedProcess = { + id, + results: { logs: [logEntry] }, + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(existingProcess); + mockPrisma.adminProcess.update.mockResolvedValue(updatedProcess); + + const result = await repository.appendProcessLog(id, logEntry); + + expect(result).toEqual(updatedProcess); + }); + + it('should initialize logs array if results.logs is missing', async () => { + const id = '507f1f77bcf86cd799439011'; + const logEntry = { + level: 'info', + message: 'First log', + timestamp: new Date().toISOString(), + }; + const existingProcess = { + id, + results: {}, + }; + const updatedProcess = { + id, + results: { logs: [logEntry] }, + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(existingProcess); + mockPrisma.adminProcess.update.mockResolvedValue(updatedProcess); + + const result = await repository.appendProcessLog(id, logEntry); + + expect(result).toEqual(updatedProcess); + }); + + it('should throw error if process not found', async () => { + const id = 'nonexistent'; + const logEntry = { + level: 'info', + message: 'Test', + timestamp: new Date().toISOString(), + }; + + mockPrisma.adminProcess.findUnique.mockResolvedValue(null); + + await expect(repository.appendProcessLog(id, logEntry)).rejects.toThrow( + `AdminProcess ${id} not found` + ); + }); + }); + + describe('deleteProcessesOlderThan()', () => { + it('should delete old processes and return count', async () => { + const date = new Date('2024-01-01'); + const mockResult = { count: 42 }; + + mockPrisma.adminProcess.deleteMany.mockResolvedValue(mockResult); + + const result = await repository.deleteProcessesOlderThan(date); + + expect(result).toEqual({ + acknowledged: true, + deletedCount: 42, + }); + expect(mockPrisma.adminProcess.deleteMany).toHaveBeenCalledWith({ + where: { + createdAt: { + lt: date, + }, + }, + }); + }); + + it('should return zero count if no processes deleted', async () => { + const date = new Date('2024-01-01'); + const mockResult = { count: 0 }; + + mockPrisma.adminProcess.deleteMany.mockResolvedValue(mockResult); + + const result = await repository.deleteProcessesOlderThan(date); + + expect(result).toEqual({ + acknowledged: true, + deletedCount: 0, + }); + }); + }); +}); diff --git a/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js b/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js deleted file mode 100644 index a1f450638..000000000 --- a/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-interface.test.js +++ /dev/null @@ -1,187 +0,0 @@ -const { ScriptExecutionRepositoryInterface } = require('../script-execution-repository-interface'); - -describe('ScriptExecutionRepositoryInterface', () => { - let repository; - - beforeEach(() => { - repository = new ScriptExecutionRepositoryInterface(); - }); - - describe('Interface contract', () => { - it('should throw error when createExecution is not implemented', async () => { - await expect( - repository.createExecution({ - scriptName: 'test-script', - scriptVersion: '1.0.0', - trigger: 'MANUAL', - mode: 'async', - input: { param1: 'value1' }, - audit: { - apiKeyName: 'test-key', - apiKeyLast4: '1234', - ipAddress: '192.168.1.1', - }, - }) - ).rejects.toThrow('Method createExecution must be implemented by subclass'); - }); - - it('should throw error when findExecutionById is not implemented', async () => { - await expect( - repository.findExecutionById('exec123') - ).rejects.toThrow('Method findExecutionById must be implemented by subclass'); - }); - - it('should throw error when findExecutionsByScriptName is not implemented', async () => { - await expect( - repository.findExecutionsByScriptName('test-script', { limit: 10 }) - ).rejects.toThrow('Method findExecutionsByScriptName must be implemented by subclass'); - }); - - it('should throw error when findExecutionsByStatus is not implemented', async () => { - await expect( - repository.findExecutionsByStatus('PENDING', { limit: 10 }) - ).rejects.toThrow('Method findExecutionsByStatus must be implemented by subclass'); - }); - - it('should throw error when updateExecutionStatus is not implemented', async () => { - await expect( - repository.updateExecutionStatus('exec123', 'RUNNING') - ).rejects.toThrow('Method updateExecutionStatus must be implemented by subclass'); - }); - - it('should throw error when updateExecutionOutput is not implemented', async () => { - await expect( - repository.updateExecutionOutput('exec123', { result: 'success' }) - ).rejects.toThrow('Method updateExecutionOutput must be implemented by subclass'); - }); - - it('should throw error when updateExecutionError is not implemented', async () => { - await expect( - repository.updateExecutionError('exec123', { - name: 'Error', - message: 'Something went wrong', - stack: 'Error: ...', - }) - ).rejects.toThrow('Method updateExecutionError must be implemented by subclass'); - }); - - it('should throw error when updateExecutionMetrics is not implemented', async () => { - await expect( - repository.updateExecutionMetrics('exec123', { - startTime: new Date(), - endTime: new Date(), - durationMs: 1234, - }) - ).rejects.toThrow('Method updateExecutionMetrics must be implemented by subclass'); - }); - - it('should throw error when appendExecutionLog is not implemented', async () => { - await expect( - repository.appendExecutionLog('exec123', { - level: 'info', - message: 'Log message', - data: {}, - timestamp: new Date().toISOString(), - }) - ).rejects.toThrow('Method appendExecutionLog must be implemented by subclass'); - }); - - it('should throw error when deleteExecutionsOlderThan is not implemented', async () => { - await expect( - repository.deleteExecutionsOlderThan(new Date('2024-01-01')) - ).rejects.toThrow('Method deleteExecutionsOlderThan must be implemented by subclass'); - }); - }); - - describe('Method signatures', () => { - it('should accept all required parameters in createExecution', async () => { - const params = { - scriptName: 'test-script', - scriptVersion: '1.0.0', - trigger: 'MANUAL', - mode: 'async', - input: { param1: 'value1' }, - audit: { - apiKeyName: 'test-key', - apiKeyLast4: '1234', - ipAddress: '192.168.1.1', - }, - }; - - await expect(repository.createExecution(params)).rejects.toThrow(); - }); - - it('should accept string parameter in findExecutionById', async () => { - await expect( - repository.findExecutionById('some-id') - ).rejects.toThrow(); - }); - - it('should accept scriptName and options in findExecutionsByScriptName', async () => { - await expect( - repository.findExecutionsByScriptName('test-script', { - limit: 10, - offset: 0, - }) - ).rejects.toThrow(); - }); - - it('should accept status and options in findExecutionsByStatus', async () => { - await expect( - repository.findExecutionsByStatus('PENDING', { - limit: 10, - offset: 0, - }) - ).rejects.toThrow(); - }); - - it('should accept id and status in updateExecutionStatus', async () => { - await expect( - repository.updateExecutionStatus('exec123', 'COMPLETED') - ).rejects.toThrow(); - }); - - it('should accept id and output in updateExecutionOutput', async () => { - await expect( - repository.updateExecutionOutput('exec123', { result: 'success' }) - ).rejects.toThrow(); - }); - - it('should accept id and error in updateExecutionError', async () => { - await expect( - repository.updateExecutionError('exec123', { - name: 'Error', - message: 'Failed', - stack: 'Stack trace', - }) - ).rejects.toThrow(); - }); - - it('should accept id and metrics in updateExecutionMetrics', async () => { - await expect( - repository.updateExecutionMetrics('exec123', { - startTime: new Date(), - endTime: new Date(), - durationMs: 5000, - }) - ).rejects.toThrow(); - }); - - it('should accept id and logEntry in appendExecutionLog', async () => { - await expect( - repository.appendExecutionLog('exec123', { - level: 'info', - message: 'Test log', - data: { key: 'value' }, - timestamp: new Date().toISOString(), - }) - ).rejects.toThrow(); - }); - - it('should accept Date parameter in deleteExecutionsOlderThan', async () => { - await expect( - repository.deleteExecutionsOlderThan(new Date('2024-01-01')) - ).rejects.toThrow(); - }); - }); -}); diff --git a/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js b/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js deleted file mode 100644 index 1a969227d..000000000 --- a/packages/core/admin-scripts/repositories/__tests__/script-execution-repository-mongo.test.js +++ /dev/null @@ -1,429 +0,0 @@ -const { ScriptExecutionRepositoryMongo } = require('../script-execution-repository-mongo'); - -describe('ScriptExecutionRepositoryMongo', () => { - let repository; - let mockPrisma; - - beforeEach(() => { - mockPrisma = { - scriptExecution: { - create: jest.fn(), - findUnique: jest.fn(), - findMany: jest.fn(), - update: jest.fn(), - deleteMany: jest.fn(), - }, - }; - - repository = new ScriptExecutionRepositoryMongo(); - repository.prisma = mockPrisma; - }); - - describe('createExecution()', () => { - it('should create execution with all fields', async () => { - const params = { - scriptName: 'test-script', - scriptVersion: '1.0.0', - trigger: 'MANUAL', - mode: 'async', - input: { param1: 'value1' }, - audit: { - apiKeyName: 'Test Key', - apiKeyLast4: '1234', - ipAddress: '192.168.1.1', - }, - }; - - const mockExecution = { - id: '507f1f77bcf86cd799439011', - scriptName: params.scriptName, - scriptVersion: params.scriptVersion, - trigger: params.trigger, - mode: params.mode, - input: params.input, - auditApiKeyName: params.audit.apiKeyName, - auditApiKeyLast4: params.audit.apiKeyLast4, - auditIpAddress: params.audit.ipAddress, - status: 'PENDING', - logs: [], - createdAt: new Date(), - }; - - mockPrisma.scriptExecution.create.mockResolvedValue(mockExecution); - - const result = await repository.createExecution(params); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.create).toHaveBeenCalledWith({ - data: { - scriptName: params.scriptName, - scriptVersion: params.scriptVersion, - trigger: params.trigger, - mode: params.mode, - input: params.input, - logs: [], - auditApiKeyName: params.audit.apiKeyName, - auditApiKeyLast4: params.audit.apiKeyLast4, - auditIpAddress: params.audit.ipAddress, - }, - }); - }); - - it('should create execution without optional fields', async () => { - const params = { - scriptName: 'test-script', - trigger: 'SCHEDULED', - }; - - const mockExecution = { - id: '507f1f77bcf86cd799439011', - scriptName: params.scriptName, - trigger: params.trigger, - mode: 'async', - status: 'PENDING', - logs: [], - createdAt: new Date(), - }; - - mockPrisma.scriptExecution.create.mockResolvedValue(mockExecution); - - const result = await repository.createExecution(params); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.create).toHaveBeenCalledWith({ - data: { - scriptName: params.scriptName, - trigger: params.trigger, - mode: 'async', - input: undefined, - logs: [], - }, - }); - }); - }); - - describe('findExecutionById()', () => { - it('should find execution by ID', async () => { - const id = '507f1f77bcf86cd799439011'; - const mockExecution = { - id, - scriptName: 'test-script', - status: 'COMPLETED', - }; - - mockPrisma.scriptExecution.findUnique.mockResolvedValue(mockExecution); - - const result = await repository.findExecutionById(id); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.findUnique).toHaveBeenCalledWith({ - where: { id }, - }); - }); - - it('should return null if execution not found', async () => { - mockPrisma.scriptExecution.findUnique.mockResolvedValue(null); - - const result = await repository.findExecutionById('nonexistent'); - - expect(result).toBeNull(); - }); - }); - - describe('findExecutionsByScriptName()', () => { - it('should find executions by script name with default options', async () => { - const scriptName = 'test-script'; - const mockExecutions = [ - { id: '1', scriptName, status: 'COMPLETED' }, - { id: '2', scriptName, status: 'RUNNING' }, - ]; - - mockPrisma.scriptExecution.findMany.mockResolvedValue(mockExecutions); - - const result = await repository.findExecutionsByScriptName(scriptName); - - expect(result).toEqual(mockExecutions); - expect(mockPrisma.scriptExecution.findMany).toHaveBeenCalledWith({ - where: { scriptName }, - orderBy: { createdAt: 'desc' }, - take: undefined, - skip: undefined, - }); - }); - - it('should find executions with custom options', async () => { - const scriptName = 'test-script'; - const options = { - limit: 10, - offset: 5, - sortBy: 'status', - sortOrder: 'asc', - }; - const mockExecutions = [{ id: '1', scriptName, status: 'COMPLETED' }]; - - mockPrisma.scriptExecution.findMany.mockResolvedValue(mockExecutions); - - const result = await repository.findExecutionsByScriptName(scriptName, options); - - expect(result).toEqual(mockExecutions); - expect(mockPrisma.scriptExecution.findMany).toHaveBeenCalledWith({ - where: { scriptName }, - orderBy: { status: 'asc' }, - take: 10, - skip: 5, - }); - }); - }); - - describe('findExecutionsByStatus()', () => { - it('should find executions by status', async () => { - const status = 'RUNNING'; - const mockExecutions = [ - { id: '1', scriptName: 'script1', status }, - { id: '2', scriptName: 'script2', status }, - ]; - - mockPrisma.scriptExecution.findMany.mockResolvedValue(mockExecutions); - - const result = await repository.findExecutionsByStatus(status); - - expect(result).toEqual(mockExecutions); - expect(mockPrisma.scriptExecution.findMany).toHaveBeenCalledWith({ - where: { status }, - orderBy: { createdAt: 'desc' }, - take: undefined, - skip: undefined, - }); - }); - }); - - describe('updateExecutionStatus()', () => { - it('should update execution status', async () => { - const id = '507f1f77bcf86cd799439011'; - const status = 'COMPLETED'; - const mockExecution = { id, status }; - - mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); - - const result = await repository.updateExecutionStatus(id, status); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ - where: { id }, - data: { status }, - }); - }); - }); - - describe('updateExecutionOutput()', () => { - it('should update execution output', async () => { - const id = '507f1f77bcf86cd799439011'; - const output = { result: 'success', data: [1, 2, 3] }; - const mockExecution = { id, output }; - - mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); - - const result = await repository.updateExecutionOutput(id, output); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ - where: { id }, - data: { output }, - }); - }); - }); - - describe('updateExecutionError()', () => { - it('should update execution error details', async () => { - const id = '507f1f77bcf86cd799439011'; - const error = { - name: 'ValidationError', - message: 'Invalid input', - stack: 'Error: Invalid input\n at validate(...)', - }; - const mockExecution = { - id, - errorName: error.name, - errorMessage: error.message, - errorStack: error.stack, - }; - - mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); - - const result = await repository.updateExecutionError(id, error); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ - where: { id }, - data: { - errorName: error.name, - errorMessage: error.message, - errorStack: error.stack, - }, - }); - }); - }); - - describe('updateExecutionMetrics()', () => { - it('should update all metrics', async () => { - const id = '507f1f77bcf86cd799439011'; - const metrics = { - startTime: new Date('2025-01-01T10:00:00Z'), - endTime: new Date('2025-01-01T10:05:00Z'), - durationMs: 300000, - }; - const mockExecution = { - id, - metricsStartTime: metrics.startTime, - metricsEndTime: metrics.endTime, - metricsDurationMs: metrics.durationMs, - }; - - mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); - - const result = await repository.updateExecutionMetrics(id, metrics); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ - where: { id }, - data: { - metricsStartTime: metrics.startTime, - metricsEndTime: metrics.endTime, - metricsDurationMs: metrics.durationMs, - }, - }); - }); - - it('should update partial metrics', async () => { - const id = '507f1f77bcf86cd799439011'; - const metrics = { - startTime: new Date('2025-01-01T10:00:00Z'), - }; - const mockExecution = { - id, - metricsStartTime: metrics.startTime, - }; - - mockPrisma.scriptExecution.update.mockResolvedValue(mockExecution); - - const result = await repository.updateExecutionMetrics(id, metrics); - - expect(result).toEqual(mockExecution); - expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ - where: { id }, - data: { - metricsStartTime: metrics.startTime, - }, - }); - }); - }); - - describe('appendExecutionLog()', () => { - it('should append log entry to existing logs', async () => { - const id = '507f1f77bcf86cd799439011'; - const logEntry = { - level: 'info', - message: 'Processing started', - data: { step: 1 }, - timestamp: new Date().toISOString(), - }; - const existingExecution = { - id, - logs: [ - { level: 'debug', message: 'Initialization', timestamp: new Date().toISOString() }, - ], - }; - const updatedExecution = { - id, - logs: [...existingExecution.logs, logEntry], - }; - - mockPrisma.scriptExecution.findUnique.mockResolvedValue(existingExecution); - mockPrisma.scriptExecution.update.mockResolvedValue(updatedExecution); - - const result = await repository.appendExecutionLog(id, logEntry); - - expect(result).toEqual(updatedExecution); - expect(mockPrisma.scriptExecution.update).toHaveBeenCalledWith({ - where: { id }, - data: { logs: [...existingExecution.logs, logEntry] }, - }); - }); - - it('should append log entry to empty logs array', async () => { - const id = '507f1f77bcf86cd799439011'; - const logEntry = { - level: 'info', - message: 'First log', - timestamp: new Date().toISOString(), - }; - const existingExecution = { - id, - logs: [], - }; - const updatedExecution = { - id, - logs: [logEntry], - }; - - mockPrisma.scriptExecution.findUnique.mockResolvedValue(existingExecution); - mockPrisma.scriptExecution.update.mockResolvedValue(updatedExecution); - - const result = await repository.appendExecutionLog(id, logEntry); - - expect(result).toEqual(updatedExecution); - }); - - it('should throw error if execution not found', async () => { - const id = 'nonexistent'; - const logEntry = { - level: 'info', - message: 'Test', - timestamp: new Date().toISOString(), - }; - - mockPrisma.scriptExecution.findUnique.mockResolvedValue(null); - - await expect(repository.appendExecutionLog(id, logEntry)).rejects.toThrow( - `Execution ${id} not found` - ); - }); - }); - - describe('deleteExecutionsOlderThan()', () => { - it('should delete old executions and return count', async () => { - const date = new Date('2024-01-01'); - const mockResult = { count: 42 }; - - mockPrisma.scriptExecution.deleteMany.mockResolvedValue(mockResult); - - const result = await repository.deleteExecutionsOlderThan(date); - - expect(result).toEqual({ - acknowledged: true, - deletedCount: 42, - }); - expect(mockPrisma.scriptExecution.deleteMany).toHaveBeenCalledWith({ - where: { - createdAt: { - lt: date, - }, - }, - }); - }); - - it('should return zero count if no executions deleted', async () => { - const date = new Date('2024-01-01'); - const mockResult = { count: 0 }; - - mockPrisma.scriptExecution.deleteMany.mockResolvedValue(mockResult); - - const result = await repository.deleteExecutionsOlderThan(date); - - expect(result).toEqual({ - acknowledged: true, - deletedCount: 0, - }); - }); - }); -}); diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js b/packages/core/admin-scripts/repositories/admin-process-repository-documentdb.js similarity index 56% rename from packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js rename to packages/core/admin-scripts/repositories/admin-process-repository-documentdb.js index 9ebe8b9bc..01f589ac7 100644 --- a/packages/core/admin-scripts/repositories/script-execution-repository-documentdb.js +++ b/packages/core/admin-scripts/repositories/admin-process-repository-documentdb.js @@ -1,9 +1,9 @@ const { - ScriptExecutionRepositoryMongo, -} = require('./script-execution-repository-mongo'); + AdminProcessRepositoryMongo, +} = require('./admin-process-repository-mongo'); /** - * DocumentDB Script Execution Repository Adapter + * DocumentDB Admin Process Repository Adapter * Extends MongoDB implementation since DocumentDB uses the same Prisma client * * DocumentDB-specific characteristics: @@ -12,10 +12,10 @@ const { * - IDs are strings with ObjectId format * - All operations identical to MongoDB implementation */ -class ScriptExecutionRepositoryDocumentDB extends ScriptExecutionRepositoryMongo { +class AdminProcessRepositoryDocumentDB extends AdminProcessRepositoryMongo { constructor() { super(); } } -module.exports = { ScriptExecutionRepositoryDocumentDB }; +module.exports = { AdminProcessRepositoryDocumentDB }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-factory.js b/packages/core/admin-scripts/repositories/admin-process-repository-factory.js similarity index 50% rename from packages/core/admin-scripts/repositories/script-execution-repository-factory.js rename to packages/core/admin-scripts/repositories/admin-process-repository-factory.js index 8d7fb4a24..cfdb946b1 100644 --- a/packages/core/admin-scripts/repositories/script-execution-repository-factory.js +++ b/packages/core/admin-scripts/repositories/admin-process-repository-factory.js @@ -1,12 +1,12 @@ -const { ScriptExecutionRepositoryMongo } = require('./script-execution-repository-mongo'); -const { ScriptExecutionRepositoryPostgres } = require('./script-execution-repository-postgres'); +const { AdminProcessRepositoryMongo } = require('./admin-process-repository-mongo'); +const { AdminProcessRepositoryPostgres } = require('./admin-process-repository-postgres'); const { - ScriptExecutionRepositoryDocumentDB, -} = require('./script-execution-repository-documentdb'); + AdminProcessRepositoryDocumentDB, +} = require('./admin-process-repository-documentdb'); const config = require('../../database/config'); /** - * Script Execution Repository Factory + * Admin Process Repository Factory * Creates the appropriate repository adapter based on database type * * This implements the Factory pattern for Hexagonal Architecture: @@ -16,24 +16,24 @@ const config = require('../../database/config'); * * Usage: * ```javascript - * const repository = createScriptExecutionRepository(); + * const repository = createAdminProcessRepository(); * ``` * - * @returns {ScriptExecutionRepositoryInterface} Configured repository adapter + * @returns {AdminProcessRepositoryInterface} Configured repository adapter * @throws {Error} If database type is not supported */ -function createScriptExecutionRepository() { +function createAdminProcessRepository() { const dbType = config.DB_TYPE; switch (dbType) { case 'mongodb': - return new ScriptExecutionRepositoryMongo(); + return new AdminProcessRepositoryMongo(); case 'postgresql': - return new ScriptExecutionRepositoryPostgres(); + return new AdminProcessRepositoryPostgres(); case 'documentdb': - return new ScriptExecutionRepositoryDocumentDB(); + return new AdminProcessRepositoryDocumentDB(); default: throw new Error( @@ -43,9 +43,9 @@ function createScriptExecutionRepository() { } module.exports = { - createScriptExecutionRepository, + createAdminProcessRepository, // Export adapters for direct testing - ScriptExecutionRepositoryMongo, - ScriptExecutionRepositoryPostgres, - ScriptExecutionRepositoryDocumentDB, + AdminProcessRepositoryMongo, + AdminProcessRepositoryPostgres, + AdminProcessRepositoryDocumentDB, }; diff --git a/packages/core/admin-scripts/repositories/admin-process-repository-interface.js b/packages/core/admin-scripts/repositories/admin-process-repository-interface.js new file mode 100644 index 000000000..17389eda7 --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-process-repository-interface.js @@ -0,0 +1,150 @@ +/** + * Admin Process Repository Interface + * Abstract base class defining the contract for admin process persistence adapters + * + * This follows the Port in Hexagonal Architecture: + * - Domain layer depends on this abstraction + * - Concrete adapters implement this interface + * - Use cases receive repositories via dependency injection + * + * Admin processes track administrative operations including: + * - Admin script executions + * - Database migrations + * - Scheduled maintenance tasks + * + * The AdminProcess model uses a flexible JSON storage pattern: + * - context: Input parameters, trigger info, audit data, script version + * - results: Output data, logs, metrics, error details + * + * @abstract + */ +class AdminProcessRepositoryInterface { + /** + * Create a new admin process record + * + * @param {Object} params - Process creation parameters + * @param {string} params.name - Name of the process (e.g., script name, migration name) + * @param {string} params.type - Type of process (e.g., 'ADMIN_SCRIPT', 'DB_MIGRATION') + * @param {Object} [params.context] - Context data (input, trigger, audit, script version) + * @param {string} [params.context.scriptVersion] - Version of the script + * @param {string} [params.context.trigger] - Trigger type ('MANUAL', 'SCHEDULED', 'QUEUE', 'WEBHOOK') + * @param {string} [params.context.mode] - Execution mode ('sync' or 'async') + * @param {Object} [params.context.input] - Input parameters + * @param {Object} [params.context.audit] - Audit information + * @param {string} [params.context.audit.apiKeyName] - Name of API key used + * @param {string} [params.context.audit.apiKeyLast4] - Last 4 chars of API key + * @param {string} [params.context.audit.ipAddress] - IP address of requester + * @returns {Promise} The created process record + * @abstract + */ + async createProcess({ name, type, context }) { + throw new Error('Method createProcess must be implemented by subclass'); + } + + /** + * Find a process by its ID + * + * @param {string|number} id - The process ID + * @returns {Promise} The process record or null if not found + * @abstract + */ + async findProcessById(id) { + throw new Error('Method findProcessById must be implemented by subclass'); + } + + /** + * Find all processes with a specific name + * + * @param {string} name - The process name to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of process records + * @abstract + */ + async findProcessesByName(name, options = {}) { + throw new Error('Method findProcessesByName must be implemented by subclass'); + } + + /** + * Find all processes with a specific state + * + * @param {string} state - State to filter by ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED') + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of process records + * @abstract + */ + async findProcessesByState(state, options = {}) { + throw new Error('Method findProcessesByState must be implemented by subclass'); + } + + /** + * Update the state of a process + * + * @param {string|number} id - The process ID + * @param {string} state - New state value ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED') + * @returns {Promise} Updated process record + * @abstract + */ + async updateProcessState(id, state) { + throw new Error('Method updateProcessState must be implemented by subclass'); + } + + /** + * Update the results of a process + * Merges new results with existing results in the results JSON field + * + * @param {string|number} id - The process ID + * @param {Object} results - Results data to merge + * @param {Object} [results.output] - Output data from the process + * @param {Object} [results.error] - Error information + * @param {string} [results.error.name] - Error name/type + * @param {string} [results.error.message] - Error message + * @param {string} [results.error.stack] - Error stack trace + * @param {Object} [results.metrics] - Performance metrics + * @param {Date} [results.metrics.startTime] - Process start time + * @param {Date} [results.metrics.endTime] - Process end time + * @param {number} [results.metrics.durationMs] - Duration in milliseconds + * @returns {Promise} Updated process record + * @abstract + */ + async updateProcessResults(id, results) { + throw new Error('Method updateProcessResults must be implemented by subclass'); + } + + /** + * Append a log entry to a process's log array in results + * + * @param {string|number} id - The process ID + * @param {Object} logEntry - Log entry to append + * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') + * @param {string} logEntry.message - Log message + * @param {Object} [logEntry.data] - Additional log data + * @param {string} logEntry.timestamp - ISO timestamp + * @returns {Promise} Updated process record + * @abstract + */ + async appendProcessLog(id, logEntry) { + throw new Error('Method appendProcessLog must be implemented by subclass'); + } + + /** + * Delete all processes older than a specific date + * Used for cleanup and retention policies + * + * @param {Date} date - Delete processes older than this date + * @returns {Promise} Deletion result with count + * @abstract + */ + async deleteProcessesOlderThan(date) { + throw new Error('Method deleteProcessesOlderThan must be implemented by subclass'); + } +} + +module.exports = { AdminProcessRepositoryInterface }; diff --git a/packages/core/admin-scripts/repositories/admin-process-repository-mongo.js b/packages/core/admin-scripts/repositories/admin-process-repository-mongo.js new file mode 100644 index 000000000..b6e48f06e --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-process-repository-mongo.js @@ -0,0 +1,213 @@ +const { prisma } = require('../../database/prisma'); +const { + AdminProcessRepositoryInterface, +} = require('./admin-process-repository-interface'); + +/** + * MongoDB Admin Process Repository Adapter + * Handles admin process persistence using Prisma with MongoDB + * + * MongoDB-specific characteristics: + * - IDs are strings with @db.ObjectId + * - context and results are Json objects + * - Stores logs in results.logs array + */ +class AdminProcessRepositoryMongo extends AdminProcessRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Create a new admin process record + * + * @param {Object} params - Process creation parameters + * @param {string} params.name - Name of the process + * @param {string} params.type - Type of process (e.g., 'ADMIN_SCRIPT', 'DB_MIGRATION') + * @param {Object} [params.context] - Context data + * @returns {Promise} The created process record + */ + async createProcess({ name, type, context = {} }) { + const data = { + name, + type, + context, + results: { logs: [] }, + }; + + const process = await this.prisma.adminProcess.create({ + data, + }); + + return process; + } + + /** + * Find a process by its ID + * + * @param {string} id - The process ID + * @returns {Promise} The process record or null if not found + */ + async findProcessById(id) { + const process = await this.prisma.adminProcess.findUnique({ + where: { id }, + }); + + return process; + } + + /** + * Find all processes with a specific name + * + * @param {string} name - The process name to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of process records + */ + async findProcessesByName(name, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const processes = await this.prisma.adminProcess.findMany({ + where: { name }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return processes; + } + + /** + * Find all processes with a specific state + * + * @param {string} state - State to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of process records + */ + async findProcessesByState(state, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const processes = await this.prisma.adminProcess.findMany({ + where: { state }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return processes; + } + + /** + * Update the state of a process + * + * @param {string} id - The process ID + * @param {string} state - New state value + * @returns {Promise} Updated process record + */ + async updateProcessState(id, state) { + const process = await this.prisma.adminProcess.update({ + where: { id }, + data: { state }, + }); + + return process; + } + + /** + * Update the results of a process + * Merges new results with existing results + * + * @param {string} id - The process ID + * @param {Object} results - Results data to merge + * @returns {Promise} Updated process record + */ + async updateProcessResults(id, results) { + // Get current process to merge results + const currentProcess = await this.prisma.adminProcess.findUnique({ + where: { id }, + }); + + if (!currentProcess) { + throw new Error(`AdminProcess ${id} not found`); + } + + // Merge new results with existing results + const mergedResults = { + ...(currentProcess.results || {}), + ...results, + }; + + const process = await this.prisma.adminProcess.update({ + where: { id }, + data: { results: mergedResults }, + }); + + return process; + } + + /** + * Append a log entry to a process's log array in results + * + * @param {string} id - The process ID + * @param {Object} logEntry - Log entry to append + * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') + * @param {string} logEntry.message - Log message + * @param {Object} [logEntry.data] - Additional log data + * @param {string} logEntry.timestamp - ISO timestamp + * @returns {Promise} Updated process record + */ + async appendProcessLog(id, logEntry) { + // Get current process + const process = await this.prisma.adminProcess.findUnique({ + where: { id }, + }); + + if (!process) { + throw new Error(`AdminProcess ${id} not found`); + } + + // Get current results and logs + const results = process.results || {}; + const logs = Array.isArray(results.logs) ? [...results.logs] : []; + logs.push(logEntry); + + // Update with new logs array in results + const updated = await this.prisma.adminProcess.update({ + where: { id }, + data: { results: { ...results, logs } }, + }); + + return updated; + } + + /** + * Delete all processes older than a specific date + * Used for cleanup and retention policies + * + * @param {Date} date - Delete processes older than this date + * @returns {Promise} Deletion result with count + */ + async deleteProcessesOlderThan(date) { + const result = await this.prisma.adminProcess.deleteMany({ + where: { + createdAt: { + lt: date, + }, + }, + }); + + return { + acknowledged: true, + deletedCount: result.count, + }; + } +} + +module.exports = { AdminProcessRepositoryMongo }; diff --git a/packages/core/admin-scripts/repositories/admin-process-repository-postgres.js b/packages/core/admin-scripts/repositories/admin-process-repository-postgres.js new file mode 100644 index 000000000..9355bb3ea --- /dev/null +++ b/packages/core/admin-scripts/repositories/admin-process-repository-postgres.js @@ -0,0 +1,251 @@ +const { prisma } = require('../../database/prisma'); +const { + AdminProcessRepositoryInterface, +} = require('./admin-process-repository-interface'); + +/** + * PostgreSQL Admin Process Repository Adapter + * Handles admin process persistence using Prisma with PostgreSQL + * + * PostgreSQL-specific characteristics: + * - Uses Int IDs with autoincrement + * - Requires ID conversion: String (app layer) ↔ Int (database) + * - All returned IDs are converted to strings for application layer consistency + * - context and results are Json objects + */ +class AdminProcessRepositoryPostgres extends AdminProcessRepositoryInterface { + constructor() { + super(); + this.prisma = prisma; + } + + /** + * Convert string ID to integer for PostgreSQL queries + * @private + * @param {string|number|null|undefined} id - ID to convert + * @returns {number|null|undefined} Integer ID or null/undefined + * @throws {Error} If ID cannot be converted to integer + */ + _convertId(id) { + if (id === null || id === undefined) return id; + const parsed = Number.parseInt(id, 10); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid ID: ${id} cannot be converted to integer`); + } + return parsed; + } + + /** + * Convert process object IDs to strings + * @private + * @param {Object|null} process - Process object from database + * @returns {Object|null} Process with string IDs + */ + _convertProcessIds(process) { + if (!process) return process; + return { + ...process, + id: process.id?.toString(), + parentProcessId: process.parentProcessId?.toString(), + }; + } + + /** + * Create a new admin process record + * + * @param {Object} params - Process creation parameters + * @param {string} params.name - Name of the process + * @param {string} params.type - Type of process (e.g., 'ADMIN_SCRIPT', 'DB_MIGRATION') + * @param {Object} [params.context] - Context data + * @returns {Promise} The created process record with string ID + */ + async createProcess({ name, type, context = {} }) { + const data = { + name, + type, + context, + results: { logs: [] }, + }; + + const process = await this.prisma.adminProcess.create({ + data, + }); + + return this._convertProcessIds(process); + } + + /** + * Find a process by its ID + * + * @param {string|number} id - The process ID + * @returns {Promise} The process record with string ID or null if not found + */ + async findProcessById(id) { + const intId = this._convertId(id); + const process = await this.prisma.adminProcess.findUnique({ + where: { id: intId }, + }); + + return this._convertProcessIds(process); + } + + /** + * Find all processes with a specific name + * + * @param {string} name - The process name to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of process records with string IDs + */ + async findProcessesByName(name, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const processes = await this.prisma.adminProcess.findMany({ + where: { name }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return processes.map((process) => this._convertProcessIds(process)); + } + + /** + * Find all processes with a specific state + * + * @param {string} state - State to filter by + * @param {Object} [options] - Query options + * @param {number} [options.limit] - Maximum number of results + * @param {number} [options.offset] - Number of results to skip + * @param {string} [options.sortBy] - Field to sort by + * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') + * @returns {Promise} Array of process records with string IDs + */ + async findProcessesByState(state, options = {}) { + const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; + + const processes = await this.prisma.adminProcess.findMany({ + where: { state }, + orderBy: { [sortBy]: sortOrder }, + take: limit, + skip: offset, + }); + + return processes.map((process) => this._convertProcessIds(process)); + } + + /** + * Update the state of a process + * + * @param {string|number} id - The process ID + * @param {string} state - New state value + * @returns {Promise} Updated process record with string ID + */ + async updateProcessState(id, state) { + const intId = this._convertId(id); + const process = await this.prisma.adminProcess.update({ + where: { id: intId }, + data: { state }, + }); + + return this._convertProcessIds(process); + } + + /** + * Update the results of a process + * Merges new results with existing results + * + * @param {string|number} id - The process ID + * @param {Object} results - Results data to merge + * @returns {Promise} Updated process record with string ID + */ + async updateProcessResults(id, results) { + const intId = this._convertId(id); + + // Get current process to merge results + const currentProcess = await this.prisma.adminProcess.findUnique({ + where: { id: intId }, + }); + + if (!currentProcess) { + throw new Error(`AdminProcess ${id} not found`); + } + + // Merge new results with existing results + const mergedResults = { + ...(currentProcess.results || {}), + ...results, + }; + + const process = await this.prisma.adminProcess.update({ + where: { id: intId }, + data: { results: mergedResults }, + }); + + return this._convertProcessIds(process); + } + + /** + * Append a log entry to a process's log array in results + * + * @param {string|number} id - The process ID + * @param {Object} logEntry - Log entry to append + * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') + * @param {string} logEntry.message - Log message + * @param {Object} [logEntry.data] - Additional log data + * @param {string} logEntry.timestamp - ISO timestamp + * @returns {Promise} Updated process record with string ID + */ + async appendProcessLog(id, logEntry) { + const intId = this._convertId(id); + + // Get current process + const process = await this.prisma.adminProcess.findUnique({ + where: { id: intId }, + }); + + if (!process) { + throw new Error(`AdminProcess ${id} not found`); + } + + // Get current results and logs + const results = process.results || {}; + const logs = Array.isArray(results.logs) ? [...results.logs] : []; + logs.push(logEntry); + + // Update with new logs array in results + const updated = await this.prisma.adminProcess.update({ + where: { id: intId }, + data: { results: { ...results, logs } }, + }); + + return this._convertProcessIds(updated); + } + + /** + * Delete all processes older than a specific date + * Used for cleanup and retention policies + * + * @param {Date} date - Delete processes older than this date + * @returns {Promise} Deletion result with count + */ + async deleteProcessesOlderThan(date) { + const result = await this.prisma.adminProcess.deleteMany({ + where: { + createdAt: { + lt: date, + }, + }, + }); + + return { + acknowledged: true, + deletedCount: result.count, + }; + } +} + +module.exports = { AdminProcessRepositoryPostgres }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-interface.js b/packages/core/admin-scripts/repositories/script-execution-repository-interface.js deleted file mode 100644 index 7b07f0523..000000000 --- a/packages/core/admin-scripts/repositories/script-execution-repository-interface.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Script Execution Repository Interface - * Abstract base class defining the contract for script execution persistence adapters - * - * This follows the Port in Hexagonal Architecture: - * - Domain layer depends on this abstraction - * - Concrete adapters implement this interface - * - Use cases receive repositories via dependency injection - * - * Script executions track the lifecycle of admin script runs, including: - * - Input parameters and output results - * - Execution status and error details - * - Performance metrics - * - Audit trail (who triggered, when, from where) - * - Real-time logs - * - * @abstract - */ -class ScriptExecutionRepositoryInterface { - /** - * Create a new script execution record - * - * @param {Object} params - Execution creation parameters - * @param {string} params.scriptName - Name of the script being executed - * @param {string} [params.scriptVersion] - Version of the script - * @param {string} params.trigger - Trigger type ('MANUAL', 'SCHEDULED', 'QUEUE', 'WEBHOOK') - * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') - * @param {Object} [params.input] - Input parameters for the script - * @param {Object} [params.audit] - Audit information - * @param {string} [params.audit.apiKeyName] - Name of API key used - * @param {string} [params.audit.apiKeyLast4] - Last 4 chars of API key - * @param {string} [params.audit.ipAddress] - IP address of requester - * @returns {Promise} The created execution record - * @abstract - */ - async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { - throw new Error('Method createExecution must be implemented by subclass'); - } - - /** - * Find an execution by its ID - * - * @param {string|number} id - The execution ID - * @returns {Promise} The execution record or null if not found - * @abstract - */ - async findExecutionById(id) { - throw new Error('Method findExecutionById must be implemented by subclass'); - } - - /** - * Find all executions for a specific script - * - * @param {string} scriptName - The script name to filter by - * @param {Object} [options] - Query options - * @param {number} [options.limit] - Maximum number of results - * @param {number} [options.offset] - Number of results to skip - * @param {string} [options.sortBy] - Field to sort by - * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') - * @returns {Promise} Array of execution records - * @abstract - */ - async findExecutionsByScriptName(scriptName, options = {}) { - throw new Error('Method findExecutionsByScriptName must be implemented by subclass'); - } - - /** - * Find all executions with a specific status - * - * @param {string} status - Status to filter by ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'TIMEOUT', 'CANCELLED') - * @param {Object} [options] - Query options - * @param {number} [options.limit] - Maximum number of results - * @param {number} [options.offset] - Number of results to skip - * @param {string} [options.sortBy] - Field to sort by - * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') - * @returns {Promise} Array of execution records - * @abstract - */ - async findExecutionsByStatus(status, options = {}) { - throw new Error('Method findExecutionsByStatus must be implemented by subclass'); - } - - /** - * Update the status of an execution - * - * @param {string|number} id - The execution ID - * @param {string} status - New status value - * @returns {Promise} Updated execution record - * @abstract - */ - async updateExecutionStatus(id, status) { - throw new Error('Method updateExecutionStatus must be implemented by subclass'); - } - - /** - * Update the output result of an execution - * - * @param {string|number} id - The execution ID - * @param {Object} output - Output data from the script - * @returns {Promise} Updated execution record - * @abstract - */ - async updateExecutionOutput(id, output) { - throw new Error('Method updateExecutionOutput must be implemented by subclass'); - } - - /** - * Update the error details of a failed execution - * - * @param {string|number} id - The execution ID - * @param {Object} error - Error information - * @param {string} error.name - Error name/type - * @param {string} error.message - Error message - * @param {string} [error.stack] - Error stack trace - * @returns {Promise} Updated execution record - * @abstract - */ - async updateExecutionError(id, error) { - throw new Error('Method updateExecutionError must be implemented by subclass'); - } - - /** - * Update the performance metrics of an execution - * - * @param {string|number} id - The execution ID - * @param {Object} metrics - Performance metrics - * @param {Date} [metrics.startTime] - Execution start time - * @param {Date} [metrics.endTime] - Execution end time - * @param {number} [metrics.durationMs] - Duration in milliseconds - * @returns {Promise} Updated execution record - * @abstract - */ - async updateExecutionMetrics(id, metrics) { - throw new Error('Method updateExecutionMetrics must be implemented by subclass'); - } - - /** - * Append a log entry to an execution's log array - * - * @param {string|number} id - The execution ID - * @param {Object} logEntry - Log entry to append - * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') - * @param {string} logEntry.message - Log message - * @param {Object} [logEntry.data] - Additional log data - * @param {string} logEntry.timestamp - ISO timestamp - * @returns {Promise} Updated execution record - * @abstract - */ - async appendExecutionLog(id, logEntry) { - throw new Error('Method appendExecutionLog must be implemented by subclass'); - } - - /** - * Delete all executions older than a specific date - * Used for cleanup and retention policies - * - * @param {Date} date - Delete executions older than this date - * @returns {Promise} Deletion result with count - * @abstract - */ - async deleteExecutionsOlderThan(date) { - throw new Error('Method deleteExecutionsOlderThan must be implemented by subclass'); - } -} - -module.exports = { ScriptExecutionRepositoryInterface }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js b/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js deleted file mode 100644 index 530f20a0d..000000000 --- a/packages/core/admin-scripts/repositories/script-execution-repository-mongo.js +++ /dev/null @@ -1,258 +0,0 @@ -const { prisma } = require('../../database/prisma'); -const { - ScriptExecutionRepositoryInterface, -} = require('./script-execution-repository-interface'); - -/** - * MongoDB Script Execution Repository Adapter - * Handles script execution persistence using Prisma with MongoDB - * - * MongoDB-specific characteristics: - * - IDs are strings with @db.ObjectId - * - logs field is Json[] - supports push operations - * - Audit fields stored as separate columns - */ -class ScriptExecutionRepositoryMongo extends ScriptExecutionRepositoryInterface { - constructor() { - super(); - this.prisma = prisma; - } - - /** - * Create a new script execution record - * - * @param {Object} params - Execution creation parameters - * @param {string} params.scriptName - Name of the script being executed - * @param {string} [params.scriptVersion] - Version of the script - * @param {string} params.trigger - Trigger type - * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') - * @param {Object} [params.input] - Input parameters for the script - * @param {Object} [params.audit] - Audit information - * @param {string} [params.audit.apiKeyName] - Name of API key used - * @param {string} [params.audit.apiKeyLast4] - Last 4 chars of API key - * @param {string} [params.audit.ipAddress] - IP address of requester - * @returns {Promise} The created execution record - */ - async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { - const data = { - scriptName, - scriptVersion, - trigger, - mode: mode || 'async', - input, - logs: [], - }; - - // Map audit object to separate fields - if (audit) { - if (audit.apiKeyName) data.auditApiKeyName = audit.apiKeyName; - if (audit.apiKeyLast4) data.auditApiKeyLast4 = audit.apiKeyLast4; - if (audit.ipAddress) data.auditIpAddress = audit.ipAddress; - } - - const execution = await this.prisma.scriptExecution.create({ - data, - }); - - return execution; - } - - /** - * Find an execution by its ID - * - * @param {string} id - The execution ID - * @returns {Promise} The execution record or null if not found - */ - async findExecutionById(id) { - const execution = await this.prisma.scriptExecution.findUnique({ - where: { id }, - }); - - return execution; - } - - /** - * Find all executions for a specific script - * - * @param {string} scriptName - The script name to filter by - * @param {Object} [options] - Query options - * @param {number} [options.limit] - Maximum number of results - * @param {number} [options.offset] - Number of results to skip - * @param {string} [options.sortBy] - Field to sort by - * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') - * @returns {Promise} Array of execution records - */ - async findExecutionsByScriptName(scriptName, options = {}) { - const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; - - const executions = await this.prisma.scriptExecution.findMany({ - where: { scriptName }, - orderBy: { [sortBy]: sortOrder }, - take: limit, - skip: offset, - }); - - return executions; - } - - /** - * Find all executions with a specific status - * - * @param {string} status - Status to filter by - * @param {Object} [options] - Query options - * @param {number} [options.limit] - Maximum number of results - * @param {number} [options.offset] - Number of results to skip - * @param {string} [options.sortBy] - Field to sort by - * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') - * @returns {Promise} Array of execution records - */ - async findExecutionsByStatus(status, options = {}) { - const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; - - const executions = await this.prisma.scriptExecution.findMany({ - where: { status }, - orderBy: { [sortBy]: sortOrder }, - take: limit, - skip: offset, - }); - - return executions; - } - - /** - * Update the status of an execution - * - * @param {string} id - The execution ID - * @param {string} status - New status value - * @returns {Promise} Updated execution record - */ - async updateExecutionStatus(id, status) { - const execution = await this.prisma.scriptExecution.update({ - where: { id }, - data: { status }, - }); - - return execution; - } - - /** - * Update the output result of an execution - * - * @param {string} id - The execution ID - * @param {Object} output - Output data from the script - * @returns {Promise} Updated execution record - */ - async updateExecutionOutput(id, output) { - const execution = await this.prisma.scriptExecution.update({ - where: { id }, - data: { output }, - }); - - return execution; - } - - /** - * Update the error details of a failed execution - * - * @param {string} id - The execution ID - * @param {Object} error - Error information - * @param {string} error.name - Error name/type - * @param {string} error.message - Error message - * @param {string} [error.stack] - Error stack trace - * @returns {Promise} Updated execution record - */ - async updateExecutionError(id, error) { - const execution = await this.prisma.scriptExecution.update({ - where: { id }, - data: { - errorName: error.name, - errorMessage: error.message, - errorStack: error.stack, - }, - }); - - return execution; - } - - /** - * Update the performance metrics of an execution - * - * @param {string} id - The execution ID - * @param {Object} metrics - Performance metrics - * @param {Date} [metrics.startTime] - Execution start time - * @param {Date} [metrics.endTime] - Execution end time - * @param {number} [metrics.durationMs] - Duration in milliseconds - * @returns {Promise} Updated execution record - */ - async updateExecutionMetrics(id, metrics) { - const data = {}; - if (metrics.startTime !== undefined) data.metricsStartTime = metrics.startTime; - if (metrics.endTime !== undefined) data.metricsEndTime = metrics.endTime; - if (metrics.durationMs !== undefined) data.metricsDurationMs = metrics.durationMs; - - const execution = await this.prisma.scriptExecution.update({ - where: { id }, - data, - }); - - return execution; - } - - /** - * Append a log entry to an execution's log array - * - * @param {string} id - The execution ID - * @param {Object} logEntry - Log entry to append - * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') - * @param {string} logEntry.message - Log message - * @param {Object} [logEntry.data] - Additional log data - * @param {string} logEntry.timestamp - ISO timestamp - * @returns {Promise} Updated execution record - */ - async appendExecutionLog(id, logEntry) { - // Get current execution - const execution = await this.prisma.scriptExecution.findUnique({ - where: { id }, - }); - - if (!execution) { - throw new Error(`Execution ${id} not found`); - } - - // Append log entry to logs array (copy to avoid mutating original) - const logs = Array.isArray(execution.logs) ? [...execution.logs] : []; - logs.push(logEntry); - - // Update with new logs array - const updated = await this.prisma.scriptExecution.update({ - where: { id }, - data: { logs }, - }); - - return updated; - } - - /** - * Delete all executions older than a specific date - * Used for cleanup and retention policies - * - * @param {Date} date - Delete executions older than this date - * @returns {Promise} Deletion result with count - */ - async deleteExecutionsOlderThan(date) { - const result = await this.prisma.scriptExecution.deleteMany({ - where: { - createdAt: { - lt: date, - }, - }, - }); - - return { - acknowledged: true, - deletedCount: result.count, - }; - } -} - -module.exports = { ScriptExecutionRepositoryMongo }; diff --git a/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js b/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js deleted file mode 100644 index fcb557ac5..000000000 --- a/packages/core/admin-scripts/repositories/script-execution-repository-postgres.js +++ /dev/null @@ -1,296 +0,0 @@ -const { prisma } = require('../../database/prisma'); -const { - ScriptExecutionRepositoryInterface, -} = require('./script-execution-repository-interface'); - -/** - * PostgreSQL Script Execution Repository Adapter - * Handles script execution persistence using Prisma with PostgreSQL - * - * PostgreSQL-specific characteristics: - * - Uses Int IDs with autoincrement - * - Requires ID conversion: String (app layer) ↔ Int (database) - * - All returned IDs are converted to strings for application layer consistency - * - logs field is Json[] - supports push operations - */ -class ScriptExecutionRepositoryPostgres extends ScriptExecutionRepositoryInterface { - constructor() { - super(); - this.prisma = prisma; - } - - /** - * Convert string ID to integer for PostgreSQL queries - * @private - * @param {string|number|null|undefined} id - ID to convert - * @returns {number|null|undefined} Integer ID or null/undefined - * @throws {Error} If ID cannot be converted to integer - */ - _convertId(id) { - if (id === null || id === undefined) return id; - const parsed = Number.parseInt(id, 10); - if (Number.isNaN(parsed)) { - throw new Error(`Invalid ID: ${id} cannot be converted to integer`); - } - return parsed; - } - - /** - * Convert execution object IDs to strings - * @private - * @param {Object|null} execution - Execution object from database - * @returns {Object|null} Execution with string IDs - */ - _convertExecutionIds(execution) { - if (!execution) return execution; - return { - ...execution, - id: execution.id?.toString(), - }; - } - - /** - * Create a new script execution record - * - * @param {Object} params - Execution creation parameters - * @param {string} params.scriptName - Name of the script being executed - * @param {string} [params.scriptVersion] - Version of the script - * @param {string} params.trigger - Trigger type - * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') - * @param {Object} [params.input] - Input parameters for the script - * @param {Object} [params.audit] - Audit information - * @param {string} [params.audit.apiKeyName] - Name of API key used - * @param {string} [params.audit.apiKeyLast4] - Last 4 chars of API key - * @param {string} [params.audit.ipAddress] - IP address of requester - * @returns {Promise} The created execution record with string ID - */ - async createExecution({ scriptName, scriptVersion, trigger, mode, input, audit }) { - const data = { - scriptName, - scriptVersion, - trigger, - mode: mode || 'async', - input, - logs: [], - }; - - // Map audit object to separate fields - if (audit) { - if (audit.apiKeyName) data.auditApiKeyName = audit.apiKeyName; - if (audit.apiKeyLast4) data.auditApiKeyLast4 = audit.apiKeyLast4; - if (audit.ipAddress) data.auditIpAddress = audit.ipAddress; - } - - const execution = await this.prisma.scriptExecution.create({ - data, - }); - - return this._convertExecutionIds(execution); - } - - /** - * Find an execution by its ID - * - * @param {string|number} id - The execution ID - * @returns {Promise} The execution record with string ID or null if not found - */ - async findExecutionById(id) { - const intId = this._convertId(id); - const execution = await this.prisma.scriptExecution.findUnique({ - where: { id: intId }, - }); - - return this._convertExecutionIds(execution); - } - - /** - * Find all executions for a specific script - * - * @param {string} scriptName - The script name to filter by - * @param {Object} [options] - Query options - * @param {number} [options.limit] - Maximum number of results - * @param {number} [options.offset] - Number of results to skip - * @param {string} [options.sortBy] - Field to sort by - * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') - * @returns {Promise} Array of execution records with string IDs - */ - async findExecutionsByScriptName(scriptName, options = {}) { - const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; - - const executions = await this.prisma.scriptExecution.findMany({ - where: { scriptName }, - orderBy: { [sortBy]: sortOrder }, - take: limit, - skip: offset, - }); - - return executions.map((execution) => this._convertExecutionIds(execution)); - } - - /** - * Find all executions with a specific status - * - * @param {string} status - Status to filter by - * @param {Object} [options] - Query options - * @param {number} [options.limit] - Maximum number of results - * @param {number} [options.offset] - Number of results to skip - * @param {string} [options.sortBy] - Field to sort by - * @param {string} [options.sortOrder] - Sort order ('asc' or 'desc') - * @returns {Promise} Array of execution records with string IDs - */ - async findExecutionsByStatus(status, options = {}) { - const { limit, offset, sortBy = 'createdAt', sortOrder = 'desc' } = options; - - const executions = await this.prisma.scriptExecution.findMany({ - where: { status }, - orderBy: { [sortBy]: sortOrder }, - take: limit, - skip: offset, - }); - - return executions.map((execution) => this._convertExecutionIds(execution)); - } - - /** - * Update the status of an execution - * - * @param {string|number} id - The execution ID - * @param {string} status - New status value - * @returns {Promise} Updated execution record with string ID - */ - async updateExecutionStatus(id, status) { - const intId = this._convertId(id); - const execution = await this.prisma.scriptExecution.update({ - where: { id: intId }, - data: { status }, - }); - - return this._convertExecutionIds(execution); - } - - /** - * Update the output result of an execution - * - * @param {string|number} id - The execution ID - * @param {Object} output - Output data from the script - * @returns {Promise} Updated execution record with string ID - */ - async updateExecutionOutput(id, output) { - const intId = this._convertId(id); - const execution = await this.prisma.scriptExecution.update({ - where: { id: intId }, - data: { output }, - }); - - return this._convertExecutionIds(execution); - } - - /** - * Update the error details of a failed execution - * - * @param {string|number} id - The execution ID - * @param {Object} error - Error information - * @param {string} error.name - Error name/type - * @param {string} error.message - Error message - * @param {string} [error.stack] - Error stack trace - * @returns {Promise} Updated execution record with string ID - */ - async updateExecutionError(id, error) { - const intId = this._convertId(id); - const execution = await this.prisma.scriptExecution.update({ - where: { id: intId }, - data: { - errorName: error.name, - errorMessage: error.message, - errorStack: error.stack, - }, - }); - - return this._convertExecutionIds(execution); - } - - /** - * Update the performance metrics of an execution - * - * @param {string|number} id - The execution ID - * @param {Object} metrics - Performance metrics - * @param {Date} [metrics.startTime] - Execution start time - * @param {Date} [metrics.endTime] - Execution end time - * @param {number} [metrics.durationMs] - Duration in milliseconds - * @returns {Promise} Updated execution record with string ID - */ - async updateExecutionMetrics(id, metrics) { - const intId = this._convertId(id); - const data = {}; - if (metrics.startTime !== undefined) data.metricsStartTime = metrics.startTime; - if (metrics.endTime !== undefined) data.metricsEndTime = metrics.endTime; - if (metrics.durationMs !== undefined) data.metricsDurationMs = metrics.durationMs; - - const execution = await this.prisma.scriptExecution.update({ - where: { id: intId }, - data, - }); - - return this._convertExecutionIds(execution); - } - - /** - * Append a log entry to an execution's log array - * - * @param {string|number} id - The execution ID - * @param {Object} logEntry - Log entry to append - * @param {string} logEntry.level - Log level ('debug', 'info', 'warn', 'error') - * @param {string} logEntry.message - Log message - * @param {Object} [logEntry.data] - Additional log data - * @param {string} logEntry.timestamp - ISO timestamp - * @returns {Promise} Updated execution record with string ID - */ - async appendExecutionLog(id, logEntry) { - const intId = this._convertId(id); - - // Get current execution - const execution = await this.prisma.scriptExecution.findUnique({ - where: { id: intId }, - }); - - if (!execution) { - throw new Error(`Execution ${id} not found`); - } - - // Append log entry to logs array (copy to avoid mutating original) - const logs = Array.isArray(execution.logs) ? [...execution.logs] : []; - logs.push(logEntry); - - // Update with new logs array - const updated = await this.prisma.scriptExecution.update({ - where: { id: intId }, - data: { logs }, - }); - - return this._convertExecutionIds(updated); - } - - /** - * Delete all executions older than a specific date - * Used for cleanup and retention policies - * - * @param {Date} date - Delete executions older than this date - * @returns {Promise} Deletion result with count - */ - async deleteExecutionsOlderThan(date) { - const result = await this.prisma.scriptExecution.deleteMany({ - where: { - createdAt: { - lt: date, - }, - }, - }); - - return { - acknowledged: true, - deletedCount: result.count, - }; - } -} - -module.exports = { ScriptExecutionRepositoryPostgres }; diff --git a/packages/core/application/commands/__tests__/admin-script-commands.test.js b/packages/core/application/commands/__tests__/admin-script-commands.test.js index 3b864f58f..6339d357d 100644 --- a/packages/core/application/commands/__tests__/admin-script-commands.test.js +++ b/packages/core/application/commands/__tests__/admin-script-commands.test.js @@ -6,47 +6,21 @@ jest.mock('../../../database/config', () => ({ PRISMA_QUERY_LOGGING: false, })); -// Mock bcrypt for deterministic testing -const mockBcryptHash = jest.fn(); -const mockBcryptCompare = jest.fn(); -jest.mock('bcryptjs', () => ({ - hash: mockBcryptHash, - compare: mockBcryptCompare, -})); - -// Mock uuid for deterministic key generation -const mockUuid = jest.fn(); -jest.mock('uuid', () => ({ - v4: mockUuid, -})); - // Mock repository factories -const mockApiKeyRepo = { - createApiKey: jest.fn(), - findActiveApiKeys: jest.fn(), - findApiKeyById: jest.fn(), - updateApiKeyLastUsed: jest.fn(), - deactivateApiKey: jest.fn(), +const mockAdminProcessRepo = { + createAdminProcess: jest.fn(), + findAdminProcessById: jest.fn(), + findAdminProcessesByName: jest.fn(), + findAdminProcessesByState: jest.fn(), + updateAdminProcessState: jest.fn(), + updateAdminProcessOutput: jest.fn(), + updateAdminProcessError: jest.fn(), + updateAdminProcessMetrics: jest.fn(), + appendAdminProcessLog: jest.fn(), }; -const mockExecutionRepo = { - createExecution: jest.fn(), - findExecutionById: jest.fn(), - findExecutionsByScriptName: jest.fn(), - findExecutionsByStatus: jest.fn(), - updateExecutionStatus: jest.fn(), - updateExecutionOutput: jest.fn(), - updateExecutionError: jest.fn(), - updateExecutionMetrics: jest.fn(), - appendExecutionLog: jest.fn(), -}; - -jest.mock('../../../admin-scripts/repositories/admin-api-key-repository-factory', () => ({ - createAdminApiKeyRepository: () => mockApiKeyRepo, -})); - -jest.mock('../../../admin-scripts/repositories/script-execution-repository-factory', () => ({ - createScriptExecutionRepository: () => mockExecutionRepo, +jest.mock('../../../admin-scripts/repositories/admin-process-repository-factory', () => ({ + createAdminProcessRepository: () => mockAdminProcessRepo, })); const { createAdminScriptCommands } = require('../admin-script-commands'); @@ -59,342 +33,31 @@ describe('createAdminScriptCommands', () => { commands = createAdminScriptCommands(); }); - describe('createAdminApiKey', () => { - it('creates API key with all fields', async () => { - const rawKey = 'test-uuid-1234-5678-abcd'; - const keyHash = 'hashed-key'; - mockUuid.mockReturnValue(rawKey); - mockBcryptHash.mockResolvedValue(keyHash); - - const mockRecord = { - id: 'key-123', - name: 'Test Key', - keyHash, - keyLast4: 'abcd', - scopes: ['scripts:execute'], - expiresAt: new Date('2025-12-31'), - }; - mockApiKeyRepo.createApiKey.mockResolvedValue(mockRecord); - - const result = await commands.createAdminApiKey({ - name: 'Test Key', - scopes: ['scripts:execute'], - expiresAt: new Date('2025-12-31'), - createdBy: 'admin@example.com', - }); - - expect(mockUuid).toHaveBeenCalled(); - expect(mockBcryptHash).toHaveBeenCalledWith(rawKey, 10); - expect(mockApiKeyRepo.createApiKey).toHaveBeenCalledWith({ - name: 'Test Key', - keyHash, - keyLast4: 'abcd', - scopes: ['scripts:execute'], - expiresAt: new Date('2025-12-31'), - createdBy: 'admin@example.com', - }); - - expect(result).toEqual({ - id: 'key-123', - rawKey, // Only returned once! - name: 'Test Key', - keyLast4: 'abcd', - scopes: ['scripts:execute'], - expiresAt: new Date('2025-12-31'), - }); - }); - - it('returns rawKey only on creation', async () => { - const rawKey = 'unique-key-12345'; - mockUuid.mockReturnValue(rawKey); - mockBcryptHash.mockResolvedValue('hashed'); - - mockApiKeyRepo.createApiKey.mockResolvedValue({ - id: 'key-1', - name: 'Key', - keyHash: 'hashed', - keyLast4: '2345', - scopes: [], - }); - - const result = await commands.createAdminApiKey({ - name: 'Key', - scopes: [], - }); - - expect(result.rawKey).toBe(rawKey); - expect(result.id).toBe('key-1'); - }); - - it('generates unique keys on multiple calls', async () => { - mockUuid - .mockReturnValueOnce('key-1-uuid') - .mockReturnValueOnce('key-2-uuid'); - mockBcryptHash - .mockResolvedValueOnce('hash-1') - .mockResolvedValueOnce('hash-2'); - - mockApiKeyRepo.createApiKey - .mockResolvedValueOnce({ - id: '1', - name: 'First', - keyHash: 'hash-1', - keyLast4: 'uuid', - scopes: [], - }) - .mockResolvedValueOnce({ - id: '2', - name: 'Second', - keyHash: 'hash-2', - keyLast4: 'uuid', - scopes: [], - }); - - const result1 = await commands.createAdminApiKey({ - name: 'First', - scopes: [], - }); - const result2 = await commands.createAdminApiKey({ - name: 'Second', - scopes: [], - }); - - expect(result1.rawKey).toBe('key-1-uuid'); - expect(result2.rawKey).toBe('key-2-uuid'); - expect(result1.id).toBe('1'); - expect(result2.id).toBe('2'); - }); - - it('hashes key with bcrypt cost factor 10', async () => { - mockUuid.mockReturnValue('test-key'); - mockBcryptHash.mockResolvedValue('hashed'); - mockApiKeyRepo.createApiKey.mockResolvedValue({ - id: '1', - name: 'Test', - keyHash: 'hashed', - keyLast4: '-key', - scopes: [], - }); - - await commands.createAdminApiKey({ name: 'Test', scopes: [] }); - - expect(mockBcryptHash).toHaveBeenCalledWith('test-key', 10); - }); - - it('maps error to response on failure', async () => { - mockUuid.mockReturnValue('key'); - mockBcryptHash.mockRejectedValue(new Error('Hashing failed')); - - const result = await commands.createAdminApiKey({ - name: 'Test', - scopes: [], - }); - - expect(result).toHaveProperty('error', 500); - expect(result).toHaveProperty('reason', 'Hashing failed'); - }); - }); - - describe('validateAdminApiKey', () => { - it('returns valid for correct key', async () => { - const rawKey = 'test-key-123'; - const mockKey = { - id: 'key-1', - name: 'Valid Key', - keyHash: 'hashed-test-key', - keyLast4: '-123', - scopes: ['scripts:execute'], - expiresAt: null, - isActive: true, - }; - - mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([mockKey]); - mockBcryptCompare.mockResolvedValue(true); - mockApiKeyRepo.updateApiKeyLastUsed.mockResolvedValue(mockKey); - - const result = await commands.validateAdminApiKey(rawKey); - - expect(mockApiKeyRepo.findActiveApiKeys).toHaveBeenCalled(); - expect(mockBcryptCompare).toHaveBeenCalledWith(rawKey, mockKey.keyHash); - expect(mockApiKeyRepo.updateApiKeyLastUsed).toHaveBeenCalledWith('key-1'); - expect(result).toEqual({ valid: true, apiKey: mockKey }); - }); - - it('returns error for invalid key', async () => { - mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([ - { id: '1', keyHash: 'hash1' }, - { id: '2', keyHash: 'hash2' }, - ]); - mockBcryptCompare.mockResolvedValue(false); - - const result = await commands.validateAdminApiKey('invalid-key'); - - expect(result).toHaveProperty('error', 401); - expect(result).toHaveProperty('code', 'INVALID_API_KEY'); - expect(result).toHaveProperty('reason', 'Invalid API key'); - expect(mockApiKeyRepo.updateApiKeyLastUsed).not.toHaveBeenCalled(); - }); - - it('returns error for expired key', async () => { - const expiredKey = { - id: 'key-1', - keyHash: 'hash', - expiresAt: new Date('2020-01-01'), // Past date - }; - - mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([expiredKey]); - mockBcryptCompare.mockResolvedValue(true); - - const result = await commands.validateAdminApiKey('expired-key'); - - expect(result).toHaveProperty('error', 401); - expect(result).toHaveProperty('code', 'EXPIRED_API_KEY'); - expect(result).toHaveProperty('reason', 'API key has expired'); - expect(mockApiKeyRepo.updateApiKeyLastUsed).not.toHaveBeenCalled(); - }); - - it('updates lastUsedAt on success', async () => { - const validKey = { - id: 'key-1', - keyHash: 'hash', - expiresAt: null, - }; - - mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([validKey]); - mockBcryptCompare.mockResolvedValue(true); - mockApiKeyRepo.updateApiKeyLastUsed.mockResolvedValue({ - ...validKey, - lastUsedAt: new Date(), - }); - - await commands.validateAdminApiKey('valid-key'); - - expect(mockApiKeyRepo.updateApiKeyLastUsed).toHaveBeenCalledWith('key-1'); - }); - - it('checks multiple keys until match found', async () => { - const keys = [ - { id: '1', keyHash: 'hash1' }, - { id: '2', keyHash: 'hash2' }, - { id: '3', keyHash: 'hash3' }, - ]; - - mockApiKeyRepo.findActiveApiKeys.mockResolvedValue(keys); - mockBcryptCompare - .mockResolvedValueOnce(false) // First key doesn't match - .mockResolvedValueOnce(true); // Second key matches - mockApiKeyRepo.updateApiKeyLastUsed.mockResolvedValue(keys[1]); - - const result = await commands.validateAdminApiKey('test-key'); - - expect(mockBcryptCompare).toHaveBeenCalledTimes(2); - expect(result.valid).toBe(true); - expect(result.apiKey).toEqual(keys[1]); - }); - }); - - describe('listAdminApiKeys', () => { - it('returns active keys without keyHash', async () => { - const mockKeys = [ - { - id: 'key-1', - name: 'First Key', - keyHash: 'secret-hash-1', - keyLast4: '1234', - scopes: ['scripts:execute'], - }, - { - id: 'key-2', - name: 'Second Key', - keyHash: 'secret-hash-2', - keyLast4: '5678', - scopes: ['scripts:read'], - }, - ]; - - mockApiKeyRepo.findActiveApiKeys.mockResolvedValue(mockKeys); - - const result = await commands.listAdminApiKeys(); - - expect(result).toHaveLength(2); - expect(result[0]).not.toHaveProperty('keyHash'); - expect(result[1]).not.toHaveProperty('keyHash'); - expect(result[0]).toEqual({ - id: 'key-1', - name: 'First Key', - keyLast4: '1234', - scopes: ['scripts:execute'], - }); - }); - - it('returns empty array if no active keys', async () => { - mockApiKeyRepo.findActiveApiKeys.mockResolvedValue([]); - - const result = await commands.listAdminApiKeys(); - - expect(result).toEqual([]); - }); - - it('maps error on repository failure', async () => { - mockApiKeyRepo.findActiveApiKeys.mockRejectedValue( - new Error('Database error') - ); - - const result = await commands.listAdminApiKeys(); - - expect(result).toHaveProperty('error', 500); - expect(result).toHaveProperty('reason', 'Database error'); - }); - }); - - describe('deactivateAdminApiKey', () => { - it('deactivates existing key', async () => { - const mockDeactivated = { - id: 'key-1', - isActive: false, - }; - - mockApiKeyRepo.deactivateApiKey.mockResolvedValue(mockDeactivated); - - const result = await commands.deactivateAdminApiKey('key-1'); - - expect(mockApiKeyRepo.deactivateApiKey).toHaveBeenCalledWith('key-1'); - expect(result).toEqual(mockDeactivated); - }); - - it('handles non-existent key gracefully', async () => { - mockApiKeyRepo.deactivateApiKey.mockRejectedValue( - new Error('Key not found') - ); - - const result = await commands.deactivateAdminApiKey('non-existent'); - - expect(result).toHaveProperty('error', 500); - expect(result).toHaveProperty('reason', 'Key not found'); - }); - }); - - describe('createScriptExecution', () => { - it('creates execution with all fields', async () => { - const mockExecution = { - id: 'exec-1', - scriptName: 'test-script', - scriptVersion: '1.0.0', - status: 'PENDING', - trigger: 'MANUAL', - mode: 'async', - input: { param: 'value' }, - audit: { - apiKeyName: 'Admin Key', - apiKeyLast4: '1234', - ipAddress: '127.0.0.1', + describe('createAdminProcess', () => { + it('creates admin process with all fields', async () => { + const mockProcess = { + id: 'proc-1', + name: 'test-script', + type: 'ADMIN_SCRIPT', + state: 'PENDING', + context: { + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param: 'value' }, + audit: { + apiKeyName: 'Admin Key', + apiKeyLast4: '1234', + ipAddress: '127.0.0.1', + }, }, + results: {}, createdAt: new Date(), }; - mockExecutionRepo.createExecution.mockResolvedValue(mockExecution); + mockAdminProcessRepo.createAdminProcess.mockResolvedValue(mockProcess); - const result = await commands.createScriptExecution({ + const result = await commands.createAdminProcess({ scriptName: 'test-script', scriptVersion: '1.0.0', trigger: 'MANUAL', @@ -407,7 +70,7 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockExecutionRepo.createExecution).toHaveBeenCalledWith({ + expect(mockAdminProcessRepo.createAdminProcess).toHaveBeenCalledWith({ scriptName: 'test-script', scriptVersion: '1.0.0', trigger: 'MANUAL', @@ -419,26 +82,30 @@ describe('createAdminScriptCommands', () => { ipAddress: '127.0.0.1', }, }); - expect(result).toEqual(mockExecution); + expect(result).toEqual(mockProcess); }); it('sets default mode to async if not provided', async () => { - const mockExecution = { - id: 'exec-1', - scriptName: 'test', - status: 'PENDING', - trigger: 'MANUAL', - mode: 'async', + const mockProcess = { + id: 'proc-1', + name: 'test', + type: 'ADMIN_SCRIPT', + state: 'PENDING', + context: { + trigger: 'MANUAL', + mode: 'async', + }, + results: {}, }; - mockExecutionRepo.createExecution.mockResolvedValue(mockExecution); + mockAdminProcessRepo.createAdminProcess.mockResolvedValue(mockProcess); - await commands.createScriptExecution({ + await commands.createAdminProcess({ scriptName: 'test', trigger: 'MANUAL', }); - expect(mockExecutionRepo.createExecution).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.createAdminProcess).toHaveBeenCalledWith( expect.objectContaining({ mode: 'async', }) @@ -446,16 +113,21 @@ describe('createAdminScriptCommands', () => { }); it('stores audit info correctly', async () => { - mockExecutionRepo.createExecution.mockResolvedValue({ - id: 'exec-1', - audit: { - apiKeyName: 'Test Key', - apiKeyLast4: 'abcd', - ipAddress: '192.168.1.1', + mockAdminProcessRepo.createAdminProcess.mockResolvedValue({ + id: 'proc-1', + name: 'test', + type: 'ADMIN_SCRIPT', + context: { + audit: { + apiKeyName: 'Test Key', + apiKeyLast4: 'abcd', + ipAddress: '192.168.1.1', + }, }, + results: {}, }); - await commands.createScriptExecution({ + await commands.createAdminProcess({ scriptName: 'test', trigger: 'MANUAL', audit: { @@ -465,7 +137,7 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockExecutionRepo.createExecution).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.createAdminProcess).toHaveBeenCalledWith( expect.objectContaining({ audit: { apiKeyName: 'Test Key', @@ -477,26 +149,29 @@ describe('createAdminScriptCommands', () => { }); }); - describe('findScriptExecutionById', () => { - it('returns execution if found', async () => { - const mockExecution = { - id: 'exec-1', - scriptName: 'test', - status: 'COMPLETED', + describe('findAdminProcessById', () => { + it('returns admin process if found', async () => { + const mockProcess = { + id: 'proc-1', + name: 'test', + type: 'ADMIN_SCRIPT', + state: 'COMPLETED', + context: {}, + results: {}, }; - mockExecutionRepo.findExecutionById.mockResolvedValue(mockExecution); + mockAdminProcessRepo.findAdminProcessById.mockResolvedValue(mockProcess); - const result = await commands.findScriptExecutionById('exec-1'); + const result = await commands.findAdminProcessById('proc-1'); - expect(mockExecutionRepo.findExecutionById).toHaveBeenCalledWith('exec-1'); - expect(result).toEqual(mockExecution); + expect(mockAdminProcessRepo.findAdminProcessById).toHaveBeenCalledWith('proc-1'); + expect(result).toEqual(mockProcess); }); it('returns error if not found', async () => { - mockExecutionRepo.findExecutionById.mockResolvedValue(null); + mockAdminProcessRepo.findAdminProcessById.mockResolvedValue(null); - const result = await commands.findScriptExecutionById('non-existent'); + const result = await commands.findAdminProcessById('non-existent'); expect(result).toHaveProperty('error', 404); expect(result).toHaveProperty('code', 'EXECUTION_NOT_FOUND'); @@ -504,37 +179,37 @@ describe('createAdminScriptCommands', () => { }); }); - describe('findScriptExecutionsByName', () => { - it('finds executions by script name', async () => { - const mockExecutions = [ - { id: 'exec-1', scriptName: 'test', status: 'COMPLETED' }, - { id: 'exec-2', scriptName: 'test', status: 'FAILED' }, + describe('findAdminProcessesByName', () => { + it('finds admin processes by script name', async () => { + const mockProcesses = [ + { id: 'proc-1', name: 'test', type: 'ADMIN_SCRIPT', state: 'COMPLETED', context: {}, results: {} }, + { id: 'proc-2', name: 'test', type: 'ADMIN_SCRIPT', state: 'FAILED', context: {}, results: {} }, ]; - mockExecutionRepo.findExecutionsByScriptName.mockResolvedValue( - mockExecutions + mockAdminProcessRepo.findAdminProcessesByName.mockResolvedValue( + mockProcesses ); - const result = await commands.findScriptExecutionsByName('test'); + const result = await commands.findAdminProcessesByName('test'); - expect(mockExecutionRepo.findExecutionsByScriptName).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findAdminProcessesByName).toHaveBeenCalledWith( 'test', {} ); - expect(result).toEqual(mockExecutions); + expect(result).toEqual(mockProcesses); }); it('passes options to repository', async () => { - mockExecutionRepo.findExecutionsByScriptName.mockResolvedValue([]); + mockAdminProcessRepo.findAdminProcessesByName.mockResolvedValue([]); - await commands.findScriptExecutionsByName('test', { + await commands.findAdminProcessesByName('test', { limit: 10, offset: 5, sortBy: 'createdAt', sortOrder: 'desc', }); - expect(mockExecutionRepo.findExecutionsByScriptName).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findAdminProcessesByName).toHaveBeenCalledWith( 'test', { limit: 10, @@ -546,65 +221,71 @@ describe('createAdminScriptCommands', () => { }); it('returns empty array on error', async () => { - mockExecutionRepo.findExecutionsByScriptName.mockRejectedValue( + mockAdminProcessRepo.findAdminProcessesByName.mockRejectedValue( new Error('DB error') ); - const result = await commands.findScriptExecutionsByName('test'); + const result = await commands.findAdminProcessesByName('test'); expect(result).toEqual([]); }); }); - describe('updateScriptExecutionStatus', () => { - it('updates status correctly', async () => { + describe('updateAdminProcessState', () => { + it('updates state correctly', async () => { const mockUpdated = { - id: 'exec-1', - status: 'RUNNING', + id: 'proc-1', + name: 'test', + type: 'ADMIN_SCRIPT', + state: 'RUNNING', + context: {}, + results: {}, }; - mockExecutionRepo.updateExecutionStatus.mockResolvedValue(mockUpdated); + mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue(mockUpdated); - const result = await commands.updateScriptExecutionStatus( - 'exec-1', + const result = await commands.updateAdminProcessState( + 'proc-1', 'RUNNING' ); - expect(mockExecutionRepo.updateExecutionStatus).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.updateAdminProcessState).toHaveBeenCalledWith( + 'proc-1', 'RUNNING' ); expect(result).toEqual(mockUpdated); }); - it('handles all status values', async () => { - const statuses = [ + it('handles all state values', async () => { + const states = [ 'PENDING', 'RUNNING', 'COMPLETED', 'FAILED', - 'TIMEOUT', - 'CANCELLED', ]; - for (const status of statuses) { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({ - id: 'exec-1', - status, + for (const state of states) { + mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({ + id: 'proc-1', + name: 'test', + type: 'ADMIN_SCRIPT', + state, + context: {}, + results: {}, }); - const result = await commands.updateScriptExecutionStatus( - 'exec-1', - status + const result = await commands.updateAdminProcessState( + 'proc-1', + state ); - expect(result.status).toBe(status); + expect(result.state).toBe(state); } }); }); - describe('appendScriptExecutionLog', () => { - it('appends log entry to logs array', async () => { + describe('appendAdminProcessLog', () => { + it('appends log entry to results.logs array', async () => { const logEntry = { level: 'info', message: 'Test log', @@ -613,19 +294,25 @@ describe('createAdminScriptCommands', () => { }; const mockUpdated = { - id: 'exec-1', - logs: [logEntry], + id: 'proc-1', + name: 'test', + type: 'ADMIN_SCRIPT', + state: 'RUNNING', + context: {}, + results: { + logs: [logEntry], + }, }; - mockExecutionRepo.appendExecutionLog.mockResolvedValue(mockUpdated); + mockAdminProcessRepo.appendAdminProcessLog.mockResolvedValue(mockUpdated); - const result = await commands.appendScriptExecutionLog('exec-1', logEntry); + const result = await commands.appendAdminProcessLog('proc-1', logEntry); - expect(mockExecutionRepo.appendExecutionLog).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.appendAdminProcessLog).toHaveBeenCalledWith( + 'proc-1', logEntry ); - expect(result.logs).toContain(logEntry); + expect(result.results.logs).toContain(logEntry); }); it('handles different log levels', async () => { @@ -638,30 +325,36 @@ describe('createAdminScriptCommands', () => { timestamp: new Date().toISOString(), }; - mockExecutionRepo.appendExecutionLog.mockResolvedValue({ - id: 'exec-1', - logs: [logEntry], + mockAdminProcessRepo.appendAdminProcessLog.mockResolvedValue({ + id: 'proc-1', + name: 'test', + type: 'ADMIN_SCRIPT', + state: 'RUNNING', + context: {}, + results: { + logs: [logEntry], + }, }); - await commands.appendScriptExecutionLog('exec-1', logEntry); + await commands.appendAdminProcessLog('proc-1', logEntry); - expect(mockExecutionRepo.appendExecutionLog).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.appendAdminProcessLog).toHaveBeenCalledWith( + 'proc-1', expect.objectContaining({ level }) ); } }); }); - describe('completeScriptExecution', () => { - it('updates status, output, error, and metrics', async () => { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); - mockExecutionRepo.updateExecutionOutput.mockResolvedValue({}); - mockExecutionRepo.updateExecutionError.mockResolvedValue({}); - mockExecutionRepo.updateExecutionMetrics.mockResolvedValue({}); + describe('completeAdminProcess', () => { + it('updates state, output, error, and metrics', async () => { + mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessOutput.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessError.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessMetrics.mockResolvedValue({}); - const result = await commands.completeScriptExecution('exec-1', { - status: 'COMPLETED', + const result = await commands.completeAdminProcess('proc-1', { + state: 'COMPLETED', output: { result: 'success' }, error: null, metrics: { @@ -671,41 +364,41 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockExecutionRepo.updateExecutionStatus).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.updateAdminProcessState).toHaveBeenCalledWith( + 'proc-1', 'COMPLETED' ); - expect(mockExecutionRepo.updateExecutionOutput).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.updateAdminProcessOutput).toHaveBeenCalledWith( + 'proc-1', { result: 'success' } ); - expect(mockExecutionRepo.updateExecutionMetrics).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.updateAdminProcessMetrics).toHaveBeenCalledWith( + 'proc-1', expect.objectContaining({ durationMs: 1234 }) ); expect(result).toEqual({ success: true }); }); it('handles partial updates', async () => { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); - await commands.completeScriptExecution('exec-1', { - status: 'FAILED', + await commands.completeAdminProcess('proc-1', { + state: 'FAILED', // No output, error, or metrics }); - expect(mockExecutionRepo.updateExecutionStatus).toHaveBeenCalled(); - expect(mockExecutionRepo.updateExecutionOutput).not.toHaveBeenCalled(); - expect(mockExecutionRepo.updateExecutionError).not.toHaveBeenCalled(); - expect(mockExecutionRepo.updateExecutionMetrics).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateAdminProcessState).toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateAdminProcessOutput).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateAdminProcessError).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateAdminProcessMetrics).not.toHaveBeenCalled(); }); it('updates error details on failure', async () => { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); - mockExecutionRepo.updateExecutionError.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessError.mockResolvedValue({}); - await commands.completeScriptExecution('exec-1', { - status: 'FAILED', + await commands.completeAdminProcess('proc-1', { + state: 'FAILED', error: { name: 'ValidationError', message: 'Invalid input', @@ -713,8 +406,8 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockExecutionRepo.updateExecutionError).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.updateAdminProcessError).toHaveBeenCalledWith( + 'proc-1', { name: 'ValidationError', message: 'Invalid input', @@ -724,44 +417,44 @@ describe('createAdminScriptCommands', () => { }); it('allows output to be null or undefined', async () => { - mockExecutionRepo.updateExecutionStatus.mockResolvedValue({}); - mockExecutionRepo.updateExecutionOutput.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateAdminProcessOutput.mockResolvedValue({}); // Test with null - await commands.completeScriptExecution('exec-1', { - status: 'COMPLETED', + await commands.completeAdminProcess('proc-1', { + state: 'COMPLETED', output: null, }); - expect(mockExecutionRepo.updateExecutionOutput).toHaveBeenCalledWith( - 'exec-1', + expect(mockAdminProcessRepo.updateAdminProcessOutput).toHaveBeenCalledWith( + 'proc-1', null ); jest.clearAllMocks(); // Test with undefined (should not call update) - await commands.completeScriptExecution('exec-2', { - status: 'COMPLETED', + await commands.completeAdminProcess('proc-2', { + state: 'COMPLETED', // output is undefined }); - expect(mockExecutionRepo.updateExecutionOutput).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateAdminProcessOutput).not.toHaveBeenCalled(); }); }); - describe('findRecentExecutions', () => { - it('finds executions by status', async () => { - const mockExecutions = [ - { id: 'exec-1', status: 'FAILED' }, - { id: 'exec-2', status: 'FAILED' }, + describe('findRecentAdminProcesses', () => { + it('finds admin processes by state', async () => { + const mockProcesses = [ + { id: 'proc-1', name: 'test', type: 'ADMIN_SCRIPT', state: 'FAILED', context: {}, results: {} }, + { id: 'proc-2', name: 'test', type: 'ADMIN_SCRIPT', state: 'FAILED', context: {}, results: {} }, ]; - mockExecutionRepo.findExecutionsByStatus.mockResolvedValue(mockExecutions); + mockAdminProcessRepo.findAdminProcessesByState.mockResolvedValue(mockProcesses); - const result = await commands.findRecentExecutions({ status: 'FAILED' }); + const result = await commands.findRecentAdminProcesses({ state: 'FAILED' }); - expect(mockExecutionRepo.findExecutionsByStatus).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findAdminProcessesByState).toHaveBeenCalledWith( 'FAILED', { limit: 20, @@ -769,47 +462,47 @@ describe('createAdminScriptCommands', () => { sortOrder: 'desc', } ); - expect(result).toEqual(mockExecutions); + expect(result).toEqual(mockProcesses); }); it('uses default limit of 20', async () => { - mockExecutionRepo.findExecutionsByStatus.mockResolvedValue([]); + mockAdminProcessRepo.findAdminProcessesByState.mockResolvedValue([]); - await commands.findRecentExecutions({ status: 'COMPLETED' }); + await commands.findRecentAdminProcesses({ state: 'COMPLETED' }); - expect(mockExecutionRepo.findExecutionsByStatus).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findAdminProcessesByState).toHaveBeenCalledWith( 'COMPLETED', expect.objectContaining({ limit: 20 }) ); }); it('allows custom limit', async () => { - mockExecutionRepo.findExecutionsByStatus.mockResolvedValue([]); + mockAdminProcessRepo.findAdminProcessesByState.mockResolvedValue([]); - await commands.findRecentExecutions({ - status: 'RUNNING', + await commands.findRecentAdminProcesses({ + state: 'RUNNING', limit: 50, }); - expect(mockExecutionRepo.findExecutionsByStatus).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findAdminProcessesByState).toHaveBeenCalledWith( 'RUNNING', expect.objectContaining({ limit: 50 }) ); }); - it('returns empty array if no status filter', async () => { - const result = await commands.findRecentExecutions({}); + it('returns empty array if no state filter', async () => { + const result = await commands.findRecentAdminProcesses({}); expect(result).toEqual([]); - expect(mockExecutionRepo.findExecutionsByStatus).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.findAdminProcessesByState).not.toHaveBeenCalled(); }); it('returns empty array on error', async () => { - mockExecutionRepo.findExecutionsByStatus.mockRejectedValue( + mockAdminProcessRepo.findAdminProcessesByState.mockRejectedValue( new Error('DB error') ); - const result = await commands.findRecentExecutions({ status: 'FAILED' }); + const result = await commands.findRecentAdminProcesses({ state: 'FAILED' }); expect(result).toEqual([]); }); diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js index 25231896e..b0eea8994 100644 --- a/packages/core/application/commands/admin-script-commands.js +++ b/packages/core/application/commands/admin-script-commands.js @@ -25,28 +25,28 @@ function mapErrorToResponse(error) { */ function createAdminScriptCommands() { // Lazy-load repository factories to avoid circular dependencies - const { createScriptExecutionRepository } = require('../../admin-scripts/repositories/script-execution-repository-factory'); + const { createAdminProcessRepository } = require('../../admin-scripts/repositories/admin-process-repository-factory'); const { createScriptScheduleRepository } = require('../../admin-scripts/repositories/script-schedule-repository-factory'); - const executionRepository = createScriptExecutionRepository(); + const adminProcessRepository = createAdminProcessRepository(); const scheduleRepository = createScriptScheduleRepository(); return { - // ==================== Execution Management Commands ==================== + // ==================== Admin Process Management Commands ==================== /** - * Create a new script execution record + * Create a new admin process record * - * @param {Object} params - Execution creation parameters + * @param {Object} params - Process creation parameters * @param {string} params.scriptName - Name of script being executed * @param {string} [params.scriptVersion] - Script version * @param {string} params.trigger - Trigger type ('MANUAL', 'SCHEDULED', 'QUEUE', 'WEBHOOK') * @param {string} [params.mode] - Execution mode ('sync' or 'async', default 'async') * @param {Object} [params.input] - Input parameters * @param {Object} [params.audit] - Audit information (apiKeyName, apiKeyLast4, ipAddress) - * @returns {Promise} Created execution record + * @returns {Promise} Created admin process record */ - async createScriptExecution({ + async createAdminProcess({ scriptName, scriptVersion, trigger, @@ -55,7 +55,7 @@ function createAdminScriptCommands() { audit, }) { try { - const execution = await executionRepository.createExecution({ + const process = await adminProcessRepository.createAdminProcess({ scriptName, scriptVersion, trigger, @@ -63,46 +63,46 @@ function createAdminScriptCommands() { input, audit, }); - return execution; + return process; } catch (error) { return mapErrorToResponse(error); } }, /** - * Find a script execution by ID + * Find an admin process by ID * - * @param {string|number} executionId - The execution ID - * @returns {Promise} Execution record or error + * @param {string|number} processId - The admin process ID + * @returns {Promise} Admin process record or error */ - async findScriptExecutionById(executionId) { + async findAdminProcessById(processId) { try { - const execution = await executionRepository.findExecutionById(executionId); - if (!execution) { - const error = new Error(`Execution ${executionId} not found`); + const process = await adminProcessRepository.findAdminProcessById(processId); + if (!process) { + const error = new Error(`Execution ${processId} not found`); error.code = 'EXECUTION_NOT_FOUND'; return mapErrorToResponse(error); } - return execution; + return process; } catch (error) { return mapErrorToResponse(error); } }, /** - * Find all executions for a specific script + * Find all admin processes for a specific script * * @param {string} scriptName - Script name to filter by * @param {Object} [options] - Query options (limit, offset, sortBy, sortOrder) - * @returns {Promise} Array of execution records + * @returns {Promise} Array of admin process records */ - async findScriptExecutionsByName(scriptName, options = {}) { + async findAdminProcessesByName(scriptName, options = {}) { try { - const executions = await executionRepository.findExecutionsByScriptName( + const processes = await adminProcessRepository.findAdminProcessesByName( scriptName, options ); - return executions; + return processes; } catch (error) { // Return empty array on error (non-critical) return []; @@ -110,17 +110,17 @@ function createAdminScriptCommands() { }, /** - * Update execution status + * Update admin process state * - * @param {string|number} executionId - The execution ID - * @param {string} status - New status ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'TIMEOUT', 'CANCELLED') - * @returns {Promise} Updated execution record + * @param {string|number} processId - The admin process ID + * @param {string} state - New state ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED') + * @returns {Promise} Updated admin process record */ - async updateScriptExecutionStatus(executionId, status) { + async updateAdminProcessState(processId, state) { try { - const updated = await executionRepository.updateExecutionStatus( - executionId, - status + const updated = await adminProcessRepository.updateAdminProcessState( + processId, + state ); return updated; } catch (error) { @@ -129,16 +129,16 @@ function createAdminScriptCommands() { }, /** - * Append a log entry to an execution's log array + * Append a log entry to an admin process's results.logs array * - * @param {string|number} executionId - The execution ID + * @param {string|number} processId - The admin process ID * @param {Object} logEntry - Log entry { level, message, data, timestamp } - * @returns {Promise} Updated execution record + * @returns {Promise} Updated admin process record */ - async appendScriptExecutionLog(executionId, logEntry) { + async appendAdminProcessLog(processId, logEntry) { try { - const updated = await executionRepository.appendExecutionLog( - executionId, + const updated = await adminProcessRepository.appendAdminProcessLog( + processId, logEntry ); return updated; @@ -148,31 +148,31 @@ function createAdminScriptCommands() { }, /** - * Complete a script execution - * Updates status, output, error, and metrics + * Complete an admin process + * Updates state, output, error, and metrics * - * @param {string|number} executionId - The execution ID + * @param {string|number} processId - The admin process ID * @param {Object} params - Completion parameters - * @param {string} [params.status] - Final status ('COMPLETED', 'FAILED', 'TIMEOUT') - * @param {Object} [params.output] - Script output/result - * @param {Object} [params.error] - Error details { name, message, stack } - * @param {Object} [params.metrics] - Performance metrics { startTime, endTime, durationMs } + * @param {string} [params.state] - Final state ('COMPLETED', 'FAILED') + * @param {Object} [params.output] - Script output/result (stored in results.output) + * @param {Object} [params.error] - Error details { name, message, stack } (stored in results.error) + * @param {Object} [params.metrics] - Performance metrics { startTime, endTime, durationMs } (stored in results.metrics) * @returns {Promise} { success: true } or error */ - async completeScriptExecution(executionId, { status, output, error, metrics }) { + async completeAdminProcess(processId, { state, output, error, metrics }) { try { // Update each field independently (partial updates allowed) - if (status) { - await executionRepository.updateExecutionStatus(executionId, status); + if (state) { + await adminProcessRepository.updateAdminProcessState(processId, state); } if (output !== undefined) { - await executionRepository.updateExecutionOutput(executionId, output); + await adminProcessRepository.updateAdminProcessOutput(processId, output); } if (error) { - await executionRepository.updateExecutionError(executionId, error); + await adminProcessRepository.updateAdminProcessError(processId, error); } if (metrics) { - await executionRepository.updateExecutionMetrics(executionId, metrics); + await adminProcessRepository.updateAdminProcessMetrics(processId, metrics); } return { success: true }; @@ -182,21 +182,21 @@ function createAdminScriptCommands() { }, /** - * Find recent executions across all scripts + * Find recent admin processes across all scripts * * @param {Object} [options] - Query options * @param {number} [options.limit] - Maximum results (default 20) - * @param {string} [options.status] - Filter by status + * @param {string} [options.state] - Filter by state * @param {Date} [options.since] - Filter by created date - * @returns {Promise} Array of recent executions + * @returns {Promise} Array of recent admin processes */ - async findRecentExecutions(options = {}) { + async findRecentAdminProcesses(options = {}) { try { - const { limit = 20, status, since } = options; + const { limit = 20, state, since } = options; - // If status filter provided, use status query - if (status) { - return await executionRepository.findExecutionsByStatus(status, { + // If state filter provided, use state query + if (state) { + return await adminProcessRepository.findAdminProcessesByState(state, { limit, sortBy: 'createdAt', sortOrder: 'desc', @@ -204,7 +204,7 @@ function createAdminScriptCommands() { } // Otherwise, use generic recent query (would need to be added to interface) - // For now, fall back to empty array if no status filter + // For now, fall back to empty array if no state filter return []; } catch (error) { return []; diff --git a/packages/core/database/use-cases/check-database-state-use-case.js b/packages/core/database/use-cases/check-database-state-use-case.js index 3eeb26f6d..c2454711b 100644 --- a/packages/core/database/use-cases/check-database-state-use-case.js +++ b/packages/core/database/use-cases/check-database-state-use-case.js @@ -62,12 +62,12 @@ class CheckDatabaseStateUseCase { // Add error if present if (state.error) { response.error = state.error; - response.recommendation = 'Run POST /db-migrate to initialize database'; + response.recommendation = 'Run POST /admin/db-migrate to initialize database'; } // Add recommendation if migrations pending if (!state.upToDate && state.pendingMigrations > 0) { - response.recommendation = `Run POST /db-migrate to apply ${state.pendingMigrations} pending migration(s)`; + response.recommendation = `Run POST /admin/db-migrate to apply ${state.pendingMigrations} pending migration(s)`; } return response; diff --git a/packages/core/database/use-cases/check-database-state-use-case.test.js b/packages/core/database/use-cases/check-database-state-use-case.test.js index f88f06650..e8a54f590 100644 --- a/packages/core/database/use-cases/check-database-state-use-case.test.js +++ b/packages/core/database/use-cases/check-database-state-use-case.test.js @@ -60,7 +60,7 @@ describe('CheckDatabaseStateUseCase', () => { pendingMigrations: 3, dbType: 'postgresql', stage: 'prod', - recommendation: 'Run POST /db-migrate to apply 3 pending migration(s)', + recommendation: 'Run POST /admin/db-migrate to apply 3 pending migration(s)', }); }); @@ -78,7 +78,7 @@ describe('CheckDatabaseStateUseCase', () => { dbType: 'postgresql', stage: 'dev', error: 'Database not initialized', - recommendation: 'Run POST /db-migrate to initialize database', + recommendation: 'Run POST /admin/db-migrate to initialize database', }); }); diff --git a/packages/core/database/use-cases/get-database-state-via-worker-use-case.test.js b/packages/core/database/use-cases/get-database-state-via-worker-use-case.test.js index 5a839f748..83477bb82 100644 --- a/packages/core/database/use-cases/get-database-state-via-worker-use-case.test.js +++ b/packages/core/database/use-cases/get-database-state-via-worker-use-case.test.js @@ -59,7 +59,7 @@ describe('GetDatabaseStateViaWorkerUseCase', () => { pendingMigrations: 3, stage: 'prod', dbType: 'postgresql', - recommendation: 'Run POST /db-migrate to apply 3 pending migration(s).', + recommendation: 'Run POST /admin/db-migrate to apply 3 pending migration(s).', }); const result = await useCase.execute('prod'); @@ -69,7 +69,7 @@ describe('GetDatabaseStateViaWorkerUseCase', () => { pendingMigrations: 3, stage: 'prod', dbType: 'postgresql', - recommendation: 'Run POST /db-migrate to apply 3 pending migration(s).', + recommendation: 'Run POST /admin/db-migrate to apply 3 pending migration(s).', }); }); diff --git a/packages/core/database/use-cases/trigger-database-migration-use-case.js b/packages/core/database/use-cases/trigger-database-migration-use-case.js index a9099a764..08d8f767d 100644 --- a/packages/core/database/use-cases/trigger-database-migration-use-case.js +++ b/packages/core/database/use-cases/trigger-database-migration-use-case.js @@ -99,7 +99,7 @@ class TriggerDatabaseMigrationUseCase { success: true, migrationId: migrationStatus.migrationId, state: migrationStatus.state, - statusUrl: `/db-migrate/${migrationStatus.migrationId}`, + statusUrl: `/admin/db-migrate/${migrationStatus.migrationId}`, s3Key: `migrations/${migrationStatus.stage}/${migrationStatus.migrationId}.json`, message: 'Database migration queued successfully', }; diff --git a/packages/core/database/use-cases/trigger-database-migration-use-case.test.js b/packages/core/database/use-cases/trigger-database-migration-use-case.test.js index f50e9a7e0..47d3d0e30 100644 --- a/packages/core/database/use-cases/trigger-database-migration-use-case.test.js +++ b/packages/core/database/use-cases/trigger-database-migration-use-case.test.js @@ -105,7 +105,7 @@ describe('TriggerDatabaseMigrationUseCase', () => { success: true, migrationId: 'migration-123', state: 'INITIALIZING', - statusUrl: '/db-migrate/migration-123', + statusUrl: '/admin/db-migrate/migration-123', s3Key: expect.stringContaining('migrations/'), message: 'Database migration queued successfully', }); diff --git a/packages/core/handlers/routers/db-migration.handler.js b/packages/core/handlers/routers/db-migration.handler.js index cb1023f55..e5545427c 100644 --- a/packages/core/handlers/routers/db-migration.handler.js +++ b/packages/core/handlers/routers/db-migration.handler.js @@ -10,7 +10,7 @@ const serverlessHttp = require('serverless-http'); const express = require('express'); const cors = require('cors'); -const dbMigrationRouter = require('./db-migration'); +const { router: dbMigrationRouter } = require('./db-migration'); // Create minimal Express app const app = express(); diff --git a/packages/core/handlers/routers/db-migration.js b/packages/core/handlers/routers/db-migration.js index 28853c19c..e63e89ab7 100644 --- a/packages/core/handlers/routers/db-migration.js +++ b/packages/core/handlers/routers/db-migration.js @@ -4,12 +4,14 @@ * HTTP API for triggering and monitoring database migrations. * * Endpoints: - * - GET /db-migrate/status - Check if migrations are pending - * - POST /db-migrate - Trigger async migration (queues job) - * - GET /db-migrate/:processId - Check migration status + * - GET /admin/db-migrate/status - Check if migrations are pending + * - POST /admin/db-migrate - Trigger async migration (queues job) + * - GET /admin/db-migrate/:processId - Check migration status + * - POST /admin/db-migrate/resolve - Resolve failed migration * * Security: - * - Requires ADMIN_API_KEY header for all requests + * - Requires x-frigg-admin-api-key header for all requests + * - Uses shared validateAdminApiKey middleware * * Architecture: * - Router (Adapter Layer) → Use Cases (Domain) → Repositories (Infrastructure) @@ -18,6 +20,7 @@ const { Router } = require('express'); const catchAsyncError = require('express-async-handler'); +const { validateAdminApiKey } = require('../middleware/admin-auth'); const { MigrationStatusRepositoryS3 } = require('../../database/repositories/migration-status-repository-s3'); const { TriggerDatabaseMigrationUseCase, @@ -56,29 +59,11 @@ const getDatabaseStateUseCase = new GetDatabaseStateViaWorkerUseCase({ workerFunctionName, }); -/** - * Admin API key validation middleware - * Matches pattern from health.js:72-88 - */ -const validateApiKey = (req, res, next) => { - const apiKey = req.headers['x-frigg-admin-api-key']; - - if (!apiKey || apiKey !== process.env.ADMIN_API_KEY) { - console.error('Unauthorized access attempt to db-migrate endpoint'); - return res.status(401).json({ - status: 'error', - message: 'Unauthorized - x-frigg-admin-api-key header required', - }); - } - - next(); -}; - -// Apply API key validation to all routes -router.use(validateApiKey); +// Apply admin API key validation to all routes (shared middleware) +router.use(validateAdminApiKey); /** - * POST /db-migrate + * POST /admin/db-migrate * * Trigger database migration (async via SQS queue) * @@ -99,7 +84,7 @@ router.use(validateApiKey); * } */ router.post( - '/db-migrate', + '/admin/db-migrate', catchAsyncError(async (req, res) => { const dbType = req.body.dbType || process.env.DB_TYPE || 'postgresql'; const { stage } = req.body; @@ -133,10 +118,10 @@ router.post( ); /** - * GET /db-migrate/status + * GET /admin/db-migrate/status * * Check if database has pending migrations - * + * * Query params: * - stage: string (optional, defaults to STAGE env var or 'production') * @@ -151,7 +136,7 @@ router.post( * } */ router.get( - '/db-migrate/status', + '/admin/db-migrate/status', catchAsyncError(async (req, res) => { const stage = req.query.stage || process.env.STAGE || 'production'; @@ -177,7 +162,7 @@ router.get( ); /** - * GET /db-migrate/:migrationId + * GET /admin/db-migrate/:migrationId * * Get migration status by migration ID * @@ -201,7 +186,7 @@ router.get( * } */ router.get( - '/db-migrate/:migrationId', + '/admin/db-migrate/:migrationId', catchAsyncError(async (req, res) => { const { migrationId } = req.params; const stage = req.query.stage || process.env.STAGE || 'production'; @@ -236,7 +221,7 @@ router.get( ); /** - * POST /db-migrate/resolve + * POST /admin/db-migrate/resolve * * Resolve a failed migration by marking it as applied or rolled back * @@ -256,7 +241,7 @@ router.get( * } */ router.post( - '/db-migrate/resolve', + '/admin/db-migrate/resolve', catchAsyncError(async (req, res) => { const { migrationName, action = 'applied' } = req.body; diff --git a/packages/core/handlers/routers/db-migration.test.js b/packages/core/handlers/routers/db-migration.test.js index 7cd87f808..5cb350ba5 100644 --- a/packages/core/handlers/routers/db-migration.test.js +++ b/packages/core/handlers/routers/db-migration.test.js @@ -47,7 +47,7 @@ describe('Database Migration Router - Adapter Layer', () => { // Test will pass if handler doesn't crash when dbType is omitted from request }); - describe('GET /db-migrate/status endpoint', () => { + describe('GET /admin/db-migrate/status endpoint', () => { it('should have status endpoint registered', () => { const router = require('./db-migration').router; const routes = router.stack @@ -57,7 +57,7 @@ describe('Database Migration Router - Adapter Layer', () => { methods: Object.keys(layer.route.methods), })); - const statusRoute = routes.find(r => r.path === '/db-migrate/status'); + const statusRoute = routes.find(r => r.path === '/admin/db-migrate/status'); expect(statusRoute).toBeDefined(); expect(statusRoute.methods).toContain('get'); }); From c3db4b1cd98fbd9dc8c59a619b19e671e0cb2387 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 04:16:26 +0000 Subject: [PATCH 25/33] refactor(admin-scripts): simplify dry-run and clean up dependencies Address PR feedback from Daniel: 1. Simplified dry-run implementation: - Deleted over-engineered dry-run-repository-wrapper.js (262 lines) - Deleted over-engineered dry-run-http-interceptor.js (297 lines) - New dry-run validates inputs and returns preview without executing - Added JSON schema validation for script parameters - Net reduction: ~990 lines removed 2. Cleaned up package.json: - Removed unused mongoose dependency - Removed unused chai devDependency - Kept supertest for Express route testing (different from nock) 3. Documented admin-script-commands architecture: - Explained why separate from integration-commands - integration-commands: user-context operations (requires integrationClass) - admin-script-commands: system operations (no user context) - Separation follows SRP and avoids coupling Tests: 262 passing --- packages/admin-scripts/package.json | 2 - .../dry-run-http-interceptor.test.js | 313 ------------------ .../dry-run-repository-wrapper.test.js | 257 -------------- .../__tests__/script-runner.test.js | 134 +++++++- .../application/dry-run-http-interceptor.js | 296 ----------------- .../application/dry-run-repository-wrapper.js | 261 --------------- .../src/application/script-runner.js | 246 +++++++------- .../commands/admin-script-commands.js | 14 + 8 files changed, 266 insertions(+), 1257 deletions(-) delete mode 100644 packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js delete mode 100644 packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js delete mode 100644 packages/admin-scripts/src/application/dry-run-http-interceptor.js delete mode 100644 packages/admin-scripts/src/application/dry-run-repository-wrapper.js diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json index e20c83d34..8f7388119 100644 --- a/packages/admin-scripts/package.json +++ b/packages/admin-scripts/package.json @@ -9,7 +9,6 @@ "bcryptjs": "^2.4.3", "express": "^4.18.2", "lodash": "4.17.21", - "mongoose": "6.11.6", "serverless-http": "^3.2.0", "uuid": "^9.0.1" }, @@ -17,7 +16,6 @@ "@friggframework/eslint-config": "^2.0.0-next.0", "@friggframework/prettier-config": "^2.0.0-next.0", "@friggframework/test": "^2.0.0-next.0", - "chai": "^4.3.6", "eslint": "^8.22.0", "jest": "^29.7.0", "prettier": "^2.7.1", diff --git a/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js b/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js deleted file mode 100644 index 498031649..000000000 --- a/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js +++ /dev/null @@ -1,313 +0,0 @@ -const { - createDryRunHttpClient, - injectDryRunHttpClient, - sanitizeHeaders, - sanitizeData, - detectService, -} = require('../dry-run-http-interceptor'); - -describe('Dry-Run HTTP Interceptor', () => { - describe('sanitizeHeaders', () => { - test('should redact authorization headers', () => { - const headers = { - 'Content-Type': 'application/json', - Authorization: 'Bearer secret-token', - 'X-API-Key': 'api-key-123', - 'User-Agent': 'frigg/1.0', - }; - - const sanitized = sanitizeHeaders(headers); - - expect(sanitized['Content-Type']).toBe('application/json'); - expect(sanitized['User-Agent']).toBe('frigg/1.0'); - expect(sanitized.Authorization).toBe('[REDACTED]'); - expect(sanitized['X-API-Key']).toBe('[REDACTED]'); - }); - - test('should handle case variations', () => { - const headers = { - authorization: 'Bearer token', - Authorization: 'Bearer token', - 'x-api-key': 'key1', - 'X-API-Key': 'key2', - }; - - const sanitized = sanitizeHeaders(headers); - - expect(sanitized.authorization).toBe('[REDACTED]'); - expect(sanitized.Authorization).toBe('[REDACTED]'); - expect(sanitized['x-api-key']).toBe('[REDACTED]'); - expect(sanitized['X-API-Key']).toBe('[REDACTED]'); - }); - - test('should handle null/undefined', () => { - expect(sanitizeHeaders(null)).toEqual({}); - expect(sanitizeHeaders(undefined)).toEqual({}); - expect(sanitizeHeaders({})).toEqual({}); - }); - }); - - describe('detectService', () => { - test('should detect CRM services', () => { - expect(detectService('https://api.hubapi.com')).toBe('HubSpot'); - expect(detectService('https://login.salesforce.com')).toBe('Salesforce'); - expect(detectService('https://api.pipedrive.com')).toBe('Pipedrive'); - expect(detectService('https://api.attio.com')).toBe('Attio'); - }); - - test('should detect communication services', () => { - expect(detectService('https://slack.com/api')).toBe('Slack'); - expect(detectService('https://discord.com/api')).toBe('Discord'); - expect(detectService('https://graph.teams.microsoft.com')).toBe('Microsoft Teams'); - }); - - test('should detect project management tools', () => { - expect(detectService('https://app.asana.com/api')).toBe('Asana'); - expect(detectService('https://api.monday.com')).toBe('Monday.com'); - expect(detectService('https://api.trello.com')).toBe('Trello'); - }); - - test('should return unknown for unrecognized services', () => { - expect(detectService('https://example.com/api')).toBe('unknown'); - expect(detectService(null)).toBe('unknown'); - expect(detectService(undefined)).toBe('unknown'); - }); - - test('should be case insensitive', () => { - expect(detectService('HTTPS://API.HUBSPOT.COM')).toBe('HubSpot'); - expect(detectService('https://API.SLACK.COM')).toBe('Slack'); - }); - }); - - describe('sanitizeData', () => { - test('should redact sensitive fields', () => { - const data = { - name: 'Test User', - email: 'test@example.com', - password: 'secret123', - apiToken: 'token-abc', - authKey: 'key-xyz', - }; - - const sanitized = sanitizeData(data); - - expect(sanitized.name).toBe('Test User'); - expect(sanitized.email).toBe('test@example.com'); - expect(sanitized.password).toBe('[REDACTED]'); - expect(sanitized.apiToken).toBe('[REDACTED]'); - expect(sanitized.authKey).toBe('[REDACTED]'); - }); - - test('should handle nested objects', () => { - const data = { - user: { - name: 'Test', - credentials: { - password: 'secret', - token: 'abc123', - }, - }, - }; - - const sanitized = sanitizeData(data); - - expect(sanitized.user.name).toBe('Test'); - expect(sanitized.user.credentials.password).toBe('[REDACTED]'); - expect(sanitized.user.credentials.token).toBe('[REDACTED]'); - }); - - test('should handle arrays', () => { - const data = [ - { id: '1', password: 'secret1' }, - { id: '2', apiKey: 'key2' }, - ]; - - const sanitized = sanitizeData(data); - - expect(sanitized[0].id).toBe('1'); - expect(sanitized[0].password).toBe('[REDACTED]'); - expect(sanitized[1].apiKey).toBe('[REDACTED]'); - }); - - test('should preserve primitives', () => { - expect(sanitizeData('string')).toBe('string'); - expect(sanitizeData(123)).toBe(123); - expect(sanitizeData(true)).toBe(true); - expect(sanitizeData(null)).toBe(null); - expect(sanitizeData(undefined)).toBe(undefined); - }); - }); - - describe('createDryRunHttpClient', () => { - let operationLog; - - beforeEach(() => { - operationLog = []; - }); - - test('should log GET requests', async () => { - const client = createDryRunHttpClient(operationLog); - - const response = await client.get('/contacts', { - baseURL: 'https://api.hubapi.com', - headers: { Authorization: 'Bearer token' }, - }); - - expect(operationLog).toHaveLength(1); - expect(operationLog[0]).toMatchObject({ - operation: 'HTTP_REQUEST', - method: 'GET', - url: 'https://api.hubapi.com/contacts', - service: 'HubSpot', - }); - - expect(operationLog[0].headers.Authorization).toBe('[REDACTED]'); - expect(response.data._dryRun).toBe(true); - }); - - test('should log POST requests with data', async () => { - const client = createDryRunHttpClient(operationLog); - - const postData = { - name: 'John Doe', - email: 'john@example.com', - password: 'secret123', - }; - - await client.post('/users', postData, { - baseURL: 'https://api.example.com', - }); - - expect(operationLog).toHaveLength(1); - expect(operationLog[0].method).toBe('POST'); - expect(operationLog[0].data.name).toBe('John Doe'); - expect(operationLog[0].data.email).toBe('john@example.com'); - expect(operationLog[0].data.password).toBe('[REDACTED]'); - }); - - test('should log PUT requests', async () => { - const client = createDryRunHttpClient(operationLog); - - await client.put('/users/123', { status: 'active' }, { - baseURL: 'https://api.example.com', - }); - - expect(operationLog).toHaveLength(1); - expect(operationLog[0].method).toBe('PUT'); - expect(operationLog[0].data.status).toBe('active'); - }); - - test('should log PATCH requests', async () => { - const client = createDryRunHttpClient(operationLog); - - await client.patch('/users/123', { name: 'Updated' }); - - expect(operationLog).toHaveLength(1); - expect(operationLog[0].method).toBe('PATCH'); - }); - - test('should log DELETE requests', async () => { - const client = createDryRunHttpClient(operationLog); - - await client.delete('/users/123', { - baseURL: 'https://api.example.com', - }); - - expect(operationLog).toHaveLength(1); - expect(operationLog[0].method).toBe('DELETE'); - }); - - test('should return mock response', async () => { - const client = createDryRunHttpClient(operationLog); - - const response = await client.get('/test'); - - expect(response.status).toBe(200); - expect(response.statusText).toContain('Dry-Run'); - expect(response.data._dryRun).toBe(true); - expect(response.headers['x-dry-run']).toBe('true'); - }); - - test('should include query params in log', async () => { - const client = createDryRunHttpClient(operationLog); - - await client.get('/search', { - baseURL: 'https://api.example.com', - params: { q: 'test', limit: 10 }, - }); - - expect(operationLog[0].params).toEqual({ q: 'test', limit: 10 }); - }); - }); - - describe('injectDryRunHttpClient', () => { - let operationLog; - let dryRunClient; - - beforeEach(() => { - operationLog = []; - dryRunClient = createDryRunHttpClient(operationLog); - }); - - test('should inject into primary API module', () => { - const integrationInstance = { - primary: { - api: { - _httpClient: { get: jest.fn() }, - }, - }, - }; - - injectDryRunHttpClient(integrationInstance, dryRunClient); - - expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient); - }); - - test('should inject into target API module', () => { - const integrationInstance = { - target: { - api: { - _httpClient: { get: jest.fn() }, - }, - }, - }; - - injectDryRunHttpClient(integrationInstance, dryRunClient); - - expect(integrationInstance.target.api._httpClient).toBe(dryRunClient); - }); - - test('should inject into both primary and target', () => { - const integrationInstance = { - primary: { - api: { _httpClient: { get: jest.fn() } }, - }, - target: { - api: { _httpClient: { get: jest.fn() } }, - }, - }; - - injectDryRunHttpClient(integrationInstance, dryRunClient); - - expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient); - expect(integrationInstance.target.api._httpClient).toBe(dryRunClient); - }); - - test('should handle missing api modules gracefully', () => { - const integrationInstance = { - primary: {}, - target: null, - }; - - expect(() => { - injectDryRunHttpClient(integrationInstance, dryRunClient); - }).not.toThrow(); - }); - - test('should handle null integration instance', () => { - expect(() => { - injectDryRunHttpClient(null, dryRunClient); - }).not.toThrow(); - }); - }); -}); diff --git a/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js b/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js deleted file mode 100644 index 4d3f9eb5d..000000000 --- a/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js +++ /dev/null @@ -1,257 +0,0 @@ -const { createDryRunWrapper, wrapAdminFriggCommandsForDryRun, sanitizeArgs } = require('../dry-run-repository-wrapper'); - -describe('Dry-Run Repository Wrapper', () => { - describe('createDryRunWrapper', () => { - let mockRepository; - let operationLog; - - beforeEach(() => { - operationLog = []; - mockRepository = { - // Read operations - findById: jest.fn(async (id) => ({ id, name: 'Test Entity' })), - findAll: jest.fn(async () => [{ id: '1' }, { id: '2' }]), - getStatus: jest.fn(() => 'active'), - - // Write operations - create: jest.fn(async (data) => ({ id: 'new-id', ...data })), - update: jest.fn(async (id, data) => ({ id, ...data })), - delete: jest.fn(async (id) => ({ deletedCount: 1 })), - updateStatus: jest.fn(async (id, status) => ({ id, status })), - }; - }); - - test('should pass through read operations unchanged', async () => { - const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); - - // Call read operations - const byId = await wrapped.findById('123'); - const all = await wrapped.findAll(); - const status = wrapped.getStatus(); - - // Verify original methods were called - expect(mockRepository.findById).toHaveBeenCalledWith('123'); - expect(mockRepository.findAll).toHaveBeenCalled(); - expect(mockRepository.getStatus).toHaveBeenCalled(); - - // Verify results match - expect(byId).toEqual({ id: '123', name: 'Test Entity' }); - expect(all).toHaveLength(2); - expect(status).toBe('active'); - - // No operations should be logged - expect(operationLog).toHaveLength(0); - }); - - test('should intercept and log write operations', async () => { - const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); - - // Call write operations - await wrapped.create({ name: 'New Entity' }); - await wrapped.update('123', { name: 'Updated' }); - await wrapped.delete('456'); - - // Original write methods should NOT be called - expect(mockRepository.create).not.toHaveBeenCalled(); - expect(mockRepository.update).not.toHaveBeenCalled(); - expect(mockRepository.delete).not.toHaveBeenCalled(); - - // All operations should be logged - expect(operationLog).toHaveLength(3); - - expect(operationLog[0]).toMatchObject({ - operation: 'CREATE', - model: 'TestModel', - method: 'create', - }); - - expect(operationLog[1]).toMatchObject({ - operation: 'UPDATE', - model: 'TestModel', - method: 'update', - }); - - expect(operationLog[2]).toMatchObject({ - operation: 'DELETE', - model: 'TestModel', - method: 'delete', - }); - }); - - test('should return mock data for create operations', async () => { - const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); - - const result = await wrapped.create({ name: 'Test', value: 42 }); - - expect(result).toMatchObject({ - name: 'Test', - value: 42, - _dryRun: true, - }); - - expect(result.id).toMatch(/^dry-run-/); - expect(result.createdAt).toBeDefined(); - }); - - test('should return mock data for update operations', async () => { - const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); - - const result = await wrapped.update('123', { status: 'inactive' }); - - expect(result).toMatchObject({ - id: '123', - status: 'inactive', - _dryRun: true, - }); - }); - - test('should return mock data for delete operations', async () => { - const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); - - const result = await wrapped.delete('123'); - - expect(result).toEqual({ - deletedCount: 1, - _dryRun: true, - }); - }); - - test('should try to return existing data for updates when possible', async () => { - const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); - - const result = await wrapped.updateStatus('123', 'inactive'); - - // Should attempt to read existing data - expect(mockRepository.findById).toHaveBeenCalledWith('123'); - - // If found, should return existing merged with updates - expect(result.id).toBe('123'); - }); - }); - - describe('sanitizeArgs', () => { - test('should redact sensitive fields in objects', () => { - const args = [ - { - id: '123', - password: 'secret123', - token: 'abc-def-ghi', - apiKey: 'sk_live_123', - name: 'Test User', - }, - ]; - - const sanitized = sanitizeArgs(args); - - expect(sanitized[0]).toEqual({ - id: '123', - password: '[REDACTED]', - token: '[REDACTED]', - apiKey: '[REDACTED]', - name: 'Test User', - }); - }); - - test('should handle nested objects', () => { - const args = [ - { - user: { - name: 'Test', - credentials: { - password: 'secret', - apiToken: 'token123', - }, - }, - }, - ]; - - const sanitized = sanitizeArgs(args); - - expect(sanitized[0].user.name).toBe('Test'); - expect(sanitized[0].user.credentials.password).toBe('[REDACTED]'); - expect(sanitized[0].user.credentials.apiToken).toBe('[REDACTED]'); - }); - - test('should handle arrays', () => { - const args = [ - [ - { id: '1', token: 'abc' }, - { id: '2', secret: 'xyz' }, - ], - ]; - - const sanitized = sanitizeArgs(args); - - expect(sanitized[0][0].token).toBe('[REDACTED]'); - expect(sanitized[0][1].secret).toBe('[REDACTED]'); - }); - - test('should preserve primitives', () => { - const args = ['string', 123, true, null, undefined]; - const sanitized = sanitizeArgs(args); - - expect(sanitized).toEqual(['string', 123, true, null, undefined]); - }); - }); - - describe('wrapAdminFriggCommandsForDryRun', () => { - let mockCommands; - let operationLog; - - beforeEach(() => { - operationLog = []; - mockCommands = { - // Read operations - findIntegrationById: jest.fn(async (id) => ({ id, status: 'active' })), - listIntegrations: jest.fn(async () => []), - - // Write operations - updateIntegrationConfig: jest.fn(async (id, config) => ({ id, config })), - updateIntegrationStatus: jest.fn(async (id, status) => ({ id, status })), - updateCredential: jest.fn(async (id, updates) => ({ id, ...updates })), - - // Other methods - log: jest.fn(), - }; - }); - - test('should pass through read operations', async () => { - const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); - - const integration = await wrapped.findIntegrationById('123'); - const list = await wrapped.listIntegrations(); - - expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123'); - expect(mockCommands.listIntegrations).toHaveBeenCalled(); - - expect(integration.id).toBe('123'); - expect(operationLog).toHaveLength(0); - }); - - test('should intercept write operations', async () => { - const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); - - await wrapped.updateIntegrationConfig('123', { setting: 'value' }); - await wrapped.updateIntegrationStatus('456', 'inactive'); - - expect(mockCommands.updateIntegrationConfig).not.toHaveBeenCalled(); - expect(mockCommands.updateIntegrationStatus).not.toHaveBeenCalled(); - - expect(operationLog).toHaveLength(2); - expect(operationLog[0].operation).toBe('UPDATEINTEGRATIONCONFIG'); - expect(operationLog[1].operation).toBe('UPDATEINTEGRATIONSTATUS'); - }); - - test('should return existing data for known update methods', async () => { - const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); - - const result = await wrapped.updateIntegrationConfig('123', { new: 'config' }); - - // Should have tried to fetch existing - expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123'); - - // Should return existing data - expect(result.id).toBe('123'); - }); - }); -}); diff --git a/packages/admin-scripts/src/application/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js index 41a38dbb5..c2038ad75 100644 --- a/packages/admin-scripts/src/application/__tests__/script-runner.test.js +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -93,7 +93,7 @@ describe('ScriptRunner', () => { expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith( 'exec-123', expect.objectContaining({ - status: 'COMPLETED', + state: 'COMPLETED', output: { success: true, params: { foo: 'bar' } }, metrics: expect.objectContaining({ durationMs: expect.any(Number), @@ -131,7 +131,7 @@ describe('ScriptRunner', () => { expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith( 'exec-123', expect.objectContaining({ - status: 'FAILED', + state: 'FAILED', error: expect.objectContaining({ message: 'Script failed', }), @@ -186,6 +186,136 @@ describe('ScriptRunner', () => { }); }); + describe('dry-run mode', () => { + it('should return preview without executing script', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('test-script', { foo: 'bar' }, { + trigger: 'MANUAL', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.status).toBe('DRY_RUN_VALID'); + expect(result.scriptName).toBe('test-script'); + expect(result.preview.script.name).toBe('test-script'); + expect(result.preview.script.version).toBe('1.0.0'); + expect(result.preview.input).toEqual({ foo: 'bar' }); + expect(result.message).toContain('validation passed'); + + // Should NOT create execution record or call commands + expect(mockCommands.createAdminProcess).not.toHaveBeenCalled(); + expect(mockCommands.updateAdminProcessState).not.toHaveBeenCalled(); + expect(mockCommands.completeAdminProcess).not.toHaveBeenCalled(); + }); + + it('should validate required parameters in dry-run', async () => { + class SchemaScript extends AdminScriptBase { + static Definition = { + name: 'schema-script', + version: '1.0.0', + description: 'Script with schema', + inputSchema: { + type: 'object', + required: ['requiredParam'], + properties: { + requiredParam: { type: 'string' }, + optionalParam: { type: 'number' }, + }, + }, + }; + + async execute() { + return {}; + } + } + + scriptFactory.register(SchemaScript); + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + // Missing required parameter + const result = await runner.execute('schema-script', {}, { + dryRun: true, + }); + + expect(result.status).toBe('DRY_RUN_INVALID'); + expect(result.preview.validation.valid).toBe(false); + expect(result.preview.validation.errors).toContain('Missing required parameter: requiredParam'); + }); + + it('should validate parameter types in dry-run', async () => { + class TypedScript extends AdminScriptBase { + static Definition = { + name: 'typed-script', + version: '1.0.0', + description: 'Script with typed params', + inputSchema: { + type: 'object', + properties: { + count: { type: 'integer' }, + name: { type: 'string' }, + enabled: { type: 'boolean' }, + }, + }, + }; + + async execute() { + return {}; + } + } + + scriptFactory.register(TypedScript); + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('typed-script', { + count: 'not-a-number', + name: 123, + enabled: 'true', + }, { + dryRun: true, + }); + + expect(result.status).toBe('DRY_RUN_INVALID'); + expect(result.preview.validation.errors).toHaveLength(3); + }); + + it('should pass validation with correct parameters', async () => { + class ValidScript extends AdminScriptBase { + static Definition = { + name: 'valid-script', + version: '1.0.0', + description: 'Script for validation', + inputSchema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + count: { type: 'integer' }, + }, + }, + }; + + async execute() { + return {}; + } + } + + scriptFactory.register(ValidScript); + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('valid-script', { + name: 'test', + count: 42, + }, { + dryRun: true, + }); + + expect(result.status).toBe('DRY_RUN_VALID'); + expect(result.preview.validation.valid).toBe(true); + expect(result.preview.validation.errors).toHaveLength(0); + }); + }); + describe('createScriptRunner()', () => { it('should create runner with default factory', () => { const runner = createScriptRunner(); diff --git a/packages/admin-scripts/src/application/dry-run-http-interceptor.js b/packages/admin-scripts/src/application/dry-run-http-interceptor.js deleted file mode 100644 index 9b9aba65c..000000000 --- a/packages/admin-scripts/src/application/dry-run-http-interceptor.js +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Dry-Run HTTP Interceptor - * - * Creates a mock HTTP client that logs requests instead of executing them. - * Used to intercept API module calls during dry-run. - */ - -/** - * Sanitize headers to remove authentication tokens - * @param {Object} headers - HTTP headers - * @returns {Object} Sanitized headers - */ -function sanitizeHeaders(headers) { - if (!headers || typeof headers !== 'object') { - return {}; - } - - const safe = { ...headers }; - - // Remove common auth headers - const sensitiveHeaders = [ - 'authorization', - 'Authorization', - 'x-api-key', - 'X-API-Key', - 'x-auth-token', - 'X-Auth-Token', - 'api-key', - 'API-Key', - 'apikey', - 'ApiKey', - 'token', - 'Token', - ]; - - for (const header of sensitiveHeaders) { - if (safe[header]) { - safe[header] = '[REDACTED]'; - } - } - - return safe; -} - -/** - * Detect service name from base URL - * @param {string} baseURL - Base URL of the API - * @returns {string} Service name - */ -function detectService(baseURL) { - if (!baseURL) return 'unknown'; - - const url = baseURL.toLowerCase(); - - // CRM Systems - if (url.includes('hubspot') || url.includes('hubapi')) return 'HubSpot'; - if (url.includes('salesforce')) return 'Salesforce'; - if (url.includes('pipedrive')) return 'Pipedrive'; - if (url.includes('zoho')) return 'Zoho CRM'; - if (url.includes('attio')) return 'Attio'; - - // Communication - if (url.includes('slack')) return 'Slack'; - if (url.includes('discord')) return 'Discord'; - if (url.includes('teams.microsoft')) return 'Microsoft Teams'; - - // Project Management - if (url.includes('asana')) return 'Asana'; - if (url.includes('monday')) return 'Monday.com'; - if (url.includes('trello')) return 'Trello'; - if (url.includes('clickup')) return 'ClickUp'; - - // Storage - if (url.includes('googleapis.com/drive')) return 'Google Drive'; - if (url.includes('dropbox')) return 'Dropbox'; - if (url.includes('box.com')) return 'Box'; - - // Email & Marketing - if (url.includes('sendgrid')) return 'SendGrid'; - if (url.includes('mailchimp')) return 'Mailchimp'; - if (url.includes('gmail')) return 'Gmail'; - - // Accounting - if (url.includes('quickbooks')) return 'QuickBooks'; - if (url.includes('xero')) return 'Xero'; - - // Other - if (url.includes('stripe')) return 'Stripe'; - if (url.includes('shopify')) return 'Shopify'; - if (url.includes('github')) return 'GitHub'; - if (url.includes('gitlab')) return 'GitLab'; - - return 'unknown'; -} - -/** - * Sanitize request data to remove sensitive information - * @param {*} data - Request data - * @returns {*} Sanitized data - */ -function sanitizeData(data) { - if (data === null || data === undefined) { - return data; - } - - if (typeof data !== 'object') { - return data; - } - - if (Array.isArray(data)) { - return data.map(sanitizeData); - } - - const sanitized = {}; - for (const [key, value] of Object.entries(data)) { - const lowerKey = key.toLowerCase(); - - // Check if this is a leaf node that should be redacted - const isSensitiveField = - lowerKey === 'password' || - lowerKey === 'token' || - lowerKey === 'secret' || - lowerKey === 'apikey' || - lowerKey.endsWith('password') || - lowerKey.endsWith('token') || - lowerKey.endsWith('secret') || - lowerKey.endsWith('key') && !lowerKey.endsWith('publickey'); - - // Only redact if it's a primitive value (not an object/array) - if (isSensitiveField && typeof value !== 'object') { - sanitized[key] = '[REDACTED]'; - continue; - } - - // Recursively sanitize nested objects - if (typeof value === 'object' && value !== null) { - sanitized[key] = sanitizeData(value); - } else { - sanitized[key] = value; - } - } - - return sanitized; -} - -/** - * Create a dry-run HTTP client - * - * @param {Array} operationLog - Array to append logged HTTP requests - * @returns {Object} Mock HTTP client compatible with axios interface - */ -function createDryRunHttpClient(operationLog) { - /** - * Mock HTTP request handler - * @param {Object} config - Request configuration - * @returns {Promise} Mock response - */ - const mockRequest = async (config) => { - // Build full URL - let fullUrl = config.url; - if (config.baseURL && !config.url.startsWith('http')) { - fullUrl = `${config.baseURL}${config.url.startsWith('/') ? '' : '/'}${config.url}`; - } - - // Log the request that WOULD have been made - const logEntry = { - operation: 'HTTP_REQUEST', - method: (config.method || 'GET').toUpperCase(), - url: fullUrl, - baseURL: config.baseURL, - path: config.url, - service: detectService(config.baseURL || fullUrl), - headers: sanitizeHeaders(config.headers), - timestamp: new Date().toISOString(), - }; - - // Include request data for write operations - if (config.data && ['POST', 'PUT', 'PATCH'].includes(logEntry.method)) { - logEntry.data = sanitizeData(config.data); - } - - // Include query params - if (config.params) { - logEntry.params = sanitizeData(config.params); - } - - operationLog.push(logEntry); - - // Return mock response - return { - status: 200, - statusText: 'OK (Dry-Run)', - data: { - _dryRun: true, - _message: 'This is a dry-run mock response', - _wouldHaveExecuted: `${logEntry.method} ${fullUrl}`, - _service: logEntry.service, - }, - headers: { - 'content-type': 'application/json', - 'x-dry-run': 'true', - }, - config, - }; - }; - - // Return axios-compatible interface - return { - request: mockRequest, - get: (url, config = {}) => mockRequest({ ...config, method: 'GET', url }), - post: (url, data, config = {}) => mockRequest({ ...config, method: 'POST', url, data }), - put: (url, data, config = {}) => mockRequest({ ...config, method: 'PUT', url, data }), - patch: (url, data, config = {}) => - mockRequest({ ...config, method: 'PATCH', url, data }), - delete: (url, config = {}) => mockRequest({ ...config, method: 'DELETE', url }), - head: (url, config = {}) => mockRequest({ ...config, method: 'HEAD', url }), - options: (url, config = {}) => mockRequest({ ...config, method: 'OPTIONS', url }), - - // Axios-specific properties - defaults: { - headers: { - common: {}, - get: {}, - post: {}, - put: {}, - patch: {}, - delete: {}, - }, - }, - - // Interceptors (no-op in dry-run) - interceptors: { - request: { use: () => {}, eject: () => {} }, - response: { use: () => {}, eject: () => {} }, - }, - }; -} - -/** - * Inject dry-run HTTP client into an integration instance - * - * @param {Object} integrationInstance - Integration instance from integrationFactory - * @param {Object} dryRunHttpClient - Dry-run HTTP client - */ -function injectDryRunHttpClient(integrationInstance, dryRunHttpClient) { - if (!integrationInstance) { - return; - } - - // Inject into primary API module - if (integrationInstance.primary?.api) { - injectIntoApiModule(integrationInstance.primary.api, dryRunHttpClient); - } - - // Inject into target API module - if (integrationInstance.target?.api) { - injectIntoApiModule(integrationInstance.target.api, dryRunHttpClient); - } -} - -/** - * Inject dry-run HTTP client into an API module - * @param {Object} apiModule - API module instance - * @param {Object} dryRunHttpClient - Dry-run HTTP client - */ -function injectIntoApiModule(apiModule, dryRunHttpClient) { - // Common property names for HTTP clients in API modules - const httpClientProps = [ - '_httpClient', - 'httpClient', - 'client', - 'axios', - 'request', - 'api', - 'http', - ]; - - for (const prop of httpClientProps) { - if (apiModule[prop] && typeof apiModule[prop] === 'object') { - apiModule[prop] = dryRunHttpClient; - } - } - - // Also check if the API module itself has request methods - if (typeof apiModule.request === 'function') { - Object.assign(apiModule, dryRunHttpClient); - } -} - -module.exports = { - createDryRunHttpClient, - injectDryRunHttpClient, - sanitizeHeaders, - sanitizeData, - detectService, -}; diff --git a/packages/admin-scripts/src/application/dry-run-repository-wrapper.js b/packages/admin-scripts/src/application/dry-run-repository-wrapper.js deleted file mode 100644 index b94a35803..000000000 --- a/packages/admin-scripts/src/application/dry-run-repository-wrapper.js +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Dry-Run Repository Wrapper - * - * Wraps any repository to intercept write operations. - * - READ operations pass through unchanged - * - WRITE operations are logged but not executed - * - * Uses Proxy pattern for dynamic method interception - */ - -/** - * Create a dry-run wrapper for any repository - * - * @param {Object} repository - The real repository to wrap - * @param {Array} operationLog - Array to append logged operations - * @param {string} modelName - Name of the model (for logging) - * @returns {Proxy} Wrapped repository that logs write operations - */ -function createDryRunWrapper(repository, operationLog, modelName) { - return new Proxy(repository, { - get(target, prop) { - const value = target[prop]; - - // Return non-function properties as-is - if (typeof value !== 'function') { - return value; - } - - // Identify write operations by name pattern - const writePatterns = /^(create|update|delete|upsert|append|remove|insert|save)/i; - const isWrite = writePatterns.test(prop); - - // Pass through read operations - if (!isWrite) { - return value.bind(target); - } - - // Wrap write operation - return async (...args) => { - // Log the operation that WOULD have been performed - operationLog.push({ - operation: prop.toUpperCase(), - model: modelName, - method: prop, - args: sanitizeArgs(args), - timestamp: new Date().toISOString(), - wouldExecute: `${modelName}.${prop}()`, - }); - - // For write operations, try to return existing data or mock data - // This helps scripts continue executing without errors - - // For updates, try to return existing data - if (prop.includes('update') || prop.includes('upsert')) { - // Try to extract ID from first argument - const possibleId = args[0]; - let existing = null; - - if (possibleId && typeof possibleId === 'string') { - // Try to find existing record - const findMethod = getFindMethod(target, prop); - if (findMethod) { - try { - existing = await findMethod.call(target, possibleId); - } catch (err) { - // Ignore errors, continue to mock - } - } - } - - // Return merged data - if (existing) { - // Merge update data with existing - return { ...existing, ...args[1], _dryRun: true }; - } - - // No existing data, return mock - if (args[1]) { - return { id: possibleId, ...args[1], _dryRun: true }; - } - - return { id: possibleId, _dryRun: true }; - } - - // For creates, return mock object with the data - if (prop.includes('create') || prop.includes('insert')) { - const data = args[0] || {}; - return { - id: `dry-run-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - ...data, - _dryRun: true, - createdAt: new Date().toISOString(), - }; - } - - // For deletes, return success indication - if (prop.includes('delete') || prop.includes('remove')) { - return { deletedCount: 1, _dryRun: true }; - } - - // Default: return mock success - return { success: true, _dryRun: true }; - }; - }, - }); -} - -/** - * Try to find a corresponding find method for an update operation - * @param {Object} target - Repository target - * @param {string} updateMethod - Update method name - * @returns {Function|null} Find method or null - */ -function getFindMethod(target, updateMethod) { - // Common patterns: updateIntegration -> findIntegrationById - const patterns = [ - () => { - const match = updateMethod.match(/update(\w+)/i); - return match ? `find${match[1]}ById` : null; - }, - () => { - const match = updateMethod.match(/update(\w+)/i); - return match ? `get${match[1]}ById` : null; - }, - () => 'findById', - () => 'getById', - ]; - - for (const pattern of patterns) { - const methodName = pattern(); - if (methodName && typeof target[methodName] === 'function') { - return target[methodName]; - } - } - - return null; -} - -/** - * Sanitize arguments for logging (remove sensitive data) - * @param {Array} args - Function arguments - * @returns {Array} Sanitized arguments - */ -function sanitizeArgs(args) { - return args.map((arg) => { - if (arg === null || arg === undefined) { - return arg; - } - - if (typeof arg !== 'object') { - return arg; - } - - if (Array.isArray(arg)) { - return arg.map((item) => sanitizeArgs([item])[0]); - } - - // Sanitize object - remove sensitive fields - const sanitized = {}; - for (const [key, value] of Object.entries(arg)) { - const lowerKey = key.toLowerCase(); - - // Skip sensitive fields - if ( - lowerKey.includes('password') || - lowerKey.includes('token') || - lowerKey.includes('secret') || - lowerKey.includes('key') || - lowerKey.includes('auth') - ) { - sanitized[key] = '[REDACTED]'; - continue; - } - - // Recursively sanitize nested objects - if (typeof value === 'object' && value !== null) { - sanitized[key] = sanitizeArgs([value])[0]; - } else { - sanitized[key] = value; - } - } - - return sanitized; - }); -} - -/** - * Wrap AdminFriggCommands for dry-run mode - * - * @param {Object} realCommands - Real AdminFriggCommands instance - * @param {Array} operationLog - Array to append logged operations - * @returns {Object} Wrapped commands with dry-run repository wrappers - */ -function wrapAdminFriggCommandsForDryRun(realCommands, operationLog) { - return new Proxy(realCommands, { - get(target, prop) { - const value = target[prop]; - - // Pass through non-functions - if (typeof value !== 'function') { - // For lazy-loaded repositories, wrap them - if (prop.endsWith('Repository') && value && typeof value === 'object') { - const modelName = prop.replace('Repository', ''); - return createDryRunWrapper( - value, - operationLog, - modelName.charAt(0).toUpperCase() + modelName.slice(1) - ); - } - return value; - } - - // Identify write operations on the commands themselves - const writePatterns = /^(update|create|delete|append)/i; - const isWrite = writePatterns.test(prop); - - if (!isWrite) { - // Read operations pass through - return value.bind(target); - } - - // Wrap write operations - return async (...args) => { - operationLog.push({ - operation: prop.toUpperCase(), - source: 'AdminFriggCommands', - method: prop, - args: sanitizeArgs(args), - timestamp: new Date().toISOString(), - }); - - // For specific known methods, try to return sensible mocks - if (prop === 'updateIntegrationConfig') { - const [integrationId] = args; - const existing = await target.findIntegrationById(integrationId); - return existing; - } - - if (prop === 'updateIntegrationStatus') { - const [integrationId] = args; - const existing = await target.findIntegrationById(integrationId); - return existing; - } - - if (prop === 'updateCredential') { - const [credentialId, updates] = args; - return { id: credentialId, ...updates, _dryRun: true }; - } - - // Default mock - return { success: true, _dryRun: true }; - }; - }, - }); -} - -module.exports = { - createDryRunWrapper, - wrapAdminFriggCommandsForDryRun, - sanitizeArgs, -}; diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js index 6eb46c7aa..1df2b0c19 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -1,8 +1,6 @@ const { getScriptFactory } = require('./script-factory'); const { createAdminFriggCommands } = require('./admin-frigg-commands'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); -const { wrapAdminFriggCommandsForDryRun } = require('./dry-run-repository-wrapper'); -const { createDryRunHttpClient, injectDryRunHttpClient } = require('./dry-run-http-interceptor'); /** * Script Runner @@ -30,7 +28,7 @@ class ScriptRunner { * @param {string} options.mode - 'sync' | 'async' * @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress } * @param {string} options.executionId - Reuse existing execution ID - * @param {boolean} options.dryRun - Execute in dry-run mode (no writes, log operations) + * @param {boolean} options.dryRun - Dry-run mode: validate and preview without executing */ async execute(scriptName, params = {}, options = {}) { const { trigger = 'MANUAL', audit = {}, executionId: existingExecutionId, dryRun = false } = options; @@ -46,6 +44,11 @@ class ScriptRunner { ); } + // Dry-run mode: validate and return preview without executing + if (dryRun) { + return this.createDryRunPreview(scriptName, definition, params); + } + let executionId = existingExecutionId; // Create execution record if not provided @@ -64,25 +67,13 @@ class ScriptRunner { const startTime = new Date(); try { - // Update status to RUNNING (skip in dry-run) - if (!dryRun) { - await this.commands.updateAdminProcessState(executionId, 'RUNNING'); - } + await this.commands.updateAdminProcessState(executionId, 'RUNNING'); // Create frigg commands for the script - let frigg; - let operationLog = []; - - if (dryRun) { - // Dry-run mode: wrap commands to intercept writes - frigg = this.createDryRunFriggCommands(operationLog); - } else { - // Normal mode: create real commands - frigg = createAdminFriggCommands({ - executionId, - integrationFactory: this.integrationFactory, - }); - } + const frigg = createAdminFriggCommands({ + executionId, + integrationFactory: this.integrationFactory, + }); // Create script instance const script = this.scriptFactory.createInstance(scriptName, { @@ -97,34 +88,15 @@ class ScriptRunner { const endTime = new Date(); const durationMs = endTime - startTime; - // Complete execution (skip in dry-run) - if (!dryRun) { - await this.commands.completeAdminProcess(executionId, { - status: 'COMPLETED', - output, - metrics: { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - durationMs, - }, - }); - } - - // Return dry-run preview if in dry-run mode - if (dryRun) { - return { - executionId, - dryRun: true, - status: 'DRY_RUN_COMPLETED', - scriptName, - preview: { - operations: operationLog, - summary: this.summarizeOperations(operationLog), - scriptOutput: output, - }, - metrics: { durationMs }, - }; - } + await this.commands.completeAdminProcess(executionId, { + state: 'COMPLETED', + output, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); return { executionId, @@ -134,31 +106,26 @@ class ScriptRunner { metrics: { durationMs }, }; } catch (error) { - // Calculate metrics even on failure const endTime = new Date(); const durationMs = endTime - startTime; - // Record failure (skip in dry-run) - if (!dryRun) { - await this.commands.completeAdminProcess(executionId, { - status: 'FAILED', - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - metrics: { - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - durationMs, - }, - }); - } + await this.commands.completeAdminProcess(executionId, { + state: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); return { executionId, - dryRun, - status: dryRun ? 'DRY_RUN_FAILED' : 'FAILED', + status: 'FAILED', scriptName, error: { name: error.name, @@ -170,80 +137,107 @@ class ScriptRunner { } /** - * Create dry-run version of AdminFriggCommands - * Intercepts all write operations and logs them + * Create dry-run preview without executing the script + * Validates inputs and shows what would be executed * - * @param {Array} operationLog - Array to collect logged operations - * @returns {Object} Wrapped AdminFriggCommands + * @param {string} scriptName - Script name + * @param {Object} definition - Script definition + * @param {Object} params - Input parameters + * @returns {Object} Dry-run preview */ - createDryRunFriggCommands(operationLog) { - // Create real commands (for read operations) - const realCommands = createAdminFriggCommands({ - executionId: null, // Don't persist logs in dry-run - integrationFactory: this.integrationFactory, - }); - - // Wrap commands to intercept writes - const wrappedCommands = wrapAdminFriggCommandsForDryRun(realCommands, operationLog); + createDryRunPreview(scriptName, definition, params) { + const validation = this.validateParams(definition, params); + + return { + dryRun: true, + status: validation.valid ? 'DRY_RUN_VALID' : 'DRY_RUN_INVALID', + scriptName, + preview: { + script: { + name: definition.name, + version: definition.version, + description: definition.description, + requiresIntegrationFactory: definition.config?.requiresIntegrationFactory || false, + }, + input: params, + inputSchema: definition.inputSchema || null, + validation, + }, + message: validation.valid + ? 'Dry-run validation passed. Script is ready to execute with provided parameters.' + : `Dry-run validation failed: ${validation.errors.join(', ')}`, + }; + } - // Create dry-run HTTP client - const dryRunHttpClient = createDryRunHttpClient(operationLog); + /** + * Validate parameters against script's input schema + * + * @param {Object} definition - Script definition + * @param {Object} params - Input parameters + * @returns {Object} Validation result { valid, errors } + */ + validateParams(definition, params) { + const errors = []; + const schema = definition.inputSchema; - // Override instantiate to inject dry-run HTTP client - const originalInstantiate = wrappedCommands.instantiate.bind(wrappedCommands); - wrappedCommands.instantiate = async (integrationId) => { - const instance = await originalInstantiate(integrationId); + if (!schema) { + return { valid: true, errors: [] }; + } - // Inject dry-run HTTP client into the integration instance - injectDryRunHttpClient(instance, dryRunHttpClient); + // Check required fields + if (schema.required && Array.isArray(schema.required)) { + for (const field of schema.required) { + if (params[field] === undefined || params[field] === null) { + errors.push(`Missing required parameter: ${field}`); + } + } + } - return instance; - }; + // Basic type validation for properties + if (schema.properties) { + for (const [key, prop] of Object.entries(schema.properties)) { + const value = params[key]; + if (value !== undefined && value !== null) { + const typeError = this.validateType(key, value, prop); + if (typeError) { + errors.push(typeError); + } + } + } + } - return wrappedCommands; + return { valid: errors.length === 0, errors }; } /** - * Summarize operations from dry-run log - * - * @param {Array} log - Operation log - * @returns {Object} Summary statistics + * Validate a single parameter type */ - summarizeOperations(log) { - const summary = { - totalOperations: log.length, - databaseWrites: 0, - httpRequests: 0, - byOperation: {}, - byModel: {}, - byService: {}, - }; + validateType(key, value, schema) { + const expectedType = schema.type; + if (!expectedType) return null; - for (const op of log) { - // Count by operation type - const operation = op.operation || op.method || 'UNKNOWN'; - summary.byOperation[operation] = (summary.byOperation[operation] || 0) + 1; - - // Database operations - if (op.model) { - summary.databaseWrites++; - summary.byModel[op.model] = summary.byModel[op.model] || []; - summary.byModel[op.model].push({ - operation: op.operation, - method: op.method, - timestamp: op.timestamp, - }); - } + const actualType = Array.isArray(value) ? 'array' : typeof value; - // HTTP requests - if (op.operation === 'HTTP_REQUEST') { - summary.httpRequests++; - const service = op.service || 'unknown'; - summary.byService[service] = (summary.byService[service] || 0) + 1; - } + if (expectedType === 'integer' && (typeof value !== 'number' || !Number.isInteger(value))) { + return `Parameter "${key}" must be an integer`; + } + if (expectedType === 'number' && typeof value !== 'number') { + return `Parameter "${key}" must be a number`; + } + if (expectedType === 'string' && typeof value !== 'string') { + return `Parameter "${key}" must be a string`; + } + if (expectedType === 'boolean' && typeof value !== 'boolean') { + return `Parameter "${key}" must be a boolean`; + } + if (expectedType === 'array' && !Array.isArray(value)) { + return `Parameter "${key}" must be an array`; + } + if (expectedType === 'object' && (typeof value !== 'object' || Array.isArray(value))) { + return `Parameter "${key}" must be an object`; } - return summary; + return null; } } diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js index b0eea8994..3215f0ea2 100644 --- a/packages/core/application/commands/admin-script-commands.js +++ b/packages/core/application/commands/admin-script-commands.js @@ -17,6 +17,20 @@ function mapErrorToResponse(error) { * - Maps errors to HTTP-friendly responses * - Returns data or error objects (never throws) * + * WHY SEPARATE FROM integration-commands.js: + * These commands are intentionally separate because they serve different domains: + * - integration-commands: User-context operations on integrations + * - Requires integrationClass constructor parameter + * - Works with userId, entityIds, integration contexts + * - Uses IntegrationRepository, ModuleRepository + * - admin-script-commands: System/admin operations without user context + * - No user context required + * - Works with AdminProcess, ScriptSchedule + * - Uses AdminProcessRepository, ScriptScheduleRepository + * + * Merging them would violate SRP and create coupling between + * user-facing integration code and admin/system code. + * * Authentication: * - Uses ENV-based ADMIN_API_KEY (see handlers/middleware/admin-auth.js) * - No database-backed API keys (simplified from original design) From 1e53bcfe5da932a8ba8eed24a90c6b11ec902d95 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Mon, 29 Dec 2025 14:24:39 -0500 Subject: [PATCH 26/33] refactor(admin-scripts): address PR review feedback for Admin Script Runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Rename requiresIntegrationFactory to requireIntegrationInstance (PR feedback) - Add JSDoc documentation for executionId parameter - Split schedule-management-use-case.js into 3 separate use cases following SRP: - GetEffectiveScheduleUseCase - UpsertScheduleUseCase - DeleteScheduleUseCase - Abstract AWS-specific naming to generic external scheduler terminology: - awsScheduleArn → externalScheduleId - awsScheduleName → externalScheduleName - updateScheduleAwsInfo → updateScheduleExternalInfo - Remove sinon dependency, use Jest mocks instead - Update Prisma schemas for both MongoDB and PostgreSQL - Update ADR documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../005-admin-script-runner.md | 13 +- packages/admin-scripts/package.json | 1 - .../__tests__/admin-frigg-commands.test.js | 2 +- .../__tests__/admin-script-base.test.js | 4 +- .../schedule-management-use-case.test.js | 276 ------------------ .../__tests__/script-runner.test.js | 4 +- .../src/application/admin-frigg-commands.js | 2 +- .../src/application/admin-script-base.js | 2 +- .../schedule-management-use-case.js | 230 --------------- .../src/application/script-runner.js | 8 +- .../delete-schedule-use-case.test.js | 168 +++++++++++ .../get-effective-schedule-use-case.test.js | 114 ++++++++ .../upsert-schedule-use-case.test.js | 201 +++++++++++++ .../use-cases/delete-schedule-use-case.js | 108 +++++++ .../get-effective-schedule-use-case.js | 78 +++++ .../src/application/use-cases/index.js | 18 ++ .../use-cases/upsert-schedule-use-case.js | 127 ++++++++ .../integration-health-check.test.js | 2 +- .../__tests__/oauth-token-refresh.test.js | 2 +- .../src/builtins/integration-health-check.js | 2 +- .../src/builtins/oauth-token-refresh.js | 2 +- .../__tests__/admin-script-router.test.js | 44 +-- .../src/infrastructure/admin-script-router.js | 40 ++- .../script-schedule-repository-interface.js | 18 +- .../script-schedule-repository-mongo.js | 26 +- .../script-schedule-repository-postgres.js | 26 +- .../commands/admin-script-commands.js | 16 +- packages/core/prisma-mongodb/schema.prisma | 6 +- packages/core/prisma-postgresql/schema.prisma | 6 +- 29 files changed, 937 insertions(+), 609 deletions(-) delete mode 100644 packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js delete mode 100644 packages/admin-scripts/src/application/schedule-management-use-case.js create mode 100644 packages/admin-scripts/src/application/use-cases/__tests__/delete-schedule-use-case.test.js create mode 100644 packages/admin-scripts/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js create mode 100644 packages/admin-scripts/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js create mode 100644 packages/admin-scripts/src/application/use-cases/delete-schedule-use-case.js create mode 100644 packages/admin-scripts/src/application/use-cases/get-effective-schedule-use-case.js create mode 100644 packages/admin-scripts/src/application/use-cases/index.js create mode 100644 packages/admin-scripts/src/application/use-cases/upsert-schedule-use-case.js diff --git a/docs/architecture-decisions/005-admin-script-runner.md b/docs/architecture-decisions/005-admin-script-runner.md index 5c88041ee..d80c1db57 100644 --- a/docs/architecture-decisions/005-admin-script-runner.md +++ b/docs/architecture-decisions/005-admin-script-runner.md @@ -59,8 +59,19 @@ class MyScript extends AdminScriptBase { schedule: { enabled: true, cronExpression: 'cron(0 12 * * ? *)' }, }; + /** + * @param {AdminFriggCommands} frigg - Helper object providing: + * - Repository access: listIntegrations(), findUserById(), findCredential(), etc. + * - Logging: log(level, message, data) - persists to execution record + * - Queue operations: queueScript(), queueScriptBatch() - for self-queuing pattern + * - Integration instantiation: instantiate(integrationId) - requires config.requireIntegrationInstance + * @param {Object} params - Script parameters (validated against inputSchema if provided) + * @returns {Promise} - Script results (validated against outputSchema if provided) + */ async execute(frigg, params) { - // frigg provides: log(), getIntegrations(), getCredentials(), etc. + // Example usage: + // const integrations = await frigg.listIntegrations({ userId: params.userId }); + // frigg.log('info', 'Processing integrations', { count: integrations.length }); return { success: true }; } } diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json index 8f7388119..46acdd4d4 100644 --- a/packages/admin-scripts/package.json +++ b/packages/admin-scripts/package.json @@ -19,7 +19,6 @@ "eslint": "^8.22.0", "jest": "^29.7.0", "prettier": "^2.7.1", - "sinon": "^16.1.1", "supertest": "^7.1.4" }, "scripts": { diff --git a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js index 70dea5c36..a77463958 100644 --- a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js +++ b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js @@ -341,7 +341,7 @@ describe('AdminFriggCommands', () => { await expect(commands.instantiate('int_123')).rejects.toThrow( 'instantiate() requires integrationFactory. ' + - 'Set Definition.config.requiresIntegrationFactory = true' + 'Set Definition.config.requireIntegrationInstance = true' ); }); diff --git a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js index 18a955403..0baa6f9da 100644 --- a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js +++ b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js @@ -28,7 +28,7 @@ describe('AdminScriptBase', () => { config: { timeout: 600000, maxRetries: 3, - requiresIntegrationFactory: true, + requireIntegrationInstance: true, }, display: { label: 'Test Script', @@ -236,7 +236,7 @@ describe('AdminScriptBase', () => { version: '1.0.0', description: 'My test script', config: { - requiresIntegrationFactory: true, + requireIntegrationInstance: true, }, }; diff --git a/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js b/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js deleted file mode 100644 index 0dc88cc33..000000000 --- a/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js +++ /dev/null @@ -1,276 +0,0 @@ -const { ScheduleManagementUseCase } = require('../schedule-management-use-case'); - -describe('ScheduleManagementUseCase', () => { - let useCase; - let mockCommands; - let mockSchedulerAdapter; - let mockScriptFactory; - - beforeEach(() => { - mockCommands = { - getScheduleByScriptName: jest.fn(), - upsertSchedule: jest.fn(), - updateScheduleAwsInfo: jest.fn(), - deleteSchedule: jest.fn(), - }; - - mockSchedulerAdapter = { - createSchedule: jest.fn(), - deleteSchedule: jest.fn(), - }; - - mockScriptFactory = { - has: jest.fn(), - get: jest.fn(), - }; - - useCase = new ScheduleManagementUseCase({ - commands: mockCommands, - schedulerAdapter: mockSchedulerAdapter, - scriptFactory: mockScriptFactory, - }); - }); - - describe('getEffectiveSchedule', () => { - it('should return database schedule when override exists', async () => { - const dbSchedule = { - scriptName: 'test-script', - enabled: true, - cronExpression: '0 9 * * *', - timezone: 'UTC', - }; - - mockScriptFactory.has.mockReturnValue(true); - mockScriptFactory.get.mockReturnValue({ Definition: {} }); - mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule); - - const result = await useCase.getEffectiveSchedule('test-script'); - - expect(result.source).toBe('database'); - expect(result.schedule).toEqual(dbSchedule); - }); - - it('should return definition schedule when no database override', async () => { - const definitionSchedule = { - enabled: true, - cronExpression: '0 12 * * *', - timezone: 'America/New_York', - }; - - mockScriptFactory.has.mockReturnValue(true); - mockScriptFactory.get.mockReturnValue({ - Definition: { schedule: definitionSchedule }, - }); - mockCommands.getScheduleByScriptName.mockResolvedValue(null); - - const result = await useCase.getEffectiveSchedule('test-script'); - - expect(result.source).toBe('definition'); - expect(result.schedule.enabled).toBe(true); - expect(result.schedule.cronExpression).toBe('0 12 * * *'); - }); - - it('should return none when no schedule configured', async () => { - mockScriptFactory.has.mockReturnValue(true); - mockScriptFactory.get.mockReturnValue({ Definition: {} }); - mockCommands.getScheduleByScriptName.mockResolvedValue(null); - - const result = await useCase.getEffectiveSchedule('test-script'); - - expect(result.source).toBe('none'); - expect(result.schedule.enabled).toBe(false); - }); - - it('should throw error when script not found', async () => { - mockScriptFactory.has.mockReturnValue(false); - - await expect(useCase.getEffectiveSchedule('non-existent')) - .rejects.toThrow('Script "non-existent" not found'); - }); - }); - - describe('upsertSchedule', () => { - it('should create schedule and provision EventBridge when enabled', async () => { - const savedSchedule = { - scriptName: 'test-script', - enabled: true, - cronExpression: '0 12 * * *', - timezone: 'UTC', - }; - - mockScriptFactory.has.mockReturnValue(true); - mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); - mockSchedulerAdapter.createSchedule.mockResolvedValue({ - scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', - scheduleName: 'frigg-script-test-script', - }); - mockCommands.updateScheduleAwsInfo.mockResolvedValue({ - ...savedSchedule, - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', - }); - - const result = await useCase.upsertSchedule('test-script', { - enabled: true, - cronExpression: '0 12 * * *', - timezone: 'UTC', - }); - - expect(result.success).toBe(true); - expect(result.schedule.scriptName).toBe('test-script'); - expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ - scriptName: 'test-script', - cronExpression: '0 12 * * *', - timezone: 'UTC', - }); - expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalled(); - }); - - it('should delete EventBridge schedule when disabling', async () => { - const existingSchedule = { - scriptName: 'test-script', - enabled: false, - cronExpression: null, - timezone: 'UTC', - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', - }; - - mockScriptFactory.has.mockReturnValue(true); - mockCommands.upsertSchedule.mockResolvedValue(existingSchedule); - mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); - mockCommands.updateScheduleAwsInfo.mockResolvedValue({ - ...existingSchedule, - awsScheduleArn: null, - }); - - const result = await useCase.upsertSchedule('test-script', { - enabled: false, - }); - - expect(result.success).toBe(true); - expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); - }); - - it('should handle scheduler errors gracefully', async () => { - const savedSchedule = { - scriptName: 'test-script', - enabled: true, - cronExpression: '0 12 * * *', - timezone: 'UTC', - }; - - mockScriptFactory.has.mockReturnValue(true); - mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); - mockSchedulerAdapter.createSchedule.mockRejectedValue( - new Error('AWS Scheduler API error') - ); - - const result = await useCase.upsertSchedule('test-script', { - enabled: true, - cronExpression: '0 12 * * *', - }); - - // Should succeed with warning, not fail - expect(result.success).toBe(true); - expect(result.schedulerWarning).toBe('AWS Scheduler API error'); - }); - - it('should throw error when script not found', async () => { - mockScriptFactory.has.mockReturnValue(false); - - await expect(useCase.upsertSchedule('non-existent', { enabled: true })) - .rejects.toThrow('Script "non-existent" not found'); - }); - - it('should throw error when enabled without cronExpression', async () => { - mockScriptFactory.has.mockReturnValue(true); - - await expect(useCase.upsertSchedule('test-script', { enabled: true })) - .rejects.toThrow('cronExpression is required when enabled is true'); - }); - }); - - describe('deleteSchedule', () => { - it('should delete schedule and EventBridge rule', async () => { - const deletedSchedule = { - scriptName: 'test-script', - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', - }; - - mockScriptFactory.has.mockReturnValue(true); - mockScriptFactory.get.mockReturnValue({ Definition: {} }); - mockCommands.deleteSchedule.mockResolvedValue({ - deletedCount: 1, - deleted: deletedSchedule, - }); - mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); - - const result = await useCase.deleteSchedule('test-script'); - - expect(result.success).toBe(true); - expect(result.deletedCount).toBe(1); - expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); - }); - - it('should not call scheduler when no AWS rule exists', async () => { - mockScriptFactory.has.mockReturnValue(true); - mockScriptFactory.get.mockReturnValue({ Definition: {} }); - mockCommands.deleteSchedule.mockResolvedValue({ - deletedCount: 1, - deleted: { scriptName: 'test-script' }, // No awsScheduleArn - }); - - const result = await useCase.deleteSchedule('test-script'); - - expect(result.success).toBe(true); - expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled(); - }); - - it('should handle scheduler delete errors gracefully', async () => { - mockScriptFactory.has.mockReturnValue(true); - mockScriptFactory.get.mockReturnValue({ Definition: {} }); - mockCommands.deleteSchedule.mockResolvedValue({ - deletedCount: 1, - deleted: { - scriptName: 'test-script', - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', - }, - }); - mockSchedulerAdapter.deleteSchedule.mockRejectedValue( - new Error('AWS delete failed') - ); - - const result = await useCase.deleteSchedule('test-script'); - - expect(result.success).toBe(true); - expect(result.schedulerWarning).toBe('AWS delete failed'); - }); - - it('should return effective schedule after deletion', async () => { - const definitionSchedule = { - enabled: true, - cronExpression: '0 6 * * *', - }; - - mockScriptFactory.has.mockReturnValue(true); - mockScriptFactory.get.mockReturnValue({ - Definition: { schedule: definitionSchedule }, - }); - mockCommands.deleteSchedule.mockResolvedValue({ - deletedCount: 1, - deleted: { scriptName: 'test-script' }, - }); - - const result = await useCase.deleteSchedule('test-script'); - - expect(result.effectiveSchedule.source).toBe('definition'); - expect(result.effectiveSchedule.enabled).toBe(true); - }); - - it('should throw error when script not found', async () => { - mockScriptFactory.has.mockReturnValue(false); - - await expect(useCase.deleteSchedule('non-existent')) - .rejects.toThrow('Script "non-existent" not found'); - }); - }); -}); diff --git a/packages/admin-scripts/src/application/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js index c2038ad75..f9858a6fe 100644 --- a/packages/admin-scripts/src/application/__tests__/script-runner.test.js +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -23,7 +23,7 @@ describe('ScriptRunner', () => { config: { timeout: 300000, maxRetries: 0, - requiresIntegrationFactory: false, + requireIntegrationInstance: false, }, }; @@ -146,7 +146,7 @@ describe('ScriptRunner', () => { version: '1.0.0', description: 'Integration script', config: { - requiresIntegrationFactory: true, + requireIntegrationInstance: true, }, }; diff --git a/packages/admin-scripts/src/application/admin-frigg-commands.js b/packages/admin-scripts/src/application/admin-frigg-commands.js index e22a5024d..21fe574c4 100644 --- a/packages/admin-scripts/src/application/admin-frigg-commands.js +++ b/packages/admin-scripts/src/application/admin-frigg-commands.js @@ -142,7 +142,7 @@ class AdminFriggCommands { if (!this.integrationFactory) { throw new Error( 'instantiate() requires integrationFactory. ' + - 'Set Definition.config.requiresIntegrationFactory = true' + 'Set Definition.config.requireIntegrationInstance = true' ); } return this.integrationFactory.getInstanceFromIntegrationId({ diff --git a/packages/admin-scripts/src/application/admin-script-base.js b/packages/admin-scripts/src/application/admin-script-base.js index d7b228779..538e64428 100644 --- a/packages/admin-scripts/src/application/admin-script-base.js +++ b/packages/admin-scripts/src/application/admin-script-base.js @@ -50,7 +50,7 @@ class AdminScriptBase { config: { timeout: 300000, // Default 5 min (ms) maxRetries: 0, - requiresIntegrationFactory: false, // Hint: does script need to instantiate integrations? + requireIntegrationInstance: false, // Hint: does script need to instantiate integrations? }, display: { diff --git a/packages/admin-scripts/src/application/schedule-management-use-case.js b/packages/admin-scripts/src/application/schedule-management-use-case.js deleted file mode 100644 index e3fba9442..000000000 --- a/packages/admin-scripts/src/application/schedule-management-use-case.js +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Schedule Management Use Case - * - * Application Layer - Hexagonal Architecture - * - * Orchestrates schedule management operations: - * - Get effective schedule (DB override > Definition > none) - * - Upsert schedule with EventBridge provisioning - * - Delete schedule with EventBridge cleanup - * - * This use case encapsulates the business logic that was previously - * embedded in the router, reducing cognitive complexity and improving testability. - */ -class ScheduleManagementUseCase { - constructor({ commands, schedulerAdapter, scriptFactory }) { - this.commands = commands; - this.schedulerAdapter = schedulerAdapter; - this.scriptFactory = scriptFactory; - } - - /** - * Validate that a script exists - * @private - */ - _validateScriptExists(scriptName) { - if (!this.scriptFactory.has(scriptName)) { - const error = new Error(`Script "${scriptName}" not found`); - error.code = 'SCRIPT_NOT_FOUND'; - throw error; - } - } - - /** - * Get the definition schedule from a script class - * @private - */ - _getDefinitionSchedule(scriptName) { - const scriptClass = this.scriptFactory.get(scriptName); - return scriptClass.Definition?.schedule || null; - } - - /** - * Get effective schedule (DB override > Definition default > none) - */ - async getEffectiveSchedule(scriptName) { - this._validateScriptExists(scriptName); - - // Check database override first - const dbSchedule = await this.commands.getScheduleByScriptName(scriptName); - if (dbSchedule) { - return { - source: 'database', - schedule: dbSchedule, - }; - } - - // Check definition default - const definitionSchedule = this._getDefinitionSchedule(scriptName); - if (definitionSchedule?.enabled) { - return { - source: 'definition', - schedule: { - scriptName, - enabled: definitionSchedule.enabled, - cronExpression: definitionSchedule.cronExpression, - timezone: definitionSchedule.timezone || 'UTC', - }, - }; - } - - // No schedule configured - return { - source: 'none', - schedule: { - scriptName, - enabled: false, - }, - }; - } - - /** - * Create or update schedule with EventBridge provisioning - */ - async upsertSchedule(scriptName, { enabled, cronExpression, timezone }) { - this._validateScriptExists(scriptName); - this._validateScheduleInput(enabled, cronExpression); - - // Save to database - const schedule = await this.commands.upsertSchedule({ - scriptName, - enabled, - cronExpression: cronExpression || null, - timezone: timezone || 'UTC', - }); - - // Provision/deprovision EventBridge - const schedulerResult = await this._syncEventBridgeSchedule( - scriptName, - enabled, - cronExpression, - timezone, - schedule.awsScheduleArn - ); - - return { - success: true, - schedule: { - ...schedule, - awsScheduleArn: schedulerResult.awsScheduleArn || schedule.awsScheduleArn, - awsScheduleName: schedulerResult.awsScheduleName || schedule.awsScheduleName, - }, - ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }), - }; - } - - /** - * Validate schedule input - * @private - */ - _validateScheduleInput(enabled, cronExpression) { - if (typeof enabled !== 'boolean') { - const error = new Error('enabled must be a boolean'); - error.code = 'INVALID_INPUT'; - throw error; - } - - if (enabled && !cronExpression) { - const error = new Error('cronExpression is required when enabled is true'); - error.code = 'INVALID_INPUT'; - throw error; - } - } - - /** - * Sync EventBridge schedule based on enabled state - * @private - */ - async _syncEventBridgeSchedule(scriptName, enabled, cronExpression, timezone, existingArn) { - const result = { awsScheduleArn: null, awsScheduleName: null, warning: null }; - - try { - if (enabled && cronExpression) { - // Create/update EventBridge schedule - const awsInfo = await this.schedulerAdapter.createSchedule({ - scriptName, - cronExpression, - timezone: timezone || 'UTC', - }); - - if (awsInfo?.scheduleArn) { - await this.commands.updateScheduleAwsInfo(scriptName, { - awsScheduleArn: awsInfo.scheduleArn, - awsScheduleName: awsInfo.scheduleName, - }); - result.awsScheduleArn = awsInfo.scheduleArn; - result.awsScheduleName = awsInfo.scheduleName; - } - } else if (!enabled && existingArn) { - // Delete EventBridge schedule - await this.schedulerAdapter.deleteSchedule(scriptName); - await this.commands.updateScheduleAwsInfo(scriptName, { - awsScheduleArn: null, - awsScheduleName: null, - }); - } - } catch (error) { - // Non-fatal: DB schedule is saved, AWS can be retried - result.warning = error.message; - } - - return result; - } - - /** - * Delete schedule override and cleanup EventBridge - */ - async deleteSchedule(scriptName) { - this._validateScriptExists(scriptName); - - // Delete from database - const deleteResult = await this.commands.deleteSchedule(scriptName); - - // Cleanup EventBridge if needed - const schedulerWarning = await this._cleanupEventBridgeSchedule( - scriptName, - deleteResult.deleted?.awsScheduleArn - ); - - // Get effective schedule after deletion - const definitionSchedule = this._getDefinitionSchedule(scriptName); - const effectiveSchedule = definitionSchedule?.enabled - ? { - source: 'definition', - enabled: definitionSchedule.enabled, - cronExpression: definitionSchedule.cronExpression, - timezone: definitionSchedule.timezone || 'UTC', - } - : { source: 'none', enabled: false }; - - return { - success: true, - deletedCount: deleteResult.deletedCount, - message: deleteResult.deletedCount > 0 - ? 'Schedule override removed' - : 'No schedule override found', - effectiveSchedule, - ...(schedulerWarning && { schedulerWarning }), - }; - } - - /** - * Cleanup EventBridge schedule if it exists - * @private - */ - async _cleanupEventBridgeSchedule(scriptName, awsScheduleArn) { - if (!awsScheduleArn) { - return null; - } - - try { - await this.schedulerAdapter.deleteSchedule(scriptName); - return null; - } catch (error) { - // Non-fatal: DB is cleaned up, AWS can be retried - return error.message; - } - } -} - -module.exports = { ScheduleManagementUseCase }; diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js index 1df2b0c19..9b1234f43 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -27,7 +27,9 @@ class ScriptRunner { * @param {string} options.trigger - 'MANUAL' | 'SCHEDULED' | 'QUEUE' * @param {string} options.mode - 'sync' | 'async' * @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress } - * @param {string} options.executionId - Reuse existing execution ID + * @param {string} options.executionId - Reuse existing AdminProcess record ID (NOT the Lambda execution ID). + * This is the database ID from the AdminProcess collection/table that tracks script executions. + * Pass this when resuming a queued execution to continue using the same execution record. * @param {boolean} options.dryRun - Dry-run mode: validate and preview without executing */ async execute(scriptName, params = {}, options = {}) { @@ -38,7 +40,7 @@ class ScriptRunner { const definition = scriptClass.Definition; // Validate integrationFactory requirement - if (definition.config?.requiresIntegrationFactory && !this.integrationFactory) { + if (definition.config?.requireIntegrationInstance && !this.integrationFactory) { throw new Error( `Script "${scriptName}" requires integrationFactory but none was provided` ); @@ -157,7 +159,7 @@ class ScriptRunner { name: definition.name, version: definition.version, description: definition.description, - requiresIntegrationFactory: definition.config?.requiresIntegrationFactory || false, + requireIntegrationInstance: definition.config?.requireIntegrationInstance || false, }, input: params, inputSchema: definition.inputSchema || null, diff --git a/packages/admin-scripts/src/application/use-cases/__tests__/delete-schedule-use-case.test.js b/packages/admin-scripts/src/application/use-cases/__tests__/delete-schedule-use-case.test.js new file mode 100644 index 000000000..18158b374 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/__tests__/delete-schedule-use-case.test.js @@ -0,0 +1,168 @@ +const { DeleteScheduleUseCase } = require('../delete-schedule-use-case'); + +describe('DeleteScheduleUseCase', () => { + let useCase; + let mockCommands; + let mockSchedulerAdapter; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + deleteSchedule: jest.fn(), + }; + + mockSchedulerAdapter = { + deleteSchedule: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new DeleteScheduleUseCase({ + commands: mockCommands, + schedulerAdapter: mockSchedulerAdapter, + scriptFactory: mockScriptFactory, + }); + }); + + describe('execute', () => { + it('should delete schedule and cleanup external scheduler', async () => { + const deletedSchedule = { + scriptName: 'test-script', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: deletedSchedule, + }); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + expect(result.message).toBe('Schedule override removed'); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should not call scheduler when no external rule exists', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, // No externalScheduleId + }); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled(); + }); + + it('should handle scheduler delete errors gracefully with warning', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { + scriptName: 'test-script', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockRejectedValue( + new Error('Scheduler delete failed') + ); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('Scheduler delete failed'); + }); + + it('should return definition schedule as effective after deletion', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 6 * * *', + timezone: 'America/Los_Angeles', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.execute('test-script'); + + expect(result.effectiveSchedule.source).toBe('definition'); + expect(result.effectiveSchedule.enabled).toBe(true); + expect(result.effectiveSchedule.cronExpression).toBe('0 6 * * *'); + expect(result.effectiveSchedule.timezone).toBe('America/Los_Angeles'); + }); + + it('should default timezone to UTC when not in definition', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: { enabled: true, cronExpression: '0 6 * * *' } }, + }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.execute('test-script'); + + expect(result.effectiveSchedule.timezone).toBe('UTC'); + }); + + it('should return none as effective when no definition schedule', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.execute('test-script'); + + expect(result.effectiveSchedule.source).toBe('none'); + expect(result.effectiveSchedule.enabled).toBe(false); + }); + + it('should return correct message when no schedule found', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 0, + deleted: null, + }); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(0); + expect(result.message).toBe('No schedule override found'); + }); + + it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.execute('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + + try { + await useCase.execute('non-existent'); + } catch (error) { + expect(error.code).toBe('SCRIPT_NOT_FOUND'); + } + }); + }); +}); diff --git a/packages/admin-scripts/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js b/packages/admin-scripts/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js new file mode 100644 index 000000000..93852cf21 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js @@ -0,0 +1,114 @@ +const { GetEffectiveScheduleUseCase } = require('../get-effective-schedule-use-case'); + +describe('GetEffectiveScheduleUseCase', () => { + let useCase; + let mockCommands; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + getScheduleByScriptName: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new GetEffectiveScheduleUseCase({ + commands: mockCommands, + scriptFactory: mockScriptFactory, + }); + }); + + describe('execute', () => { + it('should return database schedule when override exists', async () => { + const dbSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 9 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('database'); + expect(result.schedule).toEqual(dbSchedule); + }); + + it('should return definition schedule when no database override', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/New_York', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('definition'); + expect(result.schedule.enabled).toBe(true); + expect(result.schedule.cronExpression).toBe('0 12 * * *'); + expect(result.schedule.timezone).toBe('America/New_York'); + }); + + it('should default timezone to UTC when not specified in definition', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: { enabled: true, cronExpression: '0 12 * * *' } }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.schedule.timezone).toBe('UTC'); + }); + + it('should return none when no schedule configured', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('none'); + expect(result.schedule.enabled).toBe(false); + expect(result.schedule.scriptName).toBe('test-script'); + }); + + it('should return none when definition schedule is disabled', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: { enabled: false } }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('none'); + expect(result.schedule.enabled).toBe(false); + }); + + it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.execute('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + + try { + await useCase.execute('non-existent'); + } catch (error) { + expect(error.code).toBe('SCRIPT_NOT_FOUND'); + } + }); + }); +}); diff --git a/packages/admin-scripts/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js b/packages/admin-scripts/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js new file mode 100644 index 000000000..3d435c420 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js @@ -0,0 +1,201 @@ +const { UpsertScheduleUseCase } = require('../upsert-schedule-use-case'); + +describe('UpsertScheduleUseCase', () => { + let useCase; + let mockCommands; + let mockSchedulerAdapter; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + upsertSchedule: jest.fn(), + updateScheduleExternalInfo: jest.fn(), + }; + + mockSchedulerAdapter = { + createSchedule: jest.fn(), + deleteSchedule: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new UpsertScheduleUseCase({ + commands: mockCommands, + schedulerAdapter: mockSchedulerAdapter, + scriptFactory: mockScriptFactory, + }); + }); + + describe('execute', () => { + it('should create schedule and provision external scheduler when enabled', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + scheduleName: 'frigg-script-test-script', + }); + mockCommands.updateScheduleExternalInfo.mockResolvedValue({ + ...savedSchedule, + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }); + + const result = await useCase.execute('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + + expect(result.success).toBe(true); + expect(result.schedule.scriptName).toBe('test-script'); + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + expect(mockCommands.updateScheduleExternalInfo).toHaveBeenCalled(); + }); + + it('should default timezone to UTC', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue({ + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + scheduleArn: 'arn:test', + scheduleName: 'test', + }); + + await useCase.execute('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + }); + + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + }); + + it('should delete external scheduler when disabling', async () => { + const existingSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(existingSchedule); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + mockCommands.updateScheduleExternalInfo.mockResolvedValue({ + ...existingSchedule, + externalScheduleId: null, + }); + + const result = await useCase.execute('test-script', { + enabled: false, + }); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should handle scheduler errors gracefully with warning', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockRejectedValue( + new Error('Scheduler API error') + ); + + const result = await useCase.execute('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + }); + + // Should succeed with warning, not fail + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('Scheduler API error'); + }); + + it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.execute('non-existent', { enabled: true })) + .rejects.toThrow('Script "non-existent" not found'); + + try { + await useCase.execute('non-existent', { enabled: true }); + } catch (error) { + expect(error.code).toBe('SCRIPT_NOT_FOUND'); + } + }); + + it('should throw INVALID_INPUT error when enabled is not a boolean', async () => { + mockScriptFactory.has.mockReturnValue(true); + + await expect(useCase.execute('test-script', { enabled: 'yes' })) + .rejects.toThrow('enabled must be a boolean'); + + try { + await useCase.execute('test-script', { enabled: 'yes' }); + } catch (error) { + expect(error.code).toBe('INVALID_INPUT'); + } + }); + + it('should throw INVALID_INPUT error when enabled without cronExpression', async () => { + mockScriptFactory.has.mockReturnValue(true); + + await expect(useCase.execute('test-script', { enabled: true })) + .rejects.toThrow('cronExpression is required when enabled is true'); + + try { + await useCase.execute('test-script', { enabled: true }); + } catch (error) { + expect(error.code).toBe('INVALID_INPUT'); + } + }); + + it('should not require cronExpression when disabled', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue({ + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + }); + + const result = await useCase.execute('test-script', { enabled: false }); + + expect(result.success).toBe(true); + expect(mockCommands.upsertSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + }); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/use-cases/delete-schedule-use-case.js b/packages/admin-scripts/src/application/use-cases/delete-schedule-use-case.js new file mode 100644 index 000000000..41caf65e9 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/delete-schedule-use-case.js @@ -0,0 +1,108 @@ +/** + * Delete Schedule Use Case + * + * Application Layer - Hexagonal Architecture + * + * Deletes a schedule override and cleans up external scheduler resources. + * Returns the effective schedule after deletion (may fall back to definition). + */ +class DeleteScheduleUseCase { + constructor({ commands, schedulerAdapter, scriptFactory }) { + this.commands = commands; + this.schedulerAdapter = schedulerAdapter; + this.scriptFactory = scriptFactory; + } + + /** + * Delete a schedule override + * @param {string} scriptName - Name of the script + * @returns {Promise<{success: boolean, deletedCount: number, message: string, effectiveSchedule: Object, schedulerWarning?: string}>} + */ + async execute(scriptName) { + this._validateScriptExists(scriptName); + + // Delete from database + const deleteResult = await this.commands.deleteSchedule(scriptName); + + // Cleanup external scheduler if needed + const schedulerWarning = await this._cleanupExternalScheduler( + scriptName, + deleteResult.deleted?.externalScheduleId + ); + + // Determine effective schedule after deletion + const effectiveSchedule = this._getEffectiveScheduleAfterDeletion(scriptName); + + return { + success: true, + deletedCount: deleteResult.deletedCount, + message: deleteResult.deletedCount > 0 + ? 'Schedule override removed' + : 'No schedule override found', + effectiveSchedule, + ...(schedulerWarning && { schedulerWarning }), + }; + } + + /** + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * Get the definition schedule from a script class + * @private + */ + _getDefinitionSchedule(scriptName) { + const scriptClass = this.scriptFactory.get(scriptName); + return scriptClass.Definition?.schedule || null; + } + + /** + * Determine effective schedule after deletion + * @private + */ + _getEffectiveScheduleAfterDeletion(scriptName) { + const definitionSchedule = this._getDefinitionSchedule(scriptName); + + if (definitionSchedule?.enabled) { + return { + source: 'definition', + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + }; + } + + return { + source: 'none', + enabled: false, + }; + } + + /** + * Cleanup external scheduler resources + * @private + */ + async _cleanupExternalScheduler(scriptName, externalScheduleId) { + if (!externalScheduleId) { + return null; + } + + try { + await this.schedulerAdapter.deleteSchedule(scriptName); + return null; + } catch (error) { + // Non-fatal: DB is cleaned up, external scheduler can be retried + return error.message; + } + } +} + +module.exports = { DeleteScheduleUseCase }; diff --git a/packages/admin-scripts/src/application/use-cases/get-effective-schedule-use-case.js b/packages/admin-scripts/src/application/use-cases/get-effective-schedule-use-case.js new file mode 100644 index 000000000..9bc3e6be9 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/get-effective-schedule-use-case.js @@ -0,0 +1,78 @@ +/** + * Get Effective Schedule Use Case + * + * Application Layer - Hexagonal Architecture + * + * Resolves the effective schedule for a script following priority: + * 1. Database override (runtime configuration) + * 2. Definition default (code-defined schedule) + * 3. None (manual execution only) + */ +class GetEffectiveScheduleUseCase { + constructor({ commands, scriptFactory }) { + this.commands = commands; + this.scriptFactory = scriptFactory; + } + + /** + * Get effective schedule for a script + * @param {string} scriptName - Name of the script + * @returns {Promise<{source: 'database'|'definition'|'none', schedule: Object}>} + */ + async execute(scriptName) { + this._validateScriptExists(scriptName); + + // Priority 1: Database override + const dbSchedule = await this.commands.getScheduleByScriptName(scriptName); + if (dbSchedule) { + return { + source: 'database', + schedule: dbSchedule, + }; + } + + // Priority 2: Definition default + const definitionSchedule = this._getDefinitionSchedule(scriptName); + if (definitionSchedule?.enabled) { + return { + source: 'definition', + schedule: { + scriptName, + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + }, + }; + } + + // Priority 3: No schedule + return { + source: 'none', + schedule: { + scriptName, + enabled: false, + }, + }; + } + + /** + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * @private + */ + _getDefinitionSchedule(scriptName) { + const scriptClass = this.scriptFactory.get(scriptName); + return scriptClass.Definition?.schedule || null; + } +} + +module.exports = { GetEffectiveScheduleUseCase }; diff --git a/packages/admin-scripts/src/application/use-cases/index.js b/packages/admin-scripts/src/application/use-cases/index.js new file mode 100644 index 000000000..36baa33da --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/index.js @@ -0,0 +1,18 @@ +/** + * Schedule Management Use Cases + * + * Separated by Single Responsibility Principle: + * - GetEffectiveScheduleUseCase: Read schedule with priority resolution + * - UpsertScheduleUseCase: Create/update schedule with scheduler sync + * - DeleteScheduleUseCase: Delete schedule with scheduler cleanup + */ + +const { GetEffectiveScheduleUseCase } = require('./get-effective-schedule-use-case'); +const { UpsertScheduleUseCase } = require('./upsert-schedule-use-case'); +const { DeleteScheduleUseCase } = require('./delete-schedule-use-case'); + +module.exports = { + GetEffectiveScheduleUseCase, + UpsertScheduleUseCase, + DeleteScheduleUseCase, +}; diff --git a/packages/admin-scripts/src/application/use-cases/upsert-schedule-use-case.js b/packages/admin-scripts/src/application/use-cases/upsert-schedule-use-case.js new file mode 100644 index 000000000..ab795a77c --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/upsert-schedule-use-case.js @@ -0,0 +1,127 @@ +/** + * Upsert Schedule Use Case + * + * Application Layer - Hexagonal Architecture + * + * Creates or updates a schedule override with external scheduler provisioning. + * Abstracts scheduler provider (AWS EventBridge, etc.) behind schedulerAdapter. + */ +class UpsertScheduleUseCase { + constructor({ commands, schedulerAdapter, scriptFactory }) { + this.commands = commands; + this.schedulerAdapter = schedulerAdapter; + this.scriptFactory = scriptFactory; + } + + /** + * Create or update a schedule + * @param {string} scriptName - Name of the script + * @param {Object} input - Schedule configuration + * @param {boolean} input.enabled - Whether schedule is enabled + * @param {string} [input.cronExpression] - Cron expression (required if enabled) + * @param {string} [input.timezone] - Timezone (defaults to UTC) + * @returns {Promise<{success: boolean, schedule: Object, schedulerWarning?: string}>} + */ + async execute(scriptName, { enabled, cronExpression, timezone }) { + this._validateScriptExists(scriptName); + this._validateInput(enabled, cronExpression); + + // Save to database + const schedule = await this.commands.upsertSchedule({ + scriptName, + enabled, + cronExpression: cronExpression || null, + timezone: timezone || 'UTC', + }); + + // Sync with external scheduler (AWS EventBridge, etc.) + const schedulerResult = await this._syncExternalScheduler( + scriptName, + enabled, + cronExpression, + timezone, + schedule.externalScheduleId + ); + + return { + success: true, + schedule: { + ...schedule, + externalScheduleId: schedulerResult.externalScheduleId || schedule.externalScheduleId, + externalScheduleName: schedulerResult.externalScheduleName || schedule.externalScheduleName, + }, + ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }), + }; + } + + /** + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * @private + */ + _validateInput(enabled, cronExpression) { + if (typeof enabled !== 'boolean') { + const error = new Error('enabled must be a boolean'); + error.code = 'INVALID_INPUT'; + throw error; + } + + if (enabled && !cronExpression) { + const error = new Error('cronExpression is required when enabled is true'); + error.code = 'INVALID_INPUT'; + throw error; + } + } + + /** + * Sync with external scheduler service + * Abstracts AWS EventBridge or other scheduler providers + * @private + */ + async _syncExternalScheduler(scriptName, enabled, cronExpression, timezone, existingId) { + const result = { externalScheduleId: null, externalScheduleName: null, warning: null }; + + try { + if (enabled && cronExpression) { + // Create/update external schedule + const schedulerInfo = await this.schedulerAdapter.createSchedule({ + scriptName, + cronExpression, + timezone: timezone || 'UTC', + }); + + if (schedulerInfo?.scheduleArn) { + await this.commands.updateScheduleExternalInfo(scriptName, { + externalScheduleId: schedulerInfo.scheduleArn, + externalScheduleName: schedulerInfo.scheduleName, + }); + result.externalScheduleId = schedulerInfo.scheduleArn; + result.externalScheduleName = schedulerInfo.scheduleName; + } + } else if (!enabled && existingId) { + // Delete external schedule + await this.schedulerAdapter.deleteSchedule(scriptName); + await this.commands.updateScheduleExternalInfo(scriptName, { + externalScheduleId: null, + externalScheduleName: null, + }); + } + } catch (error) { + // Non-fatal: DB schedule is saved, external scheduler can be retried + result.warning = error.message; + } + + return result; + } +} + +module.exports = { UpsertScheduleUseCase }; diff --git a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js index f9422e12e..9c90f8355 100644 --- a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js +++ b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js @@ -6,7 +6,7 @@ describe('IntegrationHealthCheckScript', () => { expect(IntegrationHealthCheckScript.Definition.name).toBe('integration-health-check'); expect(IntegrationHealthCheckScript.Definition.version).toBe('1.0.0'); expect(IntegrationHealthCheckScript.Definition.source).toBe('BUILTIN'); - expect(IntegrationHealthCheckScript.Definition.config.requiresIntegrationFactory).toBe(true); + expect(IntegrationHealthCheckScript.Definition.config.requireIntegrationInstance).toBe(true); }); it('should have valid input schema', () => { diff --git a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js index 9de4b191a..1f2e36ac8 100644 --- a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js +++ b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js @@ -6,7 +6,7 @@ describe('OAuthTokenRefreshScript', () => { expect(OAuthTokenRefreshScript.Definition.name).toBe('oauth-token-refresh'); expect(OAuthTokenRefreshScript.Definition.version).toBe('1.0.0'); expect(OAuthTokenRefreshScript.Definition.source).toBe('BUILTIN'); - expect(OAuthTokenRefreshScript.Definition.config.requiresIntegrationFactory).toBe(true); + expect(OAuthTokenRefreshScript.Definition.config.requireIntegrationInstance).toBe(true); }); it('should have valid input schema', () => { diff --git a/packages/admin-scripts/src/builtins/integration-health-check.js b/packages/admin-scripts/src/builtins/integration-health-check.js index 147c7dc9e..31a5c5d55 100644 --- a/packages/admin-scripts/src/builtins/integration-health-check.js +++ b/packages/admin-scripts/src/builtins/integration-health-check.js @@ -54,7 +54,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase { config: { timeout: 900000, // 15 minutes maxRetries: 0, - requiresIntegrationFactory: true, + requireIntegrationInstance: true, }, schedule: { diff --git a/packages/admin-scripts/src/builtins/oauth-token-refresh.js b/packages/admin-scripts/src/builtins/oauth-token-refresh.js index 6586e8267..e39cc539e 100644 --- a/packages/admin-scripts/src/builtins/oauth-token-refresh.js +++ b/packages/admin-scripts/src/builtins/oauth-token-refresh.js @@ -47,7 +47,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase { config: { timeout: 600000, // 10 minutes maxRetries: 1, - requiresIntegrationFactory: true, // Needs to call external APIs + requireIntegrationInstance: true, // Needs to call external APIs }, display: { diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js index 4c99f42d4..47fed7290 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -99,7 +99,7 @@ describe('Admin Script Router', () => { version: '1.0.0', description: 'Test script', category: 'test', - requiresIntegrationFactory: false, + requireIntegrationInstance: false, schedule: null, }); }); @@ -293,8 +293,8 @@ describe('Admin Script Router', () => { timezone: 'America/New_York', lastTriggeredAt: new Date('2025-01-01T09:00:00Z'), nextTriggerAt: new Date('2025-01-02T09:00:00Z'), - awsScheduleArn: 'arn:aws:events:us-east-1:123456789012:rule/test', - awsScheduleName: 'test-script-schedule', + externalScheduleId: 'arn:aws:events:us-east-1:123456789012:rule/test', + externalScheduleName: 'test-script-schedule', createdAt: new Date('2025-01-01T00:00:00Z'), updatedAt: new Date('2025-01-01T00:00:00Z'), }; @@ -470,7 +470,7 @@ describe('Admin Script Router', () => { }; mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); - mockCommands.updateScheduleAwsInfo = jest.fn().mockResolvedValue(newSchedule); + mockCommands.updateScheduleExternalInfo = jest.fn().mockResolvedValue(newSchedule); mockSchedulerAdapter.createSchedule.mockResolvedValue({ scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', scheduleName: 'frigg-script-test-script', @@ -490,11 +490,11 @@ describe('Admin Script Router', () => { cronExpression: '0 12 * * *', timezone: 'America/Los_Angeles', }); - expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalledWith('test-script', { - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - awsScheduleName: 'frigg-script-test-script', + expect(mockCommands.updateScheduleExternalInfo).toHaveBeenCalledWith('test-script', { + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + externalScheduleName: 'frigg-script-test-script', }); - expect(response.body.schedule.awsScheduleArn).toBe('arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script'); + expect(response.body.schedule.externalScheduleId).toBe('arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script'); }); it('should delete EventBridge schedule when disabling existing schedule', async () => { @@ -503,14 +503,14 @@ describe('Admin Script Router', () => { enabled: false, cronExpression: null, timezone: 'UTC', - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - awsScheduleName: 'frigg-script-test-script', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + externalScheduleName: 'frigg-script-test-script', createdAt: new Date(), updatedAt: new Date(), }; mockCommands.upsertSchedule = jest.fn().mockResolvedValue(existingSchedule); - mockCommands.updateScheduleAwsInfo = jest.fn().mockResolvedValue(existingSchedule); + mockCommands.updateScheduleExternalInfo = jest.fn().mockResolvedValue(existingSchedule); mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); const response = await request(app) @@ -521,9 +521,9 @@ describe('Admin Script Router', () => { expect(response.status).toBe(200); expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); - expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalledWith('test-script', { - awsScheduleArn: null, - awsScheduleName: null, + expect(mockCommands.updateScheduleExternalInfo).toHaveBeenCalledWith('test-script', { + externalScheduleId: null, + externalScheduleName: null, }); }); @@ -632,7 +632,7 @@ describe('Admin Script Router', () => { expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); }); - it('should delete EventBridge schedule when AWS rule exists', async () => { + it('should delete EventBridge schedule when external rule exists', async () => { mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ acknowledged: true, deletedCount: 1, @@ -640,8 +640,8 @@ describe('Admin Script Router', () => { scriptName: 'test-script', enabled: true, cronExpression: '0 12 * * *', - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', - awsScheduleName: 'frigg-script-test-script', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + externalScheduleName: 'frigg-script-test-script', }, }); mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); @@ -654,7 +654,7 @@ describe('Admin Script Router', () => { expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); }); - it('should not call scheduler when no AWS rule exists', async () => { + it('should not call scheduler when no external rule exists', async () => { mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ acknowledged: true, deletedCount: 1, @@ -662,7 +662,7 @@ describe('Admin Script Router', () => { scriptName: 'test-script', enabled: true, cronExpression: '0 12 * * *', - // No awsScheduleArn + // No externalScheduleId }, }); @@ -682,10 +682,10 @@ describe('Admin Script Router', () => { scriptName: 'test-script', enabled: true, cronExpression: '0 12 * * *', - awsScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', }, }); - mockSchedulerAdapter.deleteSchedule.mockRejectedValue(new Error('AWS Scheduler delete failed')); + mockSchedulerAdapter.deleteSchedule.mockRejectedValue(new Error('Scheduler delete failed')); const response = await request(app).delete( '/admin/scripts/test-script/schedule' @@ -694,7 +694,7 @@ describe('Admin Script Router', () => { // Request should succeed despite scheduler error expect(response.status).toBe(200); expect(response.body.success).toBe(true); - expect(response.body.schedulerWarning).toBe('AWS Scheduler delete failed'); + expect(response.body.schedulerWarning).toBe('Scheduler delete failed'); }); }); }); diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index c8a50d69a..08e8fc926 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -6,7 +6,11 @@ const { createScriptRunner } = require('../application/script-runner'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); const { QueuerUtil } = require('@friggframework/core/queues'); const { createSchedulerAdapter } = require('../adapters/scheduler-adapter-factory'); -const { ScheduleManagementUseCase } = require('../application/schedule-management-use-case'); +const { + GetEffectiveScheduleUseCase, + UpsertScheduleUseCase, + DeleteScheduleUseCase, +} = require('../application/use-cases'); const router = express.Router(); @@ -14,15 +18,19 @@ const router = express.Router(); router.use(validateAdminApiKey); /** - * Create ScheduleManagementUseCase instance + * Create schedule use case instances * @private */ -function createScheduleManagementUseCase() { - return new ScheduleManagementUseCase({ - commands: createAdminScriptCommands(), - schedulerAdapter: createSchedulerAdapter(), - scriptFactory: getScriptFactory(), - }); +function createScheduleUseCases() { + const commands = createAdminScriptCommands(); + const schedulerAdapter = createSchedulerAdapter(); + const scriptFactory = getScriptFactory(); + + return { + getEffectiveSchedule: new GetEffectiveScheduleUseCase({ commands, scriptFactory }), + upsertSchedule: new UpsertScheduleUseCase({ commands, schedulerAdapter, scriptFactory }), + deleteSchedule: new DeleteScheduleUseCase({ commands, schedulerAdapter, scriptFactory }), + }; } /** @@ -40,8 +48,8 @@ router.get('/scripts', async (req, res) => { version: s.definition.version, description: s.definition.description, category: s.definition.display?.category || 'custom', - requiresIntegrationFactory: - s.definition.config?.requiresIntegrationFactory || false, + requireIntegrationInstance: + s.definition.config?.requireIntegrationInstance || false, schedule: s.definition.schedule || null, })), }); @@ -211,9 +219,9 @@ router.get('/scripts/:scriptName/executions', async (req, res) => { router.get('/scripts/:scriptName/schedule', async (req, res) => { try { const { scriptName } = req.params; - const useCase = createScheduleManagementUseCase(); + const { getEffectiveSchedule } = createScheduleUseCases(); - const result = await useCase.getEffectiveSchedule(scriptName); + const result = await getEffectiveSchedule.execute(scriptName); res.json({ source: result.source, @@ -240,9 +248,9 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { try { const { scriptName } = req.params; const { enabled, cronExpression, timezone } = req.body; - const useCase = createScheduleManagementUseCase(); + const { upsertSchedule } = createScheduleUseCases(); - const result = await useCase.upsertSchedule(scriptName, { + const result = await upsertSchedule.execute(scriptName, { enabled, cronExpression, timezone, @@ -281,9 +289,9 @@ router.put('/scripts/:scriptName/schedule', async (req, res) => { router.delete('/scripts/:scriptName/schedule', async (req, res) => { try { const { scriptName } = req.params; - const useCase = createScheduleManagementUseCase(); + const { deleteSchedule } = createScheduleUseCases(); - const result = await useCase.deleteSchedule(scriptName); + const result = await deleteSchedule.execute(scriptName); res.json(result); } catch (error) { diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js b/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js index 1d8edad1d..24f44e3cf 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-interface.js @@ -34,12 +34,12 @@ class ScriptScheduleRepositoryInterface { * @param {boolean} params.enabled - Whether schedule is enabled * @param {string} params.cronExpression - Cron expression * @param {string} [params.timezone] - Timezone (default 'UTC') - * @param {string} [params.awsScheduleArn] - AWS EventBridge Scheduler ARN - * @param {string} [params.awsScheduleName] - AWS EventBridge Scheduler name + * @param {string} [params.externalScheduleId] - External scheduler ID (e.g., AWS ARN) + * @param {string} [params.externalScheduleName] - External scheduler name * @returns {Promise} Created or updated schedule record * @abstract */ - async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsScheduleArn, awsScheduleName }) { + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, externalScheduleId, externalScheduleName }) { throw new Error('Method upsertSchedule must be implemented by subclass'); } @@ -55,17 +55,17 @@ class ScriptScheduleRepositoryInterface { } /** - * Update AWS EventBridge Scheduler information + * Update external scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS schedule information - * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN - * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name + * @param {Object} externalInfo - External schedule information + * @param {string} [externalInfo.externalScheduleId] - External scheduler ID (e.g., AWS ARN) + * @param {string} [externalInfo.externalScheduleName] - External scheduler name * @returns {Promise} Updated schedule record * @abstract */ - async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { - throw new Error('Method updateScheduleAwsInfo must be implemented by subclass'); + async updateScheduleExternalInfo(scriptName, { externalScheduleId, externalScheduleName }) { + throw new Error('Method updateScheduleExternalInfo must be implemented by subclass'); } /** diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js b/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js index 76e4f6445..064ed0527 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-mongo.js @@ -40,20 +40,20 @@ class ScriptScheduleRepositoryMongo extends ScriptScheduleRepositoryInterface { * @param {boolean} params.enabled - Whether schedule is enabled * @param {string} params.cronExpression - Cron expression * @param {string} [params.timezone] - Timezone (default 'UTC') - * @param {string} [params.awsScheduleArn] - AWS EventBridge Scheduler ARN - * @param {string} [params.awsScheduleName] - AWS EventBridge Scheduler name + * @param {string} [params.externalScheduleId] - External scheduler ID (e.g., AWS ARN) + * @param {string} [params.externalScheduleName] - External scheduler name * @returns {Promise} Created or updated schedule record */ - async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsScheduleArn, awsScheduleName }) { + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, externalScheduleId, externalScheduleName }) { const data = { enabled, cronExpression, timezone: timezone || 'UTC', }; - // Only set AWS fields if provided - if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; - if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; + // Only set external scheduler fields if provided + if (externalScheduleId !== undefined) data.externalScheduleId = externalScheduleId; + if (externalScheduleName !== undefined) data.externalScheduleName = externalScheduleName; const schedule = await this.prisma.scriptSchedule.upsert({ where: { scriptName }, @@ -97,18 +97,18 @@ class ScriptScheduleRepositoryMongo extends ScriptScheduleRepositoryInterface { } /** - * Update AWS EventBridge Scheduler information + * Update external scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS schedule information - * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN - * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name + * @param {Object} externalInfo - External schedule information + * @param {string} [externalInfo.externalScheduleId] - External scheduler ID (e.g., AWS ARN) + * @param {string} [externalInfo.externalScheduleName] - External scheduler name * @returns {Promise} Updated schedule record */ - async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { + async updateScheduleExternalInfo(scriptName, { externalScheduleId, externalScheduleName }) { const data = {}; - if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; - if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; + if (externalScheduleId !== undefined) data.externalScheduleId = externalScheduleId; + if (externalScheduleName !== undefined) data.externalScheduleName = externalScheduleName; const schedule = await this.prisma.scriptSchedule.update({ where: { scriptName }, diff --git a/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js index 8dca9c2ea..af73213d2 100644 --- a/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js +++ b/packages/core/admin-scripts/repositories/script-schedule-repository-postgres.js @@ -71,20 +71,20 @@ class ScriptScheduleRepositoryPostgres extends ScriptScheduleRepositoryInterface * @param {boolean} params.enabled - Whether schedule is enabled * @param {string} params.cronExpression - Cron expression * @param {string} [params.timezone] - Timezone (default 'UTC') - * @param {string} [params.awsScheduleArn] - AWS EventBridge Scheduler ARN - * @param {string} [params.awsScheduleName] - AWS EventBridge Scheduler name + * @param {string} [params.externalScheduleId] - External scheduler ID (e.g., AWS ARN) + * @param {string} [params.externalScheduleName] - External scheduler name * @returns {Promise} Created or updated schedule record with string ID */ - async upsertSchedule({ scriptName, enabled, cronExpression, timezone, awsScheduleArn, awsScheduleName }) { + async upsertSchedule({ scriptName, enabled, cronExpression, timezone, externalScheduleId, externalScheduleName }) { const data = { enabled, cronExpression, timezone: timezone || 'UTC', }; - // Only set AWS fields if provided - if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; - if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; + // Only set external scheduler fields if provided + if (externalScheduleId !== undefined) data.externalScheduleId = externalScheduleId; + if (externalScheduleName !== undefined) data.externalScheduleName = externalScheduleName; const schedule = await this.prisma.scriptSchedule.upsert({ where: { scriptName }, @@ -128,18 +128,18 @@ class ScriptScheduleRepositoryPostgres extends ScriptScheduleRepositoryInterface } /** - * Update AWS EventBridge Scheduler information + * Update external scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS schedule information - * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN - * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name + * @param {Object} externalInfo - External schedule information + * @param {string} [externalInfo.externalScheduleId] - External scheduler ID (e.g., AWS ARN) + * @param {string} [externalInfo.externalScheduleName] - External scheduler name * @returns {Promise} Updated schedule record with string ID */ - async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { + async updateScheduleExternalInfo(scriptName, { externalScheduleId, externalScheduleName }) { const data = {}; - if (awsScheduleArn !== undefined) data.awsScheduleArn = awsScheduleArn; - if (awsScheduleName !== undefined) data.awsScheduleName = awsScheduleName; + if (externalScheduleId !== undefined) data.externalScheduleId = externalScheduleId; + if (externalScheduleName !== undefined) data.externalScheduleName = externalScheduleName; const schedule = await this.prisma.scriptSchedule.update({ where: { scriptName }, diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js index 3215f0ea2..ec493587b 100644 --- a/packages/core/application/commands/admin-script-commands.js +++ b/packages/core/application/commands/admin-script-commands.js @@ -283,19 +283,19 @@ function createAdminScriptCommands() { }, /** - * Update AWS EventBridge Scheduler information + * Update external scheduler information * * @param {string} scriptName - The script name - * @param {Object} awsInfo - AWS schedule information - * @param {string} [awsInfo.awsScheduleArn] - AWS EventBridge Scheduler ARN - * @param {string} [awsInfo.awsScheduleName] - AWS EventBridge Scheduler name + * @param {Object} externalInfo - External schedule information + * @param {string} [externalInfo.externalScheduleId] - External scheduler ID (e.g., AWS ARN) + * @param {string} [externalInfo.externalScheduleName] - External scheduler name * @returns {Promise} Updated schedule */ - async updateScheduleAwsInfo(scriptName, { awsScheduleArn, awsScheduleName }) { + async updateScheduleExternalInfo(scriptName, { externalScheduleId, externalScheduleName }) { try { - const schedule = await scheduleRepository.updateScheduleAwsInfo(scriptName, { - awsScheduleArn, - awsScheduleName, + const schedule = await scheduleRepository.updateScheduleExternalInfo(scriptName, { + externalScheduleId, + externalScheduleName, }); return schedule; } catch (error) { diff --git a/packages/core/prisma-mongodb/schema.prisma b/packages/core/prisma-mongodb/schema.prisma index 4ff8ca340..0645334ae 100644 --- a/packages/core/prisma-mongodb/schema.prisma +++ b/packages/core/prisma-mongodb/schema.prisma @@ -419,9 +419,9 @@ model ScriptSchedule { lastTriggeredAt DateTime? nextTriggerAt DateTime? - // AWS EventBridge Schedule (if provisioned) - awsScheduleArn String? - awsScheduleName String? + // External Scheduler (e.g., AWS EventBridge Scheduler) + externalScheduleId String? + externalScheduleName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/core/prisma-postgresql/schema.prisma b/packages/core/prisma-postgresql/schema.prisma index b91ef8c13..e544ec23b 100644 --- a/packages/core/prisma-postgresql/schema.prisma +++ b/packages/core/prisma-postgresql/schema.prisma @@ -402,9 +402,9 @@ model ScriptSchedule { lastTriggeredAt DateTime? nextTriggerAt DateTime? - // AWS EventBridge Schedule (if provisioned) - awsScheduleArn String? - awsScheduleName String? + // External Scheduler (e.g., AWS EventBridge Scheduler) + externalScheduleId String? + externalScheduleName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From aeeef23b75be72e029f84eb3ad392c01a7da0ed9 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Tue, 30 Dec 2025 01:25:57 -0500 Subject: [PATCH 27/33] refactor(admin-scripts): address PR review - constructor injection, rename context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename AdminFriggCommands → AdminScriptContext (facade pattern) - Refactor to constructor injection (context via constructor, not execute) - Remove logging from AdminScriptBase (use context.log instead) - Clean up display object - only UI-specific overrides - Strip verbose JSDoc comments (keep code sparse) - Update all tests for new API - Add PR review tracker for remaining items 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../admin-scripts/PR_517_REVIEW_TRACKER.md | 56 ++++++ packages/admin-scripts/index.js | 11 +- .../__tests__/admin-script-base.test.js | 180 ++++++++++-------- .../src/application/admin-frigg-commands.js | 36 ++-- .../src/application/admin-script-base.js | 117 ++---------- .../src/application/script-runner.js | 14 +- .../integration-health-check.test.js | 125 ++++++------ .../__tests__/oauth-token-refresh.test.js | 80 ++++---- .../src/builtins/integration-health-check.js | 45 +++-- .../src/builtins/oauth-token-refresh.js | 37 ++-- 10 files changed, 353 insertions(+), 348 deletions(-) create mode 100644 packages/admin-scripts/PR_517_REVIEW_TRACKER.md diff --git a/packages/admin-scripts/PR_517_REVIEW_TRACKER.md b/packages/admin-scripts/PR_517_REVIEW_TRACKER.md new file mode 100644 index 000000000..6ddc2c4bc --- /dev/null +++ b/packages/admin-scripts/PR_517_REVIEW_TRACKER.md @@ -0,0 +1,56 @@ +# PR #517 Comment Tracker + +## ✅ ADDRESSED - Ready to Reply + +| # | File:Line | Original Comment | What We Did | Reply | +|---|-----------|------------------|-------------|-------| +| 1 | `admin-frigg-commands.js:15` | "I don't think this should even exist, we're just duplicating methods" | Renamed to `AdminScriptContext`, clarified as facade pattern | Renamed to `AdminScriptContext`. It's a facade - wraps repositories so scripts have one API instead of multiple imports. Open to discussing alternatives. | +| 2 | `admin-script-base.js:94` | "Commands should come via constructor" | Changed to constructor injection | Done. Context now passed via constructor, scripts access via `this.context`. | +| 3 | `admin-script-base.js:109` | "logging does not belong here" | Removed logging from base class | Removed. Scripts use `this.context.log()` which persists to admin process record. | +| 4 | `admin-script-base.js:54` | "I would rename to requireIntegrationInstance" | Already renamed | Done - already renamed in current code. | +| 5 | `admin-script-base.js:56` | "This is just a duplication of the static Definition" | Cleaned up display object | Cleaned up. `display` now only holds UI overrides (category, icon). Label/description fall back to top-level via static methods. | +| 6 | `schedule-management-use-case.js:1` | "A use case should have single entry point" | Already split in previous session | Already split into `UpsertScheduleUseCase`, `DeleteScheduleUseCase`, `GetEffectiveScheduleUseCase`. | +| 7 | `package.json:20` | "chai and sinon should slowly be pushed away" | Sinon removed in previous session | Sinon already removed. Will remove chai too. | + +--- + +## 📝 NEEDS RESPONSE ONLY - No Code Change Required + +| # | File:Line | Original Comment | Reply | +|---|-----------|------------------|-------| +| 8 | `admin-frigg-commands.js:160` | "this does not belong here" (queueScript) | queueScript enables self-queuing pattern (fan-out, pagination, retries). It's here so scripts don't need queue internals. Could move to separate utility if preferred. | +| 9 | `admin-script-base.js:39` | "why do we need the source?" | Distinguishes builtin vs user-defined scripts. UI can filter differently, builtins could have special handling. Could remove if not needed. | +| 10 | `admin-script-base.js:42` | "what's the idea with these schemas?" | Optional JSON Schema for validation/documentation. Could wire to OpenAPI or dynamic UI forms. Not critical for v1 - could remove and add later. | +| 11 | `admin-script-base.js:46` | "enabled property confusion" | Agreed the matrix is confusing. Intent: `schedule.enabled` controls auto-trigger independent of registration. Could simplify to just use presence in appDefinition. | +| 12 | `admin-script-base.js:52` | "Do we have retry logic in place already?" | Not yet - placeholder for Phase 2. Could remove until we build it. | +| 13 | `admin-script-base.js:81` | "What is the executionId?" | ID of AdminProcess record tracking this execution. Used to persist logs and update status. Created before script runs, passed to constructor. | +| 14 | `schedule-management-use-case.js:89` | "why save to database if EventBridge is source of truth?" | Database stores user's config override. EventBridge is execution engine. On deploy, we sync DB to EventBridge. Tracks user config vs code default. | + +--- + +## 🔧 OUTSTANDING - Needs Code Changes + +| # | File:Line | Original Comment | Task | +|---|-----------|------------------|------| +| 15 | `docs/architecture-decisions/005-admin-script-runner.md:71` | "What is 'frigg' in this parameter?" | Update ADR - rename `frigg` to `context` throughout | +| 16 | `package.json:22` | "We already use nock for http request mocking" | Remove msw if present, use nock consistently | +| 17 | `package.json:12` | "why mongoose?" | Check if mongoose needed or can be removed | +| 18 | `schedule-management-use-case.js:109` | "leaking AWS specifics" | Abstract behind SchedulerAdapter, remove EventBridge references from use case | +| 19 | `schedule-management-use-case.js:138` | "should not mention EventBridge here" | Same as above | +| 20 | `adapters/aws-scheduler-adapter.js:30` | "should not infer/guess/default any variable" | Remove defaults, require explicit config | +| 21 | `adapters/scheduler-adapter-factory.js:48` | "env var confusion, prefer appDefinition" | Move scheduler config to appDefinition | +| 22 | `script-runner.js:36` | "we should not assume default values" | Remove defaults, require explicit values | +| 23 | `dry-run-http-interceptor.js:1` | "I don't understand why this is needed" | Explain or remove - was for intercepting HTTP in dry-run mode | +| 24 | `dry-run-repository-wrapper.js:1` | "This is smelly" | Review/remove - was for wrapping repos in dry-run mode | +| 25 | `.github/workflows/release.yml:11` | "why do we need those?" | Check release workflow changes | + +--- + +## ❓ NEEDS DISCUSSION - Architectural Decisions + +| # | File:Line | Original Comment | Decision Needed | +|---|-----------|------------------|-----------------| +| 26 | `admin-script-base.js:39` | source field | Keep BUILTIN/USER_DEFINED or remove? | +| 27 | `admin-script-base.js:42` | inputSchema/outputSchema | Keep for future or remove for now? | +| 28 | `admin-script-base.js:46` | schedule.enabled | Simplify to just appDefinition presence? | +| 29 | `admin-script-base.js:52` | maxRetries | Remove placeholder or keep? | diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index e9884d14f..66cf01952 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -8,7 +8,13 @@ // Application Services const { ScriptFactory, getScriptFactory, createScriptFactory } = require('./src/application/script-factory'); const { AdminScriptBase } = require('./src/application/admin-script-base'); -const { AdminFriggCommands, createAdminFriggCommands } = require('./src/application/admin-frigg-commands'); +const { + AdminScriptContext, + createAdminScriptContext, + // Legacy aliases (deprecated) + AdminFriggCommands, + createAdminFriggCommands, +} = require('./src/application/admin-frigg-commands'); const { ScriptRunner, createScriptRunner } = require('./src/application/script-runner'); // Infrastructure @@ -39,6 +45,9 @@ module.exports = { ScriptFactory, getScriptFactory, createScriptFactory, + AdminScriptContext, + createAdminScriptContext, + // Legacy aliases (deprecated) AdminFriggCommands, createAdminFriggCommands, ScriptRunner, diff --git a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js index 0baa6f9da..20986a8bd 100644 --- a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js +++ b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js @@ -31,9 +31,8 @@ describe('AdminScriptBase', () => { requireIntegrationInstance: true, }, display: { - label: 'Test Script', - description: 'For testing', category: 'testing', + icon: 'test-icon', }, }; } @@ -45,6 +44,15 @@ describe('AdminScriptBase', () => { expect(TestScript.Definition.schedule.enabled).toBe(true); expect(TestScript.Definition.config.timeout).toBe(600000); }); + + it('should have clean display object without redundant fields', () => { + // Default display should only have UI-specific fields + expect(AdminScriptBase.Definition.display).toBeDefined(); + expect(AdminScriptBase.Definition.display.category).toBe('maintenance'); + // Should NOT have redundant label/description + expect(AdminScriptBase.Definition.display.label).toBeUndefined(); + expect(AdminScriptBase.Definition.display.description).toBeUndefined(); + }); }); describe('Static methods', () => { @@ -90,18 +98,68 @@ describe('AdminScriptBase', () => { source: 'USER_DEFINED', }); }); + + it('getDisplayLabel() should return display.label or fall back to name', () => { + class ScriptWithLabel extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'test', + display: { label: 'My Custom Label' }, + }; + } + + class ScriptWithoutLabel extends AdminScriptBase { + static Definition = { + name: 'another-script', + version: '1.0.0', + description: 'test', + }; + } + + expect(ScriptWithLabel.getDisplayLabel()).toBe('My Custom Label'); + expect(ScriptWithoutLabel.getDisplayLabel()).toBe('another-script'); + }); + + it('getDisplayDescription() should return display.description or fall back to description', () => { + class ScriptWithDisplayDesc extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'Technical description', + display: { description: 'User-friendly description' }, + }; + } + + class ScriptWithoutDisplayDesc extends AdminScriptBase { + static Definition = { + name: 'another-script', + version: '1.0.0', + description: 'Technical description', + }; + } + + expect(ScriptWithDisplayDesc.getDisplayDescription()).toBe('User-friendly description'); + expect(ScriptWithoutDisplayDesc.getDisplayDescription()).toBe('Technical description'); + }); }); describe('Constructor', () => { it('should initialize with default values', () => { const script = new AdminScriptBase(); + expect(script.context).toBeNull(); expect(script.executionId).toBeNull(); - expect(script.logs).toEqual([]); - expect(script._startTime).toBeNull(); expect(script.integrationFactory).toBeNull(); }); + it('should accept context parameter', () => { + const mockContext = { log: jest.fn() }; + const script = new AdminScriptBase({ context: mockContext }); + + expect(script.context).toBe(mockContext); + }); + it('should accept executionId parameter', () => { const script = new AdminScriptBase({ executionId: 'exec_123' }); @@ -117,13 +175,16 @@ describe('AdminScriptBase', () => { expect(script.integrationFactory).toBe(mockFactory); }); - it('should accept both executionId and integrationFactory', () => { + it('should accept all parameters together', () => { + const mockContext = { log: jest.fn() }; const mockFactory = { mock: true }; const script = new AdminScriptBase({ + context: mockContext, executionId: 'exec_456', integrationFactory: mockFactory, }); + expect(script.context).toBe(mockContext); expect(script.executionId).toBe('exec_456'); expect(script.integrationFactory).toBe(mockFactory); }); @@ -133,12 +194,12 @@ describe('AdminScriptBase', () => { it('should throw error when not implemented by subclass', async () => { const script = new AdminScriptBase(); - await expect(script.execute({}, {})).rejects.toThrow( + await expect(script.execute({})).rejects.toThrow( 'AdminScriptBase.execute() must be implemented by subclass' ); }); - it('should allow child classes to implement execute()', async () => { + it('should allow child classes to implement execute() with params only', async () => { class TestScript extends AdminScriptBase { static Definition = { name: 'test', @@ -146,90 +207,45 @@ describe('AdminScriptBase', () => { description: 'test', }; - async execute(frigg, params) { + async execute(params) { return { result: 'success', params }; } } const script = new TestScript(); - const frigg = {}; const params = { foo: 'bar' }; - const result = await script.execute(frigg, params); + const result = await script.execute(params); expect(result.result).toBe('success'); expect(result.params).toEqual({ foo: 'bar' }); }); - }); - - describe('Logging methods', () => { - it('log() should create log entry with timestamp', () => { - const script = new AdminScriptBase(); - const beforeTime = new Date().toISOString(); - - const entry = script.log('info', 'Test message', { key: 'value' }); - - const afterTime = new Date().toISOString(); - - expect(entry.level).toBe('info'); - expect(entry.message).toBe('Test message'); - expect(entry.data).toEqual({ key: 'value' }); - expect(entry.timestamp).toBeDefined(); - expect(entry.timestamp >= beforeTime).toBe(true); - expect(entry.timestamp <= afterTime).toBe(true); - }); - - it('log() should add entry to logs array', () => { - const script = new AdminScriptBase(); - - script.log('info', 'First'); - script.log('error', 'Second'); - script.log('warn', 'Third'); - - const logs = script.getLogs(); - - expect(logs).toHaveLength(3); - expect(logs[0].message).toBe('First'); - expect(logs[1].message).toBe('Second'); - expect(logs[2].message).toBe('Third'); - }); - - it('log() should default data to empty object', () => { - const script = new AdminScriptBase(); - - const entry = script.log('info', 'No data'); - expect(entry.data).toEqual({}); - }); - - it('getLogs() should return logs array', () => { - const script = new AdminScriptBase(); - - script.log('info', 'Message 1'); - script.log('error', 'Message 2'); - - const logs = script.getLogs(); - - expect(logs).toHaveLength(2); - expect(logs[0].level).toBe('info'); - expect(logs[1].level).toBe('error'); - }); + it('should access context via this.context', async () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'test', + }; - it('clearLogs() should empty logs array', () => { - const script = new AdminScriptBase(); + async execute(params) { + this.context.log('info', 'Starting'); + return { success: true }; + } + } - script.log('info', 'Message 1'); - script.log('info', 'Message 2'); - expect(script.getLogs()).toHaveLength(2); + const mockContext = { log: jest.fn() }; + const script = new TestScript({ context: mockContext }); - script.clearLogs(); + await script.execute({}); - expect(script.getLogs()).toHaveLength(0); + expect(mockContext.log).toHaveBeenCalledWith('info', 'Starting'); }); }); describe('Integration with child classes', () => { - it('should support full lifecycle', async () => { + it('should support full lifecycle with context injection', async () => { class MyScript extends AdminScriptBase { static Definition = { name: 'my-script', @@ -240,34 +256,34 @@ describe('AdminScriptBase', () => { }, }; - async execute(frigg, params) { - this.log('info', 'Starting execution'); - this.log('debug', 'Processing', params); + async execute(params) { + this.context.log('info', 'Starting execution'); + this.context.log('debug', 'Processing', params); if (this.integrationFactory) { - this.log('info', 'Integration factory available'); + this.context.log('info', 'Integration factory available'); } return { processed: true }; } } + const mockContext = { log: jest.fn() }; const mockFactory = { getInstanceById: jest.fn() }; const script = new MyScript({ + context: mockContext, executionId: 'exec_789', integrationFactory: mockFactory, }); - const frigg = {}; - const result = await script.execute(frigg, { test: 'data' }); + const result = await script.execute({ test: 'data' }); expect(result).toEqual({ processed: true }); - const logs = script.getLogs(); - expect(logs).toHaveLength(3); - expect(logs[0].message).toBe('Starting execution'); - expect(logs[1].message).toBe('Processing'); - expect(logs[2].message).toBe('Integration factory available'); + expect(mockContext.log).toHaveBeenCalledTimes(3); + expect(mockContext.log).toHaveBeenCalledWith('info', 'Starting execution'); + expect(mockContext.log).toHaveBeenCalledWith('debug', 'Processing', { test: 'data' }); + expect(mockContext.log).toHaveBeenCalledWith('info', 'Integration factory available'); }); }); }); diff --git a/packages/admin-scripts/src/application/admin-frigg-commands.js b/packages/admin-scripts/src/application/admin-frigg-commands.js index 21fe574c4..6bf7571c2 100644 --- a/packages/admin-scripts/src/application/admin-frigg-commands.js +++ b/packages/admin-scripts/src/application/admin-frigg-commands.js @@ -1,18 +1,6 @@ const { QueuerUtil } = require('@friggframework/core/queues'); -/** - * AdminFriggCommands - * - * Helper API for admin scripts. Provides: - * - Database access via repositories - * - Integration instantiation (optional) - * - Logging utilities - * - Queue operations for self-queuing pattern - * - * Follows lazy-loading pattern for repositories to avoid circular dependencies - * and unnecessary initialization. - */ -class AdminFriggCommands { +class AdminScriptContext { constructor(params = {}) { this.executionId = params.executionId || null; this.logs = []; @@ -151,12 +139,8 @@ class AdminFriggCommands { }); } - // ==================== QUEUE OPERATIONS (Self-Queuing Pattern) ==================== + // ==================== QUEUE OPERATIONS ==================== - /** - * Queue a script for execution - * Used for self-queuing pattern with long-running scripts - */ async queueScript(scriptName, params = {}) { const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; if (!queueUrl) { @@ -176,9 +160,6 @@ class AdminFriggCommands { this.log('info', `Queued continuation for ${scriptName}`, { params }); } - /** - * Queue multiple scripts in a batch - */ async queueScriptBatch(entries) { const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; if (!queueUrl) { @@ -230,13 +211,20 @@ class AdminFriggCommands { } /** - * Create AdminFriggCommands instance + * Create AdminScriptContext instance */ -function createAdminFriggCommands(params = {}) { - return new AdminFriggCommands(params); +function createAdminScriptContext(params = {}) { + return new AdminScriptContext(params); } +// Legacy aliases for backwards compatibility +const AdminFriggCommands = AdminScriptContext; +const createAdminFriggCommands = createAdminScriptContext; + module.exports = { + AdminScriptContext, + createAdminScriptContext, + // Legacy exports (deprecated) AdminFriggCommands, createAdminFriggCommands, }; diff --git a/packages/admin-scripts/src/application/admin-script-base.js b/packages/admin-scripts/src/application/admin-script-base.js index 538e64428..e6ad30165 100644 --- a/packages/admin-scripts/src/application/admin-script-base.js +++ b/packages/admin-scripts/src/application/admin-script-base.js @@ -1,63 +1,27 @@ -const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); - -/** - * Admin Script Base Class - * - * Base class for all admin scripts. Provides: - * - Standard script definition pattern - * - Repository access - * - Logging helpers - * - Integration factory support (optional) - * - * Usage: - * ```javascript - * class MyScript extends AdminScriptBase { - * static Definition = { - * name: 'my-script', - * version: '1.0.0', - * description: 'Does something useful', - * ... - * }; - * - * async execute(frigg, params) { - * // Your script logic here - * } - * } - * ``` - */ class AdminScriptBase { - /** - * CHILDREN SHOULD SPECIFY A DEFINITION FOR THE SCRIPT - * Pattern matches IntegrationBase.Definition - */ static Definition = { - name: 'Script Name', // Required: unique identifier - version: '0.0.0', // Required: semver for migrations - description: 'What this script does', // Required: human-readable - - // Script-specific properties + name: 'Script Name', + version: '0.0.0', + description: 'What this script does', source: 'USER_DEFINED', // 'BUILTIN' | 'USER_DEFINED' - inputSchema: null, // Optional: JSON Schema for params - outputSchema: null, // Optional: JSON Schema for results + inputSchema: null, + outputSchema: null, schedule: { - // Optional: Phase 2 enabled: false, - cronExpression: null, // 'cron(0 12 * * ? *)' + cronExpression: null, }, config: { - timeout: 300000, // Default 5 min (ms) + timeout: 300000, maxRetries: 0, - requireIntegrationInstance: false, // Hint: does script need to instantiate integrations? + requireIntegrationInstance: false, }, display: { - // For future UI - label: 'Script Name', - description: '', - category: 'maintenance', // 'maintenance' | 'healing' | 'sync' | 'custom' + category: 'maintenance', + icon: null, }, }; @@ -73,63 +37,22 @@ class AdminScriptBase { return this.Definition; } - /** - * Constructor receives dependencies - * Pattern matches IntegrationBase constructor - */ - constructor(params = {}) { - this.executionId = params.executionId || null; - this.logs = []; - this._startTime = null; - - // OPTIONAL: Integration factory for scripts that need it - this.integrationFactory = params.integrationFactory || null; - - // OPTIONAL: Injected repositories (for testing or custom implementations) - this.adminProcessRepository = params.adminProcessRepository || null; - } - - /** - * CHILDREN MUST IMPLEMENT THIS METHOD - * @param {AdminFriggCommands} frigg - Helper commands object - * @param {Object} params - Script parameters (validated against inputSchema) - * @returns {Promise} - Script results (validated against outputSchema) - */ - async execute(frigg, params) { - throw new Error('AdminScriptBase.execute() must be implemented by subclass'); + static getDisplayLabel() { + return this.Definition.display?.label || this.Definition.name; } - /** - * Logging helper - * @param {string} level - Log level (info, warn, error, debug) - * @param {string} message - Log message - * @param {Object} data - Additional data - * @returns {Object} Log entry - */ - log(level, message, data = {}) { - const entry = { - level, - message, - data, - timestamp: new Date().toISOString(), - }; - this.logs.push(entry); - return entry; + static getDisplayDescription() { + return this.Definition.display?.description || this.Definition.description; } - /** - * Get all logs - * @returns {Array} Log entries - */ - getLogs() { - return this.logs; + constructor(params = {}) { + this.context = params.context || null; + this.executionId = params.executionId || null; + this.integrationFactory = params.integrationFactory || null; } - /** - * Clear all logs - */ - clearLogs() { - this.logs = []; + async execute(params) { + throw new Error('AdminScriptBase.execute() must be implemented by subclass'); } } diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js index 9b1234f43..f18fe9e5b 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -1,5 +1,5 @@ const { getScriptFactory } = require('./script-factory'); -const { createAdminFriggCommands } = require('./admin-frigg-commands'); +const { createAdminScriptContext } = require('./admin-frigg-commands'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); /** @@ -7,8 +7,7 @@ const { createAdminScriptCommands } = require('@friggframework/core/application/ * * Orchestrates script execution with: * - Execution record creation - * - Script instantiation - * - AdminFriggCommands injection + * - Script instantiation with context injection * - Error handling * - Status updates */ @@ -71,20 +70,21 @@ class ScriptRunner { try { await this.commands.updateAdminProcessState(executionId, 'RUNNING'); - // Create frigg commands for the script - const frigg = createAdminFriggCommands({ + // Create context for the script (facade over repositories, queue, logging) + const context = createAdminScriptContext({ executionId, integrationFactory: this.integrationFactory, }); - // Create script instance + // Create script instance with context injected via constructor const script = this.scriptFactory.createInstance(scriptName, { + context, executionId, integrationFactory: this.integrationFactory, }); // Execute the script - const output = await script.execute(frigg, params); + const output = await script.execute(params); // Calculate metrics const endTime = new Date(); diff --git a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js index 9c90f8355..2ae9b9746 100644 --- a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js +++ b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js @@ -37,27 +37,33 @@ describe('IntegrationHealthCheckScript', () => { it('should have appropriate timeout configuration', () => { expect(IntegrationHealthCheckScript.Definition.config.timeout).toBe(900000); // 15 minutes }); + + it('should have clean display object', () => { + // Display should only have UI-specific fields + expect(IntegrationHealthCheckScript.Definition.display.category).toBe('maintenance'); + // Should NOT have redundant label/description - they're derived from top-level + }); }); describe('execute()', () => { let script; - let mockFrigg; + let mockContext; beforeEach(() => { - script = new IntegrationHealthCheckScript(); - mockFrigg = { + mockContext = { log: jest.fn(), listIntegrations: jest.fn(), findIntegrationById: jest.fn(), instantiate: jest.fn(), updateIntegrationStatus: jest.fn(), }; + script = new IntegrationHealthCheckScript({ context: mockContext }); }); it('should return empty results when no integrations found', async () => { - mockFrigg.listIntegrations.mockResolvedValue([]); + mockContext.listIntegrations.mockResolvedValue([]); - const result = await script.execute(mockFrigg, {}); + const result = await script.execute({}); expect(result.healthy).toBe(0); expect(result.unhealthy).toBe(0); @@ -85,10 +91,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: true }); @@ -112,9 +118,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.listIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: false }); @@ -141,9 +147,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.listIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: false }); @@ -176,10 +182,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: true }); @@ -209,18 +215,18 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); - mockFrigg.updateIntegrationStatus.mockResolvedValue(undefined); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + mockContext.updateIntegrationStatus.mockResolvedValue(undefined); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: true, updateStatus: true }); expect(result.healthy).toBe(1); - expect(mockFrigg.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ACTIVE'); + expect(mockContext.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ACTIVE'); }); it('should update integration status to ERROR for unhealthy integrations', async () => { @@ -232,17 +238,17 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.updateIntegrationStatus.mockResolvedValue(undefined); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.updateIntegrationStatus.mockResolvedValue(undefined); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: false, updateStatus: true }); expect(result.unhealthy).toBe(1); - expect(mockFrigg.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ERROR'); + expect(mockContext.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ERROR'); }); it('should not update status when updateStatus is false', async () => { @@ -265,16 +271,16 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - await script.execute(mockFrigg, { + await script.execute({ checkCredentials: true, checkConnectivity: true, updateStatus: false }); - expect(mockFrigg.updateIntegrationStatus).not.toHaveBeenCalled(); + expect(mockContext.updateIntegrationStatus).not.toHaveBeenCalled(); }); it('should handle status update failures gracefully', async () => { @@ -297,18 +303,18 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); - mockFrigg.updateIntegrationStatus.mockRejectedValue(new Error('Update failed')); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + mockContext.updateIntegrationStatus.mockRejectedValue(new Error('Update failed')); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: true, updateStatus: true }); expect(result.healthy).toBe(1); // Should still report healthy - expect(mockFrigg.log).toHaveBeenCalledWith( + expect(mockContext.log).toHaveBeenCalledWith( 'warn', expect.stringContaining('Failed to update status'), expect.any(Object) @@ -325,21 +331,21 @@ describe('IntegrationHealthCheckScript', () => { config: { type: 'salesforce', credentials: { access_token: 'token2' } } }; - mockFrigg.findIntegrationById.mockImplementation((id) => { + mockContext.findIntegrationById.mockImplementation((id) => { if (id === 'int-1') return Promise.resolve(integration1); if (id === 'int-2') return Promise.resolve(integration2); return Promise.reject(new Error('Not found')); }); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ integrationIds: ['int-1', 'int-2'], checkCredentials: true, checkConnectivity: false }); - expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-1'); - expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-2'); - expect(mockFrigg.listIntegrations).not.toHaveBeenCalled(); + expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.listIntegrations).not.toHaveBeenCalled(); expect(result.results).toHaveLength(2); }); @@ -355,10 +361,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockRejectedValue(new Error('Instantiation failed')); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockRejectedValue(new Error('Instantiation failed')); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: true }); @@ -385,10 +391,10 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: false, checkConnectivity: true }); @@ -409,16 +415,16 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.listIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ checkCredentials: true, checkConnectivity: false }); expect(result.results[0].checks.credentials).toBeDefined(); expect(result.results[0].checks.connectivity).toBeUndefined(); - expect(mockFrigg.instantiate).not.toHaveBeenCalled(); + expect(mockContext.instantiate).not.toHaveBeenCalled(); }); }); @@ -426,7 +432,7 @@ describe('IntegrationHealthCheckScript', () => { let script; beforeEach(() => { - script = new IntegrationHealthCheckScript(); + script = new IntegrationHealthCheckScript({ context: { log: jest.fn() } }); }); it('should return valid for integrations with valid credentials', () => { @@ -497,13 +503,14 @@ describe('IntegrationHealthCheckScript', () => { describe('checkApiConnectivity()', () => { let script; - let mockFrigg; + let mockContext; beforeEach(() => { - script = new IntegrationHealthCheckScript(); - mockFrigg = { + mockContext = { + log: jest.fn(), instantiate: jest.fn(), }; + script = new IntegrationHealthCheckScript({ context: mockContext }); }); it('should return valid for successful API calls', async () => { @@ -520,9 +527,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.checkApiConnectivity(mockFrigg, integration); + const result = await script.checkApiConnectivity(integration); expect(result.valid).toBe(true); expect(result.issue).toBeNull(); @@ -543,9 +550,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.checkApiConnectivity(mockFrigg, integration); + const result = await script.checkApiConnectivity(integration); expect(result.valid).toBe(true); expect(mockInstance.primary.api.getCurrentUser).toHaveBeenCalled(); @@ -563,9 +570,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.checkApiConnectivity(mockFrigg, integration); + const result = await script.checkApiConnectivity(integration); expect(result.valid).toBe(true); expect(result.issue).toBeNull(); @@ -586,9 +593,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.checkApiConnectivity(mockFrigg, integration); + const result = await script.checkApiConnectivity(integration); expect(result.valid).toBe(false); expect(result.issue).toContain('API connectivity failed'); diff --git a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js index 1f2e36ac8..bfaf6d592 100644 --- a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js +++ b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js @@ -29,32 +29,40 @@ describe('OAuthTokenRefreshScript', () => { it('should have appropriate timeout configuration', () => { expect(OAuthTokenRefreshScript.Definition.config.timeout).toBe(600000); // 10 minutes }); + + it('should have clean display object without redundant fields', () => { + expect(OAuthTokenRefreshScript.Definition.display).toBeDefined(); + expect(OAuthTokenRefreshScript.Definition.display.category).toBe('maintenance'); + // Should NOT have redundant label/description + expect(OAuthTokenRefreshScript.Definition.display.label).toBeUndefined(); + expect(OAuthTokenRefreshScript.Definition.display.description).toBeUndefined(); + }); }); describe('execute()', () => { let script; - let mockFrigg; + let mockContext; beforeEach(() => { - script = new OAuthTokenRefreshScript(); - mockFrigg = { + mockContext = { log: jest.fn(), listIntegrations: jest.fn(), findIntegrationById: jest.fn(), instantiate: jest.fn(), }; + script = new OAuthTokenRefreshScript({ context: mockContext }); }); it('should return empty results when no integrations found', async () => { - mockFrigg.listIntegrations.mockResolvedValue([]); + mockContext.listIntegrations.mockResolvedValue([]); - const result = await script.execute(mockFrigg, {}); + const result = await script.execute({}); expect(result.refreshed).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); expect(result.details).toEqual([]); - expect(mockFrigg.log).toHaveBeenCalledWith('info', expect.any(String), expect.any(Object)); + expect(mockContext.log).toHaveBeenCalledWith('info', expect.any(String), expect.any(Object)); }); it('should skip integrations without OAuth credentials', async () => { @@ -62,9 +70,9 @@ describe('OAuthTokenRefreshScript', () => { id: 'int-1', config: {} // No credentials }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.listIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, {}); + const result = await script.execute({}); expect(result.skipped).toBe(1); expect(result.refreshed).toBe(0); @@ -85,9 +93,9 @@ describe('OAuthTokenRefreshScript', () => { } } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.listIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, {}); + const result = await script.execute({}); expect(result.skipped).toBe(1); expect(result.details[0]).toMatchObject({ @@ -108,9 +116,9 @@ describe('OAuthTokenRefreshScript', () => { } } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.listIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -142,10 +150,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -170,16 +178,16 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); + mockContext.listIntegrations.mockResolvedValue([integration]); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24, dryRun: true }); expect(result.refreshed).toBe(0); expect(result.skipped).toBe(1); - expect(mockFrigg.instantiate).not.toHaveBeenCalled(); + expect(mockContext.instantiate).not.toHaveBeenCalled(); expect(result.details[0]).toMatchObject({ integrationId: 'int-1', action: 'skipped', @@ -207,10 +215,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -243,10 +251,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockResolvedValue(mockInstance); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -268,19 +276,19 @@ describe('OAuthTokenRefreshScript', () => { config: { credentials: { access_token: 'token2' } } }; - mockFrigg.findIntegrationById.mockImplementation((id) => { + mockContext.findIntegrationById.mockImplementation((id) => { if (id === 'int-1') return Promise.resolve(integration1); if (id === 'int-2') return Promise.resolve(integration2); return Promise.reject(new Error('Not found')); }); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ integrationIds: ['int-1', 'int-2'] }); - expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-1'); - expect(mockFrigg.findIntegrationById).toHaveBeenCalledWith('int-2'); - expect(mockFrigg.listIntegrations).not.toHaveBeenCalled(); + expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.listIntegrations).not.toHaveBeenCalled(); expect(result.details).toHaveLength(2); }); @@ -295,10 +303,10 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockFrigg.listIntegrations.mockResolvedValue([integration]); - mockFrigg.instantiate.mockRejectedValue(new Error('Instantiation failed')); + mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockRejectedValue(new Error('Instantiation failed')); - const result = await script.execute(mockFrigg, { + const result = await script.execute({ expiryThresholdHours: 24 }); @@ -313,14 +321,14 @@ describe('OAuthTokenRefreshScript', () => { describe('processIntegration()', () => { let script; - let mockFrigg; + let mockContext; beforeEach(() => { - script = new OAuthTokenRefreshScript(); - mockFrigg = { + mockContext = { log: jest.fn(), instantiate: jest.fn(), }; + script = new OAuthTokenRefreshScript({ context: mockContext }); }); it('should return correct detail object for each scenario', async () => { @@ -331,7 +339,7 @@ describe('OAuthTokenRefreshScript', () => { config: {} }; - const result = await script.processIntegration(mockFrigg, integration, { + const result = await script.processIntegration(integration, { expiryThresholdHours: 24, dryRun: false }); diff --git a/packages/admin-scripts/src/builtins/integration-health-check.js b/packages/admin-scripts/src/builtins/integration-health-check.js index 31a5c5d55..b04d7b05f 100644 --- a/packages/admin-scripts/src/builtins/integration-health-check.js +++ b/packages/admin-scripts/src/builtins/integration-health-check.js @@ -62,14 +62,13 @@ class IntegrationHealthCheckScript extends AdminScriptBase { cronExpression: 'cron(0 6 * * ? *)', // Daily at 6 AM UTC }, + // UI-specific overrides display: { - label: 'Integration Health Check', - description: 'Check health and connectivity of integrations', category: 'maintenance', }, }; - async execute(frigg, params = {}) { + async execute(params = {}) { const { integrationIds = null, checkCredentials = true, @@ -84,7 +83,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase { results: [] }; - frigg.log('info', 'Starting integration health check', { + this.context.log('info', 'Starting integration health check', { checkCredentials, checkConnectivity, updateStatus, @@ -95,17 +94,17 @@ class IntegrationHealthCheckScript extends AdminScriptBase { let integrations; if (integrationIds && integrationIds.length > 0) { integrations = await Promise.all( - integrationIds.map(id => frigg.findIntegrationById(id).catch(() => null)) + integrationIds.map(id => this.context.findIntegrationById(id).catch(() => null)) ); integrations = integrations.filter(Boolean); } else { - integrations = await this.getAllIntegrations(frigg); + integrations = await this.getAllIntegrations(); } - frigg.log('info', `Checking ${integrations.length} integrations`); + this.context.log('info', `Checking ${integrations.length} integrations`); for (const integration of integrations) { - const result = await this.checkIntegration(frigg, integration, { + const result = await this.checkIntegration(integration, { checkCredentials, checkConnectivity }); @@ -124,17 +123,17 @@ class IntegrationHealthCheckScript extends AdminScriptBase { if (updateStatus && result.status !== 'unknown') { try { const newStatus = result.status === 'healthy' ? 'ACTIVE' : 'ERROR'; - await frigg.updateIntegrationStatus(integration.id, newStatus); - frigg.log('info', `Updated status for ${integration.id} to ${newStatus}`); + await this.context.updateIntegrationStatus(integration.id, newStatus); + this.context.log('info', `Updated status for ${integration.id} to ${newStatus}`); } catch (error) { - frigg.log('warn', `Failed to update status for ${integration.id}`, { + this.context.log('warn', `Failed to update status for ${integration.id}`, { error: error.message }); } } } - frigg.log('info', 'Health check completed', { + this.context.log('info', 'Health check completed', { healthy: summary.healthy, unhealthy: summary.unhealthy, unknown: summary.unknown @@ -143,19 +142,19 @@ class IntegrationHealthCheckScript extends AdminScriptBase { return summary; } - async getAllIntegrations(frigg) { - return frigg.listIntegrations({}); + async getAllIntegrations() { + return this.context.listIntegrations({}); } - async checkIntegration(frigg, integration, options) { + async checkIntegration(integration, options) { const { checkCredentials, checkConnectivity } = options; const result = this._createCheckResult(integration); try { - await this._runChecks(frigg, integration, result, { checkCredentials, checkConnectivity }); + await this._runChecks(integration, result, { checkCredentials, checkConnectivity }); this._determineOverallStatus(result); } catch (error) { - this._handleCheckError(frigg, integration, result, error); + this._handleCheckError(integration, result, error); } return result; @@ -179,7 +178,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase { * Run all requested checks * @private */ - async _runChecks(frigg, integration, result, options) { + async _runChecks(integration, result, options) { const { checkCredentials, checkConnectivity } = options; if (checkCredentials) { @@ -187,7 +186,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase { } if (checkConnectivity) { - this._addCheckResult(result, 'connectivity', await this.checkApiConnectivity(frigg, integration)); + this._addCheckResult(result, 'connectivity', await this.checkApiConnectivity(integration)); } } @@ -214,8 +213,8 @@ class IntegrationHealthCheckScript extends AdminScriptBase { * Handle check error and update result * @private */ - _handleCheckError(frigg, integration, result, error) { - frigg.log('error', `Error checking integration ${integration.id}`, { + _handleCheckError(integration, result, error) { + this.context.log('error', `Error checking integration ${integration.id}`, { error: error.message }); result.status = 'unknown'; @@ -246,12 +245,12 @@ class IntegrationHealthCheckScript extends AdminScriptBase { return result; } - async checkApiConnectivity(frigg, integration) { + async checkApiConnectivity(integration) { const result = { valid: true, issue: null, responseTime: null }; try { const startTime = Date.now(); - const instance = await frigg.instantiate(integration.id); + const instance = await this.context.instantiate(integration.id); // Try to make a simple API call if (instance.primary?.api?.getAuthenticationInfo) { diff --git a/packages/admin-scripts/src/builtins/oauth-token-refresh.js b/packages/admin-scripts/src/builtins/oauth-token-refresh.js index e39cc539e..b45739b87 100644 --- a/packages/admin-scripts/src/builtins/oauth-token-refresh.js +++ b/packages/admin-scripts/src/builtins/oauth-token-refresh.js @@ -50,14 +50,13 @@ class OAuthTokenRefreshScript extends AdminScriptBase { requireIntegrationInstance: true, // Needs to call external APIs }, + // UI-specific overrides display: { - label: 'OAuth Token Refresh', - description: 'Refresh OAuth tokens before they expire', category: 'maintenance', }, }; - async execute(frigg, params = {}) { + async execute(params = {}) { const { integrationIds = null, expiryThresholdHours = 24, @@ -71,7 +70,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase { details: [] }; - frigg.log('info', 'Starting OAuth token refresh', { + this.context.log('info', 'Starting OAuth token refresh', { expiryThresholdHours, dryRun, specificIds: integrationIds?.length || 'all' @@ -81,19 +80,19 @@ class OAuthTokenRefreshScript extends AdminScriptBase { let integrations; if (integrationIds && integrationIds.length > 0) { integrations = await Promise.all( - integrationIds.map(id => frigg.findIntegrationById(id).catch(() => null)) + integrationIds.map(id => this.context.findIntegrationById(id).catch(() => null)) ); integrations = integrations.filter(Boolean); } else { // Get all integrations (this would need to be paginated for large deployments) - integrations = await this.getAllIntegrations(frigg); + integrations = await this.getAllIntegrations(); } - frigg.log('info', `Found ${integrations.length} integrations to check`); + this.context.log('info', `Found ${integrations.length} integrations to check`); for (const integration of integrations) { try { - const detail = await this.processIntegration(frigg, integration, { + const detail = await this.processIntegration(integration, { expiryThresholdHours, dryRun }); @@ -108,7 +107,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase { results.failed++; } } catch (error) { - frigg.log('error', `Error processing integration ${integration.id}`, { + this.context.log('error', `Error processing integration ${integration.id}`, { error: error.message }); results.failed++; @@ -120,7 +119,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase { } } - frigg.log('info', 'OAuth token refresh completed', { + this.context.log('info', 'OAuth token refresh completed', { refreshed: results.refreshed, failed: results.failed, skipped: results.skipped @@ -129,13 +128,13 @@ class OAuthTokenRefreshScript extends AdminScriptBase { return results; } - async getAllIntegrations(frigg) { + async getAllIntegrations() { // This is a simplified implementation // In production, would need pagination for large datasets - return frigg.listIntegrations({}); + return this.context.listIntegrations({}); } - async processIntegration(frigg, integration, options) { + async processIntegration(integration, options) { const { expiryThresholdHours, dryRun } = options; // Check prerequisites @@ -146,12 +145,12 @@ class OAuthTokenRefreshScript extends AdminScriptBase { // Handle dry run if (dryRun) { - frigg.log('info', `[DRY RUN] Would refresh token for ${integration.id}`); + this.context.log('info', `[DRY RUN] Would refresh token for ${integration.id}`); return this._createResult(integration.id, 'skipped', 'Dry run - would have refreshed'); } // Perform refresh - return this._performTokenRefresh(frigg, integration); + return this._performTokenRefresh(integration); } /** @@ -183,18 +182,18 @@ class OAuthTokenRefreshScript extends AdminScriptBase { * Perform the actual token refresh * @private */ - async _performTokenRefresh(frigg, integration) { + async _performTokenRefresh(integration) { const expiresAt = integration.config?.credentials?.expires_at; try { - const instance = await frigg.instantiate(integration.id); + const instance = await this.context.instantiate(integration.id); if (!instance.primary?.api?.refreshAccessToken) { return this._createResult(integration.id, 'skipped', 'API does not support token refresh'); } await instance.primary.api.refreshAccessToken(); - frigg.log('info', `Refreshed token for integration ${integration.id}`); + this.context.log('info', `Refreshed token for integration ${integration.id}`); return { integrationId: integration.id, @@ -202,7 +201,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase { previousExpiry: expiresAt }; } catch (error) { - frigg.log('error', `Failed to refresh token for ${integration.id}`, { + this.context.log('error', `Failed to refresh token for ${integration.id}`, { error: error.message }); return this._createResult(integration.id, 'failed', error.message); From 7e782599075f468b2defbb00cd243dcce243702f Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Wed, 11 Feb 2026 00:30:06 -0500 Subject: [PATCH 28/33] refactor(admin-scripts): address PR review - no defaults, fix bugs, cleanup - Remove hardcoded defaults and env var auto-detection from scheduler adapters; require explicit config from appDefinition pattern - Region inherits from AWS_REGION (Lambda runtime), not per-adapter config - Make trigger required in ScriptRunner.execute() - Fix status->state bug in script-executor-handler error path - Remove PR_517_REVIEW_TRACKER.md from repo - Regenerate package-lock.json (remove stale mongoose/chai/sinon) - Remove detectSchedulerAdapterType and its export Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 3 - .../admin-scripts/PR_517_REVIEW_TRACKER.md | 56 ----- packages/admin-scripts/index.js | 2 - .../__tests__/aws-scheduler-adapter.test.js | 93 +++++--- .../scheduler-adapter-factory.test.js | 214 +++--------------- .../src/adapters/aws-scheduler-adapter.js | 17 +- .../src/adapters/scheduler-adapter-factory.js | 48 ++-- .../__tests__/script-runner.test.js | 21 +- .../src/application/script-runner.js | 6 +- .../infrastructure/script-executor-handler.js | 2 +- 10 files changed, 147 insertions(+), 315 deletions(-) delete mode 100644 packages/admin-scripts/PR_517_REVIEW_TRACKER.md diff --git a/package-lock.json b/package-lock.json index b0a1ee121..f3a1ee9b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39753,7 +39753,6 @@ "bcryptjs": "^2.4.3", "express": "^4.18.2", "lodash": "4.17.21", - "mongoose": "6.11.6", "serverless-http": "^3.2.0", "uuid": "^9.0.1" }, @@ -39761,11 +39760,9 @@ "@friggframework/eslint-config": "^2.0.0-next.0", "@friggframework/prettier-config": "^2.0.0-next.0", "@friggframework/test": "^2.0.0-next.0", - "chai": "^4.3.6", "eslint": "^8.22.0", "jest": "^29.7.0", "prettier": "^2.7.1", - "sinon": "^16.1.1", "supertest": "^7.1.4" } }, diff --git a/packages/admin-scripts/PR_517_REVIEW_TRACKER.md b/packages/admin-scripts/PR_517_REVIEW_TRACKER.md deleted file mode 100644 index 6ddc2c4bc..000000000 --- a/packages/admin-scripts/PR_517_REVIEW_TRACKER.md +++ /dev/null @@ -1,56 +0,0 @@ -# PR #517 Comment Tracker - -## ✅ ADDRESSED - Ready to Reply - -| # | File:Line | Original Comment | What We Did | Reply | -|---|-----------|------------------|-------------|-------| -| 1 | `admin-frigg-commands.js:15` | "I don't think this should even exist, we're just duplicating methods" | Renamed to `AdminScriptContext`, clarified as facade pattern | Renamed to `AdminScriptContext`. It's a facade - wraps repositories so scripts have one API instead of multiple imports. Open to discussing alternatives. | -| 2 | `admin-script-base.js:94` | "Commands should come via constructor" | Changed to constructor injection | Done. Context now passed via constructor, scripts access via `this.context`. | -| 3 | `admin-script-base.js:109` | "logging does not belong here" | Removed logging from base class | Removed. Scripts use `this.context.log()` which persists to admin process record. | -| 4 | `admin-script-base.js:54` | "I would rename to requireIntegrationInstance" | Already renamed | Done - already renamed in current code. | -| 5 | `admin-script-base.js:56` | "This is just a duplication of the static Definition" | Cleaned up display object | Cleaned up. `display` now only holds UI overrides (category, icon). Label/description fall back to top-level via static methods. | -| 6 | `schedule-management-use-case.js:1` | "A use case should have single entry point" | Already split in previous session | Already split into `UpsertScheduleUseCase`, `DeleteScheduleUseCase`, `GetEffectiveScheduleUseCase`. | -| 7 | `package.json:20` | "chai and sinon should slowly be pushed away" | Sinon removed in previous session | Sinon already removed. Will remove chai too. | - ---- - -## 📝 NEEDS RESPONSE ONLY - No Code Change Required - -| # | File:Line | Original Comment | Reply | -|---|-----------|------------------|-------| -| 8 | `admin-frigg-commands.js:160` | "this does not belong here" (queueScript) | queueScript enables self-queuing pattern (fan-out, pagination, retries). It's here so scripts don't need queue internals. Could move to separate utility if preferred. | -| 9 | `admin-script-base.js:39` | "why do we need the source?" | Distinguishes builtin vs user-defined scripts. UI can filter differently, builtins could have special handling. Could remove if not needed. | -| 10 | `admin-script-base.js:42` | "what's the idea with these schemas?" | Optional JSON Schema for validation/documentation. Could wire to OpenAPI or dynamic UI forms. Not critical for v1 - could remove and add later. | -| 11 | `admin-script-base.js:46` | "enabled property confusion" | Agreed the matrix is confusing. Intent: `schedule.enabled` controls auto-trigger independent of registration. Could simplify to just use presence in appDefinition. | -| 12 | `admin-script-base.js:52` | "Do we have retry logic in place already?" | Not yet - placeholder for Phase 2. Could remove until we build it. | -| 13 | `admin-script-base.js:81` | "What is the executionId?" | ID of AdminProcess record tracking this execution. Used to persist logs and update status. Created before script runs, passed to constructor. | -| 14 | `schedule-management-use-case.js:89` | "why save to database if EventBridge is source of truth?" | Database stores user's config override. EventBridge is execution engine. On deploy, we sync DB to EventBridge. Tracks user config vs code default. | - ---- - -## 🔧 OUTSTANDING - Needs Code Changes - -| # | File:Line | Original Comment | Task | -|---|-----------|------------------|------| -| 15 | `docs/architecture-decisions/005-admin-script-runner.md:71` | "What is 'frigg' in this parameter?" | Update ADR - rename `frigg` to `context` throughout | -| 16 | `package.json:22` | "We already use nock for http request mocking" | Remove msw if present, use nock consistently | -| 17 | `package.json:12` | "why mongoose?" | Check if mongoose needed or can be removed | -| 18 | `schedule-management-use-case.js:109` | "leaking AWS specifics" | Abstract behind SchedulerAdapter, remove EventBridge references from use case | -| 19 | `schedule-management-use-case.js:138` | "should not mention EventBridge here" | Same as above | -| 20 | `adapters/aws-scheduler-adapter.js:30` | "should not infer/guess/default any variable" | Remove defaults, require explicit config | -| 21 | `adapters/scheduler-adapter-factory.js:48` | "env var confusion, prefer appDefinition" | Move scheduler config to appDefinition | -| 22 | `script-runner.js:36` | "we should not assume default values" | Remove defaults, require explicit values | -| 23 | `dry-run-http-interceptor.js:1` | "I don't understand why this is needed" | Explain or remove - was for intercepting HTTP in dry-run mode | -| 24 | `dry-run-repository-wrapper.js:1` | "This is smelly" | Review/remove - was for wrapping repos in dry-run mode | -| 25 | `.github/workflows/release.yml:11` | "why do we need those?" | Check release workflow changes | - ---- - -## ❓ NEEDS DISCUSSION - Architectural Decisions - -| # | File:Line | Original Comment | Decision Needed | -|---|-----------|------------------|-----------------| -| 26 | `admin-script-base.js:39` | source field | Keep BUILTIN/USER_DEFINED or remove? | -| 27 | `admin-script-base.js:42` | inputSchema/outputSchema | Keep for future or remove for now? | -| 28 | `admin-script-base.js:46` | schedule.enabled | Simplify to just appDefinition presence? | -| 29 | `admin-script-base.js:52` | maxRetries | Remove placeholder or keep? | diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js index 66cf01952..b5e379910 100644 --- a/packages/admin-scripts/index.js +++ b/packages/admin-scripts/index.js @@ -36,7 +36,6 @@ const { AWSSchedulerAdapter } = require('./src/adapters/aws-scheduler-adapter'); const { LocalSchedulerAdapter } = require('./src/adapters/local-scheduler-adapter'); const { createSchedulerAdapter, - detectSchedulerAdapterType, } = require('./src/adapters/scheduler-adapter-factory'); module.exports = { @@ -71,5 +70,4 @@ module.exports = { AWSSchedulerAdapter, LocalSchedulerAdapter, createSchedulerAdapter, - detectSchedulerAdapterType, }; diff --git a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js index 9ccd461ff..d8836048f 100644 --- a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js @@ -18,34 +18,28 @@ jest.mock('@aws-sdk/client-scheduler', () => { }; }); +const defaultParams = { + targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor', + scheduleGroupName: 'frigg-admin-scripts', + roleArn: 'arn:aws:iam::123456789012:role/test-role', +}; + describe('AWSSchedulerAdapter', () => { let adapter; let mockSend; - let originalEnv; - - beforeAll(() => { - originalEnv = { ...process.env }; - }); + const originalEnv = process.env; beforeEach(() => { jest.clearAllMocks(); - - // Reset environment variables - process.env.AWS_REGION = 'us-east-1'; - process.env.SCHEDULE_GROUP_NAME = 'test-schedule-group'; - process.env.SCHEDULER_ROLE_ARN = 'arn:aws:iam::123456789012:role/test-role'; - process.env.ADMIN_SCRIPT_LAMBDA_ARN = 'arn:aws:lambda:us-east-1:123456789012:function:test-executor'; + process.env = { ...originalEnv, AWS_REGION: 'us-east-1' }; const sdk = require('@aws-sdk/client-scheduler'); mockSend = sdk._mockSend; - adapter = new AWSSchedulerAdapter({ - targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor', - scheduleGroupName: 'frigg-admin-scripts', - }); + adapter = new AWSSchedulerAdapter({ ...defaultParams }); }); - afterAll(() => { + afterEach(() => { process.env = originalEnv; }); @@ -60,35 +54,46 @@ describe('AWSSchedulerAdapter', () => { }); describe('Constructor', () => { - it('should use provided configuration', () => { + it('should use provided configuration and AWS_REGION from env', () => { + process.env.AWS_REGION = 'eu-west-1'; const customAdapter = new AWSSchedulerAdapter({ - region: 'eu-west-1', targetLambdaArn: 'arn:aws:lambda:eu-west-1:123456789012:function:custom', scheduleGroupName: 'custom-group', + roleArn: 'arn:aws:iam::123456789012:role/custom-role', }); expect(customAdapter.region).toBe('eu-west-1'); expect(customAdapter.targetLambdaArn).toBe('arn:aws:lambda:eu-west-1:123456789012:function:custom'); expect(customAdapter.scheduleGroupName).toBe('custom-group'); + expect(customAdapter.roleArn).toBe('arn:aws:iam::123456789012:role/custom-role'); }); - it('should use environment variables as fallback', () => { - const envAdapter = new AWSSchedulerAdapter(); - - expect(envAdapter.region).toBe('us-east-1'); - expect(envAdapter.targetLambdaArn).toBe('arn:aws:lambda:us-east-1:123456789012:function:test-executor'); - expect(envAdapter.scheduleGroupName).toBe('test-schedule-group'); + it('should throw if AWS_REGION is not set', () => { + delete process.env.AWS_REGION; + expect(() => new AWSSchedulerAdapter({ + ...defaultParams, + })).toThrow('AWSSchedulerAdapter requires AWS_REGION environment variable'); }); - it('should use defaults when no config or env vars', () => { - delete process.env.AWS_REGION; - delete process.env.SCHEDULE_GROUP_NAME; - delete process.env.ADMIN_SCRIPT_LAMBDA_ARN; + it('should throw if targetLambdaArn is missing', () => { + expect(() => new AWSSchedulerAdapter({ + scheduleGroupName: defaultParams.scheduleGroupName, + roleArn: defaultParams.roleArn, + })).toThrow('AWSSchedulerAdapter requires targetLambdaArn'); + }); - const defaultAdapter = new AWSSchedulerAdapter(); + it('should throw if scheduleGroupName is missing', () => { + expect(() => new AWSSchedulerAdapter({ + targetLambdaArn: defaultParams.targetLambdaArn, + roleArn: defaultParams.roleArn, + })).toThrow('AWSSchedulerAdapter requires scheduleGroupName'); + }); - expect(defaultAdapter.region).toBe('us-east-1'); - expect(defaultAdapter.scheduleGroupName).toBe('frigg-admin-scripts'); + it('should throw if roleArn is missing', () => { + expect(() => new AWSSchedulerAdapter({ + targetLambdaArn: defaultParams.targetLambdaArn, + scheduleGroupName: defaultParams.scheduleGroupName, + })).toThrow('AWSSchedulerAdapter requires roleArn'); }); }); @@ -139,7 +144,7 @@ describe('AWSSchedulerAdapter', () => { }); }); - it('should configure target with Lambda ARN and role', async () => { + it('should configure target with Lambda ARN and constructor roleArn', async () => { mockSend.mockResolvedValue({ ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', }); @@ -154,6 +159,26 @@ describe('AWSSchedulerAdapter', () => { expect(command.params.Target.RoleArn).toBe('arn:aws:iam::123456789012:role/test-role'); }); + it('should use roleArn from constructor, not process.env', async () => { + const customRoleArn = 'arn:aws:iam::999999999999:role/custom-scheduler-role'; + const customAdapter = new AWSSchedulerAdapter({ + ...defaultParams, + roleArn: customRoleArn, + }); + + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await customAdapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.Target.RoleArn).toBe(customRoleArn); + }); + it('should enable schedule by default', async () => { mockSend.mockResolvedValue({ ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', @@ -301,9 +326,7 @@ describe('AWSSchedulerAdapter', () => { describe('Lazy SDK loading', () => { it('should load AWS SDK on first client access', () => { - const newAdapter = new AWSSchedulerAdapter({ - targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:test', - }); + const newAdapter = new AWSSchedulerAdapter({ ...defaultParams }); expect(newAdapter.scheduler).toBeNull(); diff --git a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js index 1abbc8b5f..c59be6529 100644 --- a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js @@ -1,7 +1,4 @@ -const { - createSchedulerAdapter, - detectSchedulerAdapterType, -} = require('../scheduler-adapter-factory'); +const { createSchedulerAdapter } = require('../scheduler-adapter-factory'); const { AWSSchedulerAdapter } = require('../aws-scheduler-adapter'); const { LocalSchedulerAdapter } = require('../local-scheduler-adapter'); @@ -17,71 +14,56 @@ jest.mock('@aws-sdk/client-scheduler', () => ({ ListSchedulesCommand: jest.fn(), })); -describe('Scheduler Adapter Factory', () => { - let originalEnv; +const awsAdapterParams = { + targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:test', + scheduleGroupName: 'test-group', + roleArn: 'arn:aws:iam::123456789012:role/test-role', +}; - beforeAll(() => { - originalEnv = { ...process.env }; - }); +describe('Scheduler Adapter Factory', () => { + const originalEnv = process.env; beforeEach(() => { - // Reset environment variables - delete process.env.SCHEDULER_ADAPTER; - delete process.env.STAGE; - delete process.env.NODE_ENV; + process.env = { ...originalEnv, AWS_REGION: 'us-east-1' }; }); - afterAll(() => { + afterEach(() => { process.env = originalEnv; }); describe('createSchedulerAdapter()', () => { - it('should create local adapter by default', () => { - const adapter = createSchedulerAdapter(); + it('should throw if type is not provided', () => { + expect(() => createSchedulerAdapter()).toThrow(); + }); - expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); - expect(adapter.getName()).toBe('local-cron'); + it('should throw if type is not provided in options object', () => { + expect(() => createSchedulerAdapter({})).toThrow(); }); - it('should create local adapter when explicitly specified', () => { + it('should create local adapter when type is "local"', () => { const adapter = createSchedulerAdapter({ type: 'local' }); expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + expect(adapter.getName()).toBe('local-cron'); }); it('should create AWS adapter when type is "aws"', () => { - const adapter = createSchedulerAdapter({ type: 'aws' }); + const adapter = createSchedulerAdapter({ type: 'aws', ...awsAdapterParams }); expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); expect(adapter.getName()).toBe('aws-eventbridge-scheduler'); }); it('should create AWS adapter when type is "eventbridge"', () => { - const adapter = createSchedulerAdapter({ type: 'eventbridge' }); - - expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); - }); - - it('should use SCHEDULER_ADAPTER env variable', () => { - process.env.SCHEDULER_ADAPTER = 'aws'; - - const adapter = createSchedulerAdapter(); + const adapter = createSchedulerAdapter({ type: 'eventbridge', ...awsAdapterParams }); expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); }); - it('should allow explicit type to override env variable', () => { - process.env.SCHEDULER_ADAPTER = 'aws'; - - const adapter = createSchedulerAdapter({ type: 'local' }); - - expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); - }); - it('should handle case-insensitive type values', () => { - const adapter1 = createSchedulerAdapter({ type: 'AWS' }); + const adapter1 = createSchedulerAdapter({ type: 'AWS', ...awsAdapterParams }); const adapter2 = createSchedulerAdapter({ type: 'LOCAL' }); - const adapter3 = createSchedulerAdapter({ type: 'EventBridge' }); + const adapter3 = createSchedulerAdapter({ type: 'EventBridge', ...awsAdapterParams }); expect(adapter1).toBeInstanceOf(AWSSchedulerAdapter); expect(adapter2).toBeInstanceOf(LocalSchedulerAdapter); @@ -91,17 +73,29 @@ describe('Scheduler Adapter Factory', () => { it('should pass AWS configuration to AWS adapter', () => { const config = { type: 'aws', - region: 'eu-west-1', targetLambdaArn: 'arn:aws:lambda:eu-west-1:123456789012:function:test', scheduleGroupName: 'custom-group', + roleArn: 'arn:aws:iam::123456789012:role/custom-role', }; const adapter = createSchedulerAdapter(config); expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); - expect(adapter.region).toBe('eu-west-1'); + expect(adapter.region).toBe('us-east-1'); // From process.env.AWS_REGION expect(adapter.targetLambdaArn).toBe('arn:aws:lambda:eu-west-1:123456789012:function:test'); expect(adapter.scheduleGroupName).toBe('custom-group'); + expect(adapter.roleArn).toBe('arn:aws:iam::123456789012:role/custom-role'); + }); + + it('should pass roleArn through to AWS adapter', () => { + const adapter = createSchedulerAdapter({ + type: 'aws', + ...awsAdapterParams, + roleArn: 'arn:aws:iam::999999999999:role/scheduler-role', + }); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter.roleArn).toBe('arn:aws:iam::999999999999:role/scheduler-role'); }); it('should ignore AWS config for local adapter', () => { @@ -116,142 +110,8 @@ describe('Scheduler Adapter Factory', () => { expect(adapter.region).toBeUndefined(); }); - it('should handle unknown adapter type by creating local adapter', () => { - const adapter = createSchedulerAdapter({ type: 'unknown-type' }); - - expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); - }); - }); - - describe('detectSchedulerAdapterType()', () => { - it('should return "local" by default', () => { - const type = detectSchedulerAdapterType(); - - expect(type).toBe('local'); - }); - - it('should return env SCHEDULER_ADAPTER when set', () => { - process.env.SCHEDULER_ADAPTER = 'aws'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('aws'); - }); - - it('should return "aws" for production stage', () => { - process.env.STAGE = 'production'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('aws'); - }); - - it('should return "aws" for prod stage', () => { - process.env.STAGE = 'prod'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('aws'); - }); - - it('should return "aws" for staging stage', () => { - process.env.STAGE = 'staging'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('aws'); - }); - - it('should return "aws" for stage stage', () => { - process.env.STAGE = 'stage'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('aws'); - }); - - it('should handle case-insensitive stage values', () => { - process.env.STAGE = 'PRODUCTION'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('aws'); - }); - - it('should return "local" for dev stage', () => { - process.env.STAGE = 'dev'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('local'); - }); - - it('should return "local" for development stage', () => { - process.env.STAGE = 'development'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('local'); - }); - - it('should return "local" for test stage', () => { - process.env.STAGE = 'test'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('local'); - }); - - it('should return "local" for local stage', () => { - process.env.STAGE = 'local'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('local'); - }); - - it('should use NODE_ENV as fallback for STAGE', () => { - delete process.env.STAGE; - process.env.NODE_ENV = 'production'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('aws'); - }); - - it('should prioritize explicit SCHEDULER_ADAPTER over auto-detection', () => { - process.env.SCHEDULER_ADAPTER = 'local'; - process.env.STAGE = 'production'; - - const type = detectSchedulerAdapterType(); - - expect(type).toBe('local'); - }); - }); - - describe('Integration with createSchedulerAdapter', () => { - it('should auto-detect and create AWS adapter in production', () => { - process.env.STAGE = 'production'; - - const adapter = createSchedulerAdapter(); - - expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); - }); - - it('should auto-detect and create local adapter in development', () => { - process.env.STAGE = 'development'; - - const adapter = createSchedulerAdapter(); - - expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); - }); - - it('should allow explicit override of auto-detection', () => { - process.env.STAGE = 'production'; - - const adapter = createSchedulerAdapter({ type: 'local' }); - - expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + it('should throw for unknown adapter type', () => { + expect(() => createSchedulerAdapter({ type: 'unknown-type' })).toThrow(); }); }); }); diff --git a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js index 2717b21e4..ec86b84ee 100644 --- a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js @@ -25,12 +25,19 @@ function loadSchedulerSDK() { * Supports cron expressions, timezone configuration, and Lambda invocation. */ class AWSSchedulerAdapter extends SchedulerAdapter { - constructor({ region, credentials, targetLambdaArn, scheduleGroupName } = {}) { + constructor({ credentials, targetLambdaArn, scheduleGroupName, roleArn } = {}) { super(); - this.region = region || process.env.AWS_REGION || 'us-east-1'; + if (!targetLambdaArn) throw new Error('AWSSchedulerAdapter requires targetLambdaArn'); + if (!scheduleGroupName) throw new Error('AWSSchedulerAdapter requires scheduleGroupName'); + if (!roleArn) throw new Error('AWSSchedulerAdapter requires roleArn'); + // Region inherits from the service (set by Lambda runtime, same for all AWS resources) + const region = process.env.AWS_REGION; + if (!region) throw new Error('AWSSchedulerAdapter requires AWS_REGION environment variable'); + this.region = region; this.credentials = credentials; - this.targetLambdaArn = targetLambdaArn || process.env.ADMIN_SCRIPT_LAMBDA_ARN; - this.scheduleGroupName = scheduleGroupName || process.env.SCHEDULE_GROUP_NAME || 'frigg-admin-scripts'; + this.targetLambdaArn = targetLambdaArn; + this.scheduleGroupName = scheduleGroupName; + this.roleArn = roleArn; this.scheduler = null; } @@ -61,7 +68,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter { FlexibleTimeWindow: { Mode: 'OFF' }, Target: { Arn: this.targetLambdaArn, - RoleArn: process.env.SCHEDULER_ROLE_ARN, + RoleArn: this.roleArn, Input: JSON.stringify({ scriptName, trigger: 'SCHEDULED', diff --git a/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js b/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js index 9e11fd08f..522920b1b 100644 --- a/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js +++ b/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js @@ -6,64 +6,44 @@ const { LocalSchedulerAdapter } = require('./local-scheduler-adapter'); * * Application Layer - Hexagonal Architecture * - * Creates the appropriate scheduler adapter based on configuration. - * Supports environment-based auto-detection and explicit configuration. + * Creates the appropriate scheduler adapter based on explicit configuration + * from appDefinition. Does not auto-detect or read environment variables. */ /** * Create a scheduler adapter instance * - * @param {Object} options - Configuration options - * @param {string} [options.type] - Adapter type ('aws', 'eventbridge', 'local') - * @param {string} [options.region] - AWS region (for AWS adapter) + * @param {Object} options - Configuration options (from appDefinition.adminScripts.scheduler) + * @param {string} options.type - Adapter type ('aws', 'eventbridge', 'local') - required * @param {Object} [options.credentials] - AWS credentials (for AWS adapter) - * @param {string} [options.targetLambdaArn] - Lambda ARN to invoke (for AWS adapter) - * @param {string} [options.scheduleGroupName] - EventBridge schedule group name (for AWS adapter) + * @param {string} [options.targetLambdaArn] - Lambda ARN to invoke (required for AWS adapter) + * @param {string} [options.scheduleGroupName] - EventBridge schedule group name (required for AWS adapter) + * @param {string} [options.roleArn] - IAM role ARN for scheduler (required for AWS adapter) * @returns {SchedulerAdapter} Configured scheduler adapter */ function createSchedulerAdapter(options = {}) { - const adapterType = options.type || detectSchedulerAdapterType(); + if (!options.type) { + throw new Error('Scheduler adapter type is required. Configure in appDefinition.adminScripts.scheduler.type'); + } - switch (adapterType.toLowerCase()) { + switch (options.type.toLowerCase()) { case 'aws': case 'eventbridge': return new AWSSchedulerAdapter({ - region: options.region, credentials: options.credentials, targetLambdaArn: options.targetLambdaArn, scheduleGroupName: options.scheduleGroupName, + roleArn: options.roleArn, }); case 'local': - default: return new LocalSchedulerAdapter(); - } -} - -/** - * Determine the appropriate scheduler adapter type based on environment - * - * @returns {string} Adapter type ('aws' or 'local') - */ -function detectSchedulerAdapterType() { - // If explicitly set, use that - if (process.env.SCHEDULER_ADAPTER) { - return process.env.SCHEDULER_ADAPTER; - } - // Auto-detect based on environment - const stage = process.env.STAGE || process.env.NODE_ENV || 'local'; - - // Use AWS adapter in production/staging environments - if (['production', 'prod', 'staging', 'stage'].includes(stage.toLowerCase())) { - return 'aws'; + default: + throw new Error(`Unknown scheduler adapter type: ${options.type}`); } - - // Use local adapter for dev/test/local - return 'local'; } module.exports = { createSchedulerAdapter, - detectSchedulerAdapterType, }; diff --git a/packages/admin-scripts/src/application/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js index f9858a6fe..ad63135e0 100644 --- a/packages/admin-scripts/src/application/__tests__/script-runner.test.js +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -27,7 +27,7 @@ describe('ScriptRunner', () => { }, }; - async execute(frigg, params) { + async execute(params) { return { success: true, params }; } } @@ -102,6 +102,22 @@ describe('ScriptRunner', () => { ); }); + it('should throw error if trigger is not provided', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + await expect( + runner.execute('test-script', { foo: 'bar' }, {}) + ).rejects.toThrow('options.trigger is required'); + }); + + it('should throw error if options are omitted entirely', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + await expect( + runner.execute('test-script', { foo: 'bar' }) + ).rejects.toThrow('options.trigger is required'); + }); + it('should handle script execution failure', async () => { class FailingScript extends AdminScriptBase { static Definition = { @@ -235,6 +251,7 @@ describe('ScriptRunner', () => { // Missing required parameter const result = await runner.execute('schema-script', {}, { + trigger: 'MANUAL', dryRun: true, }); @@ -272,6 +289,7 @@ describe('ScriptRunner', () => { name: 123, enabled: 'true', }, { + trigger: 'MANUAL', dryRun: true, }); @@ -307,6 +325,7 @@ describe('ScriptRunner', () => { name: 'test', count: 42, }, { + trigger: 'MANUAL', dryRun: true, }); diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js index f18fe9e5b..83af9c993 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -32,7 +32,11 @@ class ScriptRunner { * @param {boolean} options.dryRun - Dry-run mode: validate and preview without executing */ async execute(scriptName, params = {}, options = {}) { - const { trigger = 'MANUAL', audit = {}, executionId: existingExecutionId, dryRun = false } = options; + const { trigger, audit = {}, executionId: existingExecutionId, dryRun = false } = options; + + if (!trigger) { + throw new Error('options.trigger is required (MANUAL | SCHEDULED | QUEUE)'); + } // Get script class const scriptClass = this.scriptFactory.get(scriptName); diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js index e9effd4a4..1ad50a70b 100644 --- a/packages/admin-scripts/src/infrastructure/script-executor-handler.js +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -46,7 +46,7 @@ async function handler(event) { const commands = createAdminScriptCommands(); await commands .completeAdminProcess(executionId, { - status: 'FAILED', + state: 'FAILED', error: { name: error.name, message: error.message, From 9066dd6181d4fb9a1ec3d046c3f0de4ebba651a8 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Wed, 11 Feb 2026 01:27:13 -0500 Subject: [PATCH 29/33] refactor(admin-scripts): extract dry-run to /validate route, simplify handler - Extract dry-run validation from ScriptRunner into standalone validateScriptInput() with dedicated POST /scripts/:name/validate route - Simplify script-executor-handler to thin SQS adapter; runner handles all error recording and status updates - Change async execution response status from PENDING to QUEUED - Remove dryRun param from execute endpoint (separate concerns) Co-Authored-By: Claude Opus 4.6 --- .../__tests__/script-runner.test.js | 133 ------------ .../__tests__/validate-script-input.test.js | 196 ++++++++++++++++++ .../src/application/script-runner.js | 112 +--------- .../src/application/validate-script-input.js | 116 +++++++++++ .../__tests__/admin-script-router.test.js | 4 +- .../src/infrastructure/admin-script-router.js | 42 ++-- .../infrastructure/script-executor-handler.js | 51 ++--- 7 files changed, 358 insertions(+), 296 deletions(-) create mode 100644 packages/admin-scripts/src/application/__tests__/validate-script-input.test.js create mode 100644 packages/admin-scripts/src/application/validate-script-input.js diff --git a/packages/admin-scripts/src/application/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js index ad63135e0..f89f411b1 100644 --- a/packages/admin-scripts/src/application/__tests__/script-runner.test.js +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -202,139 +202,6 @@ describe('ScriptRunner', () => { }); }); - describe('dry-run mode', () => { - it('should return preview without executing script', async () => { - const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); - - const result = await runner.execute('test-script', { foo: 'bar' }, { - trigger: 'MANUAL', - dryRun: true, - }); - - expect(result.dryRun).toBe(true); - expect(result.status).toBe('DRY_RUN_VALID'); - expect(result.scriptName).toBe('test-script'); - expect(result.preview.script.name).toBe('test-script'); - expect(result.preview.script.version).toBe('1.0.0'); - expect(result.preview.input).toEqual({ foo: 'bar' }); - expect(result.message).toContain('validation passed'); - - // Should NOT create execution record or call commands - expect(mockCommands.createAdminProcess).not.toHaveBeenCalled(); - expect(mockCommands.updateAdminProcessState).not.toHaveBeenCalled(); - expect(mockCommands.completeAdminProcess).not.toHaveBeenCalled(); - }); - - it('should validate required parameters in dry-run', async () => { - class SchemaScript extends AdminScriptBase { - static Definition = { - name: 'schema-script', - version: '1.0.0', - description: 'Script with schema', - inputSchema: { - type: 'object', - required: ['requiredParam'], - properties: { - requiredParam: { type: 'string' }, - optionalParam: { type: 'number' }, - }, - }, - }; - - async execute() { - return {}; - } - } - - scriptFactory.register(SchemaScript); - const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); - - // Missing required parameter - const result = await runner.execute('schema-script', {}, { - trigger: 'MANUAL', - dryRun: true, - }); - - expect(result.status).toBe('DRY_RUN_INVALID'); - expect(result.preview.validation.valid).toBe(false); - expect(result.preview.validation.errors).toContain('Missing required parameter: requiredParam'); - }); - - it('should validate parameter types in dry-run', async () => { - class TypedScript extends AdminScriptBase { - static Definition = { - name: 'typed-script', - version: '1.0.0', - description: 'Script with typed params', - inputSchema: { - type: 'object', - properties: { - count: { type: 'integer' }, - name: { type: 'string' }, - enabled: { type: 'boolean' }, - }, - }, - }; - - async execute() { - return {}; - } - } - - scriptFactory.register(TypedScript); - const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); - - const result = await runner.execute('typed-script', { - count: 'not-a-number', - name: 123, - enabled: 'true', - }, { - trigger: 'MANUAL', - dryRun: true, - }); - - expect(result.status).toBe('DRY_RUN_INVALID'); - expect(result.preview.validation.errors).toHaveLength(3); - }); - - it('should pass validation with correct parameters', async () => { - class ValidScript extends AdminScriptBase { - static Definition = { - name: 'valid-script', - version: '1.0.0', - description: 'Script for validation', - inputSchema: { - type: 'object', - required: ['name'], - properties: { - name: { type: 'string' }, - count: { type: 'integer' }, - }, - }, - }; - - async execute() { - return {}; - } - } - - scriptFactory.register(ValidScript); - const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); - - const result = await runner.execute('valid-script', { - name: 'test', - count: 42, - }, { - trigger: 'MANUAL', - dryRun: true, - }); - - expect(result.status).toBe('DRY_RUN_VALID'); - expect(result.preview.validation.valid).toBe(true); - expect(result.preview.validation.errors).toHaveLength(0); - }); - }); - describe('createScriptRunner()', () => { it('should create runner with default factory', () => { const runner = createScriptRunner(); diff --git a/packages/admin-scripts/src/application/__tests__/validate-script-input.test.js b/packages/admin-scripts/src/application/__tests__/validate-script-input.test.js new file mode 100644 index 000000000..66bc16914 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/validate-script-input.test.js @@ -0,0 +1,196 @@ +const { validateScriptInput, validateParams, validateType } = require('../validate-script-input'); +const { ScriptFactory } = require('../script-factory'); +const { AdminScriptBase } = require('../admin-script-base'); + +describe('validateScriptInput', () => { + let scriptFactory; + + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'Test script', + config: { + requireIntegrationInstance: false, + }, + }; + + async execute(params) { + return { success: true, params }; + } + } + + class SchemaScript extends AdminScriptBase { + static Definition = { + name: 'schema-script', + version: '1.0.0', + description: 'Script with schema', + inputSchema: { + type: 'object', + required: ['requiredParam'], + properties: { + requiredParam: { type: 'string' }, + optionalParam: { type: 'number' }, + }, + }, + }; + + async execute() { + return {}; + } + } + + class TypedScript extends AdminScriptBase { + static Definition = { + name: 'typed-script', + version: '1.0.0', + description: 'Script with typed params', + inputSchema: { + type: 'object', + properties: { + count: { type: 'integer' }, + name: { type: 'string' }, + enabled: { type: 'boolean' }, + }, + }, + }; + + async execute() { + return {}; + } + } + + beforeEach(() => { + scriptFactory = new ScriptFactory([TestScript, SchemaScript, TypedScript]); + }); + + describe('validateScriptInput()', () => { + it('should return VALID for script without schema', () => { + const result = validateScriptInput(scriptFactory, 'test-script', { foo: 'bar' }); + + expect(result.status).toBe('VALID'); + expect(result.scriptName).toBe('test-script'); + expect(result.preview.script.name).toBe('test-script'); + expect(result.preview.script.version).toBe('1.0.0'); + expect(result.preview.input).toEqual({ foo: 'bar' }); + expect(result.message).toContain('Validation passed'); + }); + + it('should return INVALID when required parameters are missing', () => { + const result = validateScriptInput(scriptFactory, 'schema-script', {}); + + expect(result.status).toBe('INVALID'); + expect(result.preview.validation.valid).toBe(false); + expect(result.preview.validation.errors).toContain('Missing required parameter: requiredParam'); + }); + + it('should return INVALID for wrong parameter types', () => { + const result = validateScriptInput(scriptFactory, 'typed-script', { + count: 'not-a-number', + name: 123, + enabled: 'true', + }); + + expect(result.status).toBe('INVALID'); + expect(result.preview.validation.errors).toHaveLength(3); + }); + + it('should return VALID with correct parameters', () => { + const result = validateScriptInput(scriptFactory, 'schema-script', { + requiredParam: 'hello', + optionalParam: 42, + }); + + expect(result.status).toBe('VALID'); + expect(result.preview.validation.valid).toBe(true); + expect(result.preview.validation.errors).toHaveLength(0); + }); + + it('should include inputSchema in preview', () => { + const result = validateScriptInput(scriptFactory, 'schema-script', { + requiredParam: 'test', + }); + + expect(result.preview.inputSchema).toEqual({ + type: 'object', + required: ['requiredParam'], + properties: { + requiredParam: { type: 'string' }, + optionalParam: { type: 'number' }, + }, + }); + }); + + it('should return null inputSchema when script has no schema', () => { + const result = validateScriptInput(scriptFactory, 'test-script', {}); + + expect(result.preview.inputSchema).toBeNull(); + }); + }); + + describe('validateParams()', () => { + it('should return valid when no schema defined', () => { + const result = validateParams({ name: 'test' }, { anything: 'goes' }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should check required fields', () => { + const definition = { + inputSchema: { + type: 'object', + required: ['a', 'b'], + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + }, + }, + }; + + const result = validateParams(definition, { a: 'yes' }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required parameter: b'); + }); + }); + + describe('validateType()', () => { + it('should validate integer type', () => { + expect(validateType('x', 42, { type: 'integer' })).toBeNull(); + expect(validateType('x', 3.14, { type: 'integer' })).toContain('must be an integer'); + expect(validateType('x', 'foo', { type: 'integer' })).toContain('must be an integer'); + }); + + it('should validate number type', () => { + expect(validateType('x', 3.14, { type: 'number' })).toBeNull(); + expect(validateType('x', 42, { type: 'number' })).toBeNull(); + expect(validateType('x', 'foo', { type: 'number' })).toContain('must be a number'); + }); + + it('should validate string type', () => { + expect(validateType('x', 'hello', { type: 'string' })).toBeNull(); + expect(validateType('x', 123, { type: 'string' })).toContain('must be a string'); + }); + + it('should validate boolean type', () => { + expect(validateType('x', true, { type: 'boolean' })).toBeNull(); + expect(validateType('x', 'true', { type: 'boolean' })).toContain('must be a boolean'); + }); + + it('should validate array type', () => { + expect(validateType('x', [1, 2], { type: 'array' })).toBeNull(); + expect(validateType('x', 'not-array', { type: 'array' })).toContain('must be an array'); + }); + + it('should validate object type', () => { + expect(validateType('x', { a: 1 }, { type: 'object' })).toBeNull(); + expect(validateType('x', [1, 2], { type: 'object' })).toContain('must be an object'); + expect(validateType('x', 'string', { type: 'object' })).toContain('must be an object'); + }); + + it('should return null when no type specified', () => { + expect(validateType('x', 'anything', {})).toBeNull(); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js index 83af9c993..75158881b 100644 --- a/packages/admin-scripts/src/application/script-runner.js +++ b/packages/admin-scripts/src/application/script-runner.js @@ -29,10 +29,9 @@ class ScriptRunner { * @param {string} options.executionId - Reuse existing AdminProcess record ID (NOT the Lambda execution ID). * This is the database ID from the AdminProcess collection/table that tracks script executions. * Pass this when resuming a queued execution to continue using the same execution record. - * @param {boolean} options.dryRun - Dry-run mode: validate and preview without executing */ async execute(scriptName, params = {}, options = {}) { - const { trigger, audit = {}, executionId: existingExecutionId, dryRun = false } = options; + const { trigger, audit = {}, executionId: existingExecutionId } = options; if (!trigger) { throw new Error('options.trigger is required (MANUAL | SCHEDULED | QUEUE)'); @@ -49,11 +48,6 @@ class ScriptRunner { ); } - // Dry-run mode: validate and return preview without executing - if (dryRun) { - return this.createDryRunPreview(scriptName, definition, params); - } - let executionId = existingExecutionId; // Create execution record if not provided @@ -141,110 +135,6 @@ class ScriptRunner { }; } } - - /** - * Create dry-run preview without executing the script - * Validates inputs and shows what would be executed - * - * @param {string} scriptName - Script name - * @param {Object} definition - Script definition - * @param {Object} params - Input parameters - * @returns {Object} Dry-run preview - */ - createDryRunPreview(scriptName, definition, params) { - const validation = this.validateParams(definition, params); - - return { - dryRun: true, - status: validation.valid ? 'DRY_RUN_VALID' : 'DRY_RUN_INVALID', - scriptName, - preview: { - script: { - name: definition.name, - version: definition.version, - description: definition.description, - requireIntegrationInstance: definition.config?.requireIntegrationInstance || false, - }, - input: params, - inputSchema: definition.inputSchema || null, - validation, - }, - message: validation.valid - ? 'Dry-run validation passed. Script is ready to execute with provided parameters.' - : `Dry-run validation failed: ${validation.errors.join(', ')}`, - }; - } - - /** - * Validate parameters against script's input schema - * - * @param {Object} definition - Script definition - * @param {Object} params - Input parameters - * @returns {Object} Validation result { valid, errors } - */ - validateParams(definition, params) { - const errors = []; - const schema = definition.inputSchema; - - if (!schema) { - return { valid: true, errors: [] }; - } - - // Check required fields - if (schema.required && Array.isArray(schema.required)) { - for (const field of schema.required) { - if (params[field] === undefined || params[field] === null) { - errors.push(`Missing required parameter: ${field}`); - } - } - } - - // Basic type validation for properties - if (schema.properties) { - for (const [key, prop] of Object.entries(schema.properties)) { - const value = params[key]; - if (value !== undefined && value !== null) { - const typeError = this.validateType(key, value, prop); - if (typeError) { - errors.push(typeError); - } - } - } - } - - return { valid: errors.length === 0, errors }; - } - - /** - * Validate a single parameter type - */ - validateType(key, value, schema) { - const expectedType = schema.type; - if (!expectedType) return null; - - const actualType = Array.isArray(value) ? 'array' : typeof value; - - if (expectedType === 'integer' && (typeof value !== 'number' || !Number.isInteger(value))) { - return `Parameter "${key}" must be an integer`; - } - if (expectedType === 'number' && typeof value !== 'number') { - return `Parameter "${key}" must be a number`; - } - if (expectedType === 'string' && typeof value !== 'string') { - return `Parameter "${key}" must be a string`; - } - if (expectedType === 'boolean' && typeof value !== 'boolean') { - return `Parameter "${key}" must be a boolean`; - } - if (expectedType === 'array' && !Array.isArray(value)) { - return `Parameter "${key}" must be an array`; - } - if (expectedType === 'object' && (typeof value !== 'object' || Array.isArray(value))) { - return `Parameter "${key}" must be an object`; - } - - return null; - } } function createScriptRunner(params = {}) { diff --git a/packages/admin-scripts/src/application/validate-script-input.js b/packages/admin-scripts/src/application/validate-script-input.js new file mode 100644 index 000000000..8a6cb6fdd --- /dev/null +++ b/packages/admin-scripts/src/application/validate-script-input.js @@ -0,0 +1,116 @@ +/** + * Validate Script Input + * + * Application Layer - Standalone validation for script inputs. + * Used by the /validate endpoint to preview what would be executed + * without actually running the script. + */ + +/** + * Validate script input parameters against the script's definition and schema. + * + * @param {Object} scriptFactory - Script factory instance + * @param {string} scriptName - Name of the script to validate + * @param {Object} params - Input parameters to validate + * @returns {Object} Validation preview result + */ +function validateScriptInput(scriptFactory, scriptName, params = {}) { + const scriptClass = scriptFactory.get(scriptName); + const definition = scriptClass.Definition; + const validation = validateParams(definition, params); + + return { + status: validation.valid ? 'VALID' : 'INVALID', + scriptName, + preview: { + script: { + name: definition.name, + version: definition.version, + description: definition.description, + requireIntegrationInstance: definition.config?.requireIntegrationInstance || false, + }, + input: params, + inputSchema: definition.inputSchema || null, + validation, + }, + message: validation.valid + ? 'Validation passed. Script is ready to execute with provided parameters.' + : `Validation failed: ${validation.errors.join(', ')}`, + }; +} + +/** + * Validate parameters against a script's input schema. + * + * @param {Object} definition - Script definition + * @param {Object} params - Input parameters + * @returns {Object} { valid: boolean, errors: string[] } + */ +function validateParams(definition, params) { + const errors = []; + const schema = definition.inputSchema; + + if (!schema) { + return { valid: true, errors: [] }; + } + + // Check required fields + if (schema.required && Array.isArray(schema.required)) { + for (const field of schema.required) { + if (params[field] === undefined || params[field] === null) { + errors.push(`Missing required parameter: ${field}`); + } + } + } + + // Basic type validation for properties + if (schema.properties) { + for (const [key, prop] of Object.entries(schema.properties)) { + const value = params[key]; + if (value !== undefined && value !== null) { + const typeError = validateType(key, value, prop); + if (typeError) { + errors.push(typeError); + } + } + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Validate a single parameter type. + * + * @param {string} key - Parameter name + * @param {*} value - Parameter value + * @param {Object} schema - JSON Schema property definition + * @returns {string|null} Error message or null if valid + */ +function validateType(key, value, schema) { + const expectedType = schema.type; + if (!expectedType) return null; + + if (expectedType === 'integer' && (typeof value !== 'number' || !Number.isInteger(value))) { + return `Parameter "${key}" must be an integer`; + } + if (expectedType === 'number' && typeof value !== 'number') { + return `Parameter "${key}" must be a number`; + } + if (expectedType === 'string' && typeof value !== 'string') { + return `Parameter "${key}" must be a string`; + } + if (expectedType === 'boolean' && typeof value !== 'boolean') { + return `Parameter "${key}" must be a boolean`; + } + if (expectedType === 'array' && !Array.isArray(value)) { + return `Parameter "${key}" must be an array`; + } + if (expectedType === 'object' && (typeof value !== 'object' || Array.isArray(value))) { + return `Parameter "${key}" must be an object`; + } + + return null; +} + +module.exports = { validateScriptInput, validateParams, validateType }; diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js index 47fed7290..0e93970db 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -181,7 +181,7 @@ describe('Admin Script Router', () => { }); expect(response.status).toBe(202); - expect(response.body.status).toBe('PENDING'); + expect(response.body.status).toBe('QUEUED'); expect(response.body.executionId).toBe('exec-456'); expect(QueuerUtil.send).toHaveBeenCalledWith( expect.objectContaining({ @@ -204,7 +204,7 @@ describe('Admin Script Router', () => { }); expect(response.status).toBe(202); - expect(response.body.status).toBe('PENDING'); + expect(response.body.status).toBe('QUEUED'); }); it('should return 404 for non-existent script', async () => { diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index 08e8fc926..753de3f47 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -3,6 +3,7 @@ const serverless = require('serverless-http'); const { validateAdminApiKey } = require('./admin-auth-middleware'); const { getScriptFactory } = require('../application/script-factory'); const { createScriptRunner } = require('../application/script-runner'); +const { validateScriptInput } = require('../application/validate-script-input'); const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); const { QueuerUtil } = require('@friggframework/core/queues'); const { createSchedulerAdapter } = require('../adapters/scheduler-adapter-factory'); @@ -95,13 +96,13 @@ router.get('/scripts/:scriptName', async (req, res) => { }); /** - * POST /admin/scripts/:scriptName - * Execute a script (sync, async, or dry-run) + * POST /admin/scripts/:scriptName/validate + * Validate script inputs without executing (dry-run) */ -router.post('/scripts/:scriptName', async (req, res) => { +router.post('/scripts/:scriptName/validate', async (req, res) => { try { const { scriptName } = req.params; - const { params = {}, mode = 'async', dryRun = false } = req.body; + const { params = {} } = req.body; const factory = getScriptFactory(); if (!factory.has(scriptName)) { @@ -111,19 +112,32 @@ router.post('/scripts/:scriptName', async (req, res) => { }); } - // Dry-run always executes synchronously - if (dryRun) { - const runner = createScriptRunner(); - const result = await runner.execute(scriptName, params, { - trigger: 'MANUAL', - mode: 'sync', - dryRun: true, + const result = validateScriptInput(factory, scriptName, params); + res.json(result); + } catch (error) { + console.error('Error validating script:', error); + res.status(500).json({ error: 'Failed to validate script' }); + } +}); + +/** + * POST /admin/scripts/:scriptName + * Execute a script (sync or async) + */ +router.post('/scripts/:scriptName', async (req, res) => { + try { + const { scriptName } = req.params; + const { params = {}, mode = 'async' } = req.body; + const factory = getScriptFactory(); + + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', }); - return res.json(result); } if (mode === 'sync') { - // Synchronous execution - wait for result const runner = createScriptRunner(); const result = await runner.execute(scriptName, params, { trigger: 'MANUAL', @@ -155,7 +169,7 @@ router.post('/scripts/:scriptName', async (req, res) => { res.status(202).json({ executionId: execution.id, - status: 'PENDING', + status: 'QUEUED', scriptName, message: 'Script queued for execution', }); diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js index 1ad50a70b..1afab4583 100644 --- a/packages/admin-scripts/src/infrastructure/script-executor-handler.js +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -1,65 +1,44 @@ const { createScriptRunner } = require('../application/script-runner'); -const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); /** * SQS Queue Worker Lambda Handler * - * Processes script execution messages from AdminScriptQueue + * Processes script execution messages from AdminScriptQueue. + * Thin adapter: parses SQS messages and delegates to ScriptRunner. + * ScriptRunner handles execution tracking, error recording, and status updates. */ async function handler(event) { const results = []; for (const record of event.Records) { - const message = JSON.parse(record.body); - const { scriptName, executionId, trigger, params } = message; - - console.log(`Processing script: ${scriptName}, executionId: ${executionId}`); + let scriptName; try { - const runner = createScriptRunner(); - const commands = createAdminScriptCommands(); + const message = JSON.parse(record.body); + ({ scriptName } = message); + const { executionId, trigger, params } = message; - // If executionId provided (async from API), update existing record - if (executionId) { - await commands.updateAdminProcessState(executionId, 'RUNNING'); - } + console.log(`Processing script: ${scriptName}, executionId: ${executionId}`); + const runner = createScriptRunner(); const result = await runner.execute(scriptName, params, { trigger: trigger || 'QUEUE', mode: 'async', - executionId, // Reuse existing if provided + executionId, }); - console.log( - `Script completed: ${scriptName}, status: ${result.status}` - ); + console.log(`Script completed: ${scriptName}, status: ${result.status}`); results.push({ scriptName, status: result.status, executionId: result.executionId, }); } catch (error) { - console.error(`Script failed: ${scriptName}`, error); - - // Try to update execution status if we have an ID - if (executionId) { - const commands = createAdminScriptCommands(); - await commands - .completeAdminProcess(executionId, { - state: 'FAILED', - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - }) - .catch((e) => - console.error('Failed to update execution:', e) - ); - } - + // Only reaches here for unexpected failures (message parse errors, runner construction). + // Script execution errors are handled by ScriptRunner and returned as { status: 'FAILED' }. + console.error(`Unexpected error processing record:`, error); results.push({ - scriptName, + scriptName: scriptName || 'unknown', status: 'FAILED', error: error.message, }); From 2239974579f9a1f5f01275c2bd9c147362e1515f Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Thu, 12 Feb 2026 10:12:37 -0500 Subject: [PATCH 30/33] refactor(admin-scripts): slim down context, remove static helpers, add SQS validation - Remove 12 thin wrapper methods from AdminScriptContext; scripts now access repos directly via context.integrationRepository, etc. - Remove DB log persistence from log(); in-memory only now - Remove adminProcessRepository lazy getter (no longer needed) - Add JSDoc documenting AdminScriptContext's unique value - Remove 5 static helpers from AdminScriptBase (getName, getDefinition, etc.) - Update builtin scripts to use repos directly - Add scriptName/executionId validation in SQS handler - Update all tests (-529/+149 lines) Co-Authored-By: Claude Opus 4.6 --- .../__tests__/admin-frigg-commands.test.js | 376 ++++-------------- .../__tests__/admin-script-base.test.js | 91 ----- .../src/application/admin-frigg-commands.js | 93 +---- .../src/application/admin-script-base.js | 20 - .../integration-health-check.test.js | 52 +-- .../__tests__/oauth-token-refresh.test.js | 32 +- .../src/builtins/integration-health-check.js | 6 +- .../src/builtins/oauth-token-refresh.js | 4 +- .../infrastructure/script-executor-handler.js | 4 + 9 files changed, 149 insertions(+), 529 deletions(-) diff --git a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js index a77463958..c8fdbb149 100644 --- a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js +++ b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js @@ -5,22 +5,18 @@ jest.mock('@friggframework/core/integrations/repositories/integration-repository jest.mock('@friggframework/core/user/repositories/user-repository-factory'); jest.mock('@friggframework/core/modules/repositories/module-repository-factory'); jest.mock('@friggframework/core/credential/repositories/credential-repository-factory'); -jest.mock('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); jest.mock('@friggframework/core/queues'); -describe('AdminFriggCommands', () => { +describe('AdminScriptContext', () => { let mockIntegrationRepo; let mockUserRepo; let mockModuleRepo; let mockCredentialRepo; - let mockAdminProcessRepo; let mockQueuerUtil; beforeEach(() => { - // Reset all mocks jest.clearAllMocks(); - // Create mock repositories mockIntegrationRepo = { findIntegrations: jest.fn(), findIntegrationById: jest.fn(), @@ -46,300 +42,116 @@ describe('AdminFriggCommands', () => { updateCredential: jest.fn(), }; - mockAdminProcessRepo = { - appendProcessLog: jest.fn().mockResolvedValue(undefined), - }; - mockQueuerUtil = { send: jest.fn().mockResolvedValue(undefined), batchSend: jest.fn().mockResolvedValue(undefined), }; - // Mock factory functions const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); - const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); const { QueuerUtil } = require('@friggframework/core/queues'); createIntegrationRepository.mockReturnValue(mockIntegrationRepo); createUserRepository.mockReturnValue(mockUserRepo); createModuleRepository.mockReturnValue(mockModuleRepo); createCredentialRepository.mockReturnValue(mockCredentialRepo); - createAdminProcessRepository.mockReturnValue(mockAdminProcessRepo); - // Mock QueuerUtil methods QueuerUtil.send = mockQueuerUtil.send; QueuerUtil.batchSend = mockQueuerUtil.batchSend; }); describe('Constructor', () => { it('creates with executionId', () => { - const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); - expect(commands.executionId).toBe('exec_123'); - expect(commands.logs).toEqual([]); - expect(commands.integrationFactory).toBeNull(); + expect(ctx.executionId).toBe('exec_123'); + expect(ctx.logs).toEqual([]); + expect(ctx.integrationFactory).toBeNull(); }); it('creates with integrationFactory', () => { const mockFactory = { getInstanceFromIntegrationId: jest.fn() }; - const commands = new AdminFriggCommands({ integrationFactory: mockFactory }); + const ctx = new AdminFriggCommands({ integrationFactory: mockFactory }); - expect(commands.integrationFactory).toBe(mockFactory); + expect(ctx.integrationFactory).toBe(mockFactory); }); it('creates without params (defaults)', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - expect(commands.executionId).toBeNull(); - expect(commands.logs).toEqual([]); - expect(commands.integrationFactory).toBeNull(); + expect(ctx.executionId).toBeNull(); + expect(ctx.logs).toEqual([]); + expect(ctx.integrationFactory).toBeNull(); }); }); describe('Lazy Repository Loading', () => { it('creates integrationRepository on first access', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); expect(createIntegrationRepository).not.toHaveBeenCalled(); - const repo = commands.integrationRepository; + const repo = ctx.integrationRepository; expect(createIntegrationRepository).toHaveBeenCalledTimes(1); expect(repo).toBe(mockIntegrationRepo); }); it('returns same instance on subsequent access', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - const repo1 = commands.integrationRepository; - const repo2 = commands.integrationRepository; + const repo1 = ctx.integrationRepository; + const repo2 = ctx.integrationRepository; expect(repo1).toBe(repo2); expect(repo1).toBe(mockIntegrationRepo); }); it('creates userRepository on first access', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); expect(createUserRepository).not.toHaveBeenCalled(); - const repo = commands.userRepository; + const repo = ctx.userRepository; expect(createUserRepository).toHaveBeenCalledTimes(1); expect(repo).toBe(mockUserRepo); }); it('creates moduleRepository on first access', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); expect(createModuleRepository).not.toHaveBeenCalled(); - const repo = commands.moduleRepository; + const repo = ctx.moduleRepository; expect(createModuleRepository).toHaveBeenCalledTimes(1); expect(repo).toBe(mockModuleRepo); }); it('creates credentialRepository on first access', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); expect(createCredentialRepository).not.toHaveBeenCalled(); - const repo = commands.credentialRepository; + const repo = ctx.credentialRepository; expect(createCredentialRepository).toHaveBeenCalledTimes(1); expect(repo).toBe(mockCredentialRepo); }); - - it('creates adminProcessRepository on first access', () => { - const commands = new AdminFriggCommands(); - const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); - - expect(createAdminProcessRepository).not.toHaveBeenCalled(); - - const repo = commands.adminProcessRepository; - - expect(createAdminProcessRepository).toHaveBeenCalledTimes(1); - expect(repo).toBe(mockAdminProcessRepo); - }); - }); - - describe('Integration Queries', () => { - it('listIntegrations with userId filter calls findIntegrationsByUserId', async () => { - const commands = new AdminFriggCommands(); - const mockIntegrations = [{ id: '1' }, { id: '2' }]; - mockIntegrationRepo.findIntegrationsByUserId.mockResolvedValue(mockIntegrations); - - const result = await commands.listIntegrations({ userId: 'user_123' }); - - expect(result).toEqual(mockIntegrations); - expect(mockIntegrationRepo.findIntegrationsByUserId).toHaveBeenCalledWith('user_123'); - }); - - it('listIntegrations without userId calls findIntegrations', async () => { - const commands = new AdminFriggCommands(); - const mockIntegrations = [{ id: '1' }]; - mockIntegrationRepo.findIntegrations.mockResolvedValue(mockIntegrations); - - const result = await commands.listIntegrations({ status: 'active' }); - - expect(result).toEqual(mockIntegrations); - expect(mockIntegrationRepo.findIntegrations).toHaveBeenCalledWith({ status: 'active' }); - }); - - it('findIntegrationById calls repository', async () => { - const commands = new AdminFriggCommands(); - const mockIntegration = { id: 'int_123', name: 'Test' }; - mockIntegrationRepo.findIntegrationById.mockResolvedValue(mockIntegration); - - const result = await commands.findIntegrationById('int_123'); - - expect(result).toEqual(mockIntegration); - expect(mockIntegrationRepo.findIntegrationById).toHaveBeenCalledWith('int_123'); - }); - - it('findIntegrationsByUserId calls repository', async () => { - const commands = new AdminFriggCommands(); - const mockIntegrations = [{ id: '1' }, { id: '2' }]; - mockIntegrationRepo.findIntegrationsByUserId.mockResolvedValue(mockIntegrations); - - const result = await commands.findIntegrationsByUserId('user_123'); - - expect(result).toEqual(mockIntegrations); - expect(mockIntegrationRepo.findIntegrationsByUserId).toHaveBeenCalledWith('user_123'); - }); - - it('updateIntegrationConfig calls repository', async () => { - const commands = new AdminFriggCommands(); - const newConfig = { setting: 'value' }; - const updatedIntegration = { id: 'int_123', config: newConfig }; - mockIntegrationRepo.updateIntegrationConfig.mockResolvedValue(updatedIntegration); - - const result = await commands.updateIntegrationConfig('int_123', newConfig); - - expect(result).toEqual(updatedIntegration); - expect(mockIntegrationRepo.updateIntegrationConfig).toHaveBeenCalledWith('int_123', newConfig); - }); - - it('updateIntegrationStatus calls repository', async () => { - const commands = new AdminFriggCommands(); - const updatedIntegration = { id: 'int_123', status: 'active' }; - mockIntegrationRepo.updateIntegrationStatus.mockResolvedValue(updatedIntegration); - - const result = await commands.updateIntegrationStatus('int_123', 'active'); - - expect(result).toEqual(updatedIntegration); - expect(mockIntegrationRepo.updateIntegrationStatus).toHaveBeenCalledWith('int_123', 'active'); - }); - }); - - describe('User Queries', () => { - it('findUserById calls repository', async () => { - const commands = new AdminFriggCommands(); - const mockUser = { id: 'user_123', email: 'test@example.com' }; - mockUserRepo.findIndividualUserById.mockResolvedValue(mockUser); - - const result = await commands.findUserById('user_123'); - - expect(result).toEqual(mockUser); - expect(mockUserRepo.findIndividualUserById).toHaveBeenCalledWith('user_123'); - }); - - it('findUserByAppUserId calls repository', async () => { - const commands = new AdminFriggCommands(); - const mockUser = { id: 'user_123', appUserId: 'app_456' }; - mockUserRepo.findIndividualUserByAppUserId.mockResolvedValue(mockUser); - - const result = await commands.findUserByAppUserId('app_456'); - - expect(result).toEqual(mockUser); - expect(mockUserRepo.findIndividualUserByAppUserId).toHaveBeenCalledWith('app_456'); - }); - - it('findUserByUsername calls repository', async () => { - const commands = new AdminFriggCommands(); - const mockUser = { id: 'user_123', username: 'testuser' }; - mockUserRepo.findIndividualUserByUsername.mockResolvedValue(mockUser); - - const result = await commands.findUserByUsername('testuser'); - - expect(result).toEqual(mockUser); - expect(mockUserRepo.findIndividualUserByUsername).toHaveBeenCalledWith('testuser'); - }); - }); - - describe('Entity Queries', () => { - it('listEntities with userId filter calls findEntitiesByUserId', async () => { - const commands = new AdminFriggCommands(); - const mockEntities = [{ id: 'ent_1' }, { id: 'ent_2' }]; - mockModuleRepo.findEntitiesByUserId.mockResolvedValue(mockEntities); - - const result = await commands.listEntities({ userId: 'user_123' }); - - expect(result).toEqual(mockEntities); - expect(mockModuleRepo.findEntitiesByUserId).toHaveBeenCalledWith('user_123'); - }); - - it('listEntities without userId calls findEntity', async () => { - const commands = new AdminFriggCommands(); - const mockEntities = [{ id: 'ent_1' }]; - mockModuleRepo.findEntity.mockResolvedValue(mockEntities); - - const result = await commands.listEntities({ type: 'account' }); - - expect(result).toEqual(mockEntities); - expect(mockModuleRepo.findEntity).toHaveBeenCalledWith({ type: 'account' }); - }); - - it('findEntityById calls repository', async () => { - const commands = new AdminFriggCommands(); - const mockEntity = { id: 'ent_123', name: 'Test Entity' }; - mockModuleRepo.findEntityById.mockResolvedValue(mockEntity); - - const result = await commands.findEntityById('ent_123'); - - expect(result).toEqual(mockEntity); - expect(mockModuleRepo.findEntityById).toHaveBeenCalledWith('ent_123'); - }); - }); - - describe('Credential Queries', () => { - it('findCredential calls repository', async () => { - const commands = new AdminFriggCommands(); - const mockCredential = { id: 'cred_123', userId: 'user_123' }; - mockCredentialRepo.findCredential.mockResolvedValue(mockCredential); - - const result = await commands.findCredential({ userId: 'user_123' }); - - expect(result).toEqual(mockCredential); - expect(mockCredentialRepo.findCredential).toHaveBeenCalledWith({ userId: 'user_123' }); - }); - - it('updateCredential calls repository', async () => { - const commands = new AdminFriggCommands(); - const updates = { data: { newToken: 'xyz' } }; - const updatedCredential = { id: 'cred_123', ...updates }; - mockCredentialRepo.updateCredential.mockResolvedValue(updatedCredential); - - const result = await commands.updateCredential('cred_123', updates); - - expect(result).toEqual(updatedCredential); - expect(mockCredentialRepo.updateCredential).toHaveBeenCalledWith('cred_123', updates); - }); }); describe('instantiate()', () => { it('throws if no integrationFactory', async () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - await expect(commands.instantiate('int_123')).rejects.toThrow( + await expect(ctx.instantiate('int_123')).rejects.toThrow( 'instantiate() requires integrationFactory. ' + 'Set Definition.config.requireIntegrationInstance = true' ); @@ -350,9 +162,9 @@ describe('AdminFriggCommands', () => { const mockFactory = { getInstanceFromIntegrationId: jest.fn().mockResolvedValue(mockInstance), }; - const commands = new AdminFriggCommands({ integrationFactory: mockFactory }); + const ctx = new AdminFriggCommands({ integrationFactory: mockFactory }); - const result = await commands.instantiate('int_123'); + const result = await ctx.instantiate('int_123'); expect(result).toEqual(mockInstance); expect(mockFactory.getInstanceFromIntegrationId).toHaveBeenCalledWith({ @@ -366,9 +178,9 @@ describe('AdminFriggCommands', () => { const mockFactory = { getInstanceFromIntegrationId: jest.fn().mockResolvedValue(mockInstance), }; - const commands = new AdminFriggCommands({ integrationFactory: mockFactory }); + const ctx = new AdminFriggCommands({ integrationFactory: mockFactory }); - await commands.instantiate('int_123'); + await ctx.instantiate('int_123'); const callArgs = mockFactory.getInstanceFromIntegrationId.mock.calls[0][0]; expect(callArgs._isAdminContext).toBe(true); @@ -388,19 +200,19 @@ describe('AdminFriggCommands', () => { it('throws if ADMIN_SCRIPT_QUEUE_URL not set', async () => { delete process.env.ADMIN_SCRIPT_QUEUE_URL; - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - await expect(commands.queueScript('test-script', {})).rejects.toThrow( + await expect(ctx.queueScript('test-script', {})).rejects.toThrow( 'ADMIN_SCRIPT_QUEUE_URL environment variable not set' ); }); it('calls QueuerUtil.send with correct params', async () => { process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/admin-scripts'; - const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); const params = { integrationId: 'int_456' }; - await commands.queueScript('test-script', params); + await ctx.queueScript('test-script', params); expect(mockQueuerUtil.send).toHaveBeenCalledWith( { @@ -415,9 +227,9 @@ describe('AdminFriggCommands', () => { it('includes parentExecutionId from constructor', async () => { process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; - const commands = new AdminFriggCommands({ executionId: 'exec_parent' }); + const ctx = new AdminFriggCommands({ executionId: 'exec_parent' }); - await commands.queueScript('my-script', {}); + await ctx.queueScript('my-script', {}); const callArgs = mockQueuerUtil.send.mock.calls[0][0]; expect(callArgs.parentExecutionId).toBe('exec_parent'); @@ -425,12 +237,12 @@ describe('AdminFriggCommands', () => { it('logs queuing operation', async () => { process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const params = { batchId: 'batch_1' }; - await commands.queueScript('test-script', params); + await ctx.queueScript('test-script', params); - const logs = commands.getLogs(); + const logs = ctx.getLogs(); expect(logs).toHaveLength(1); expect(logs[0].level).toBe('info'); expect(logs[0].message).toBe('Queued continuation for test-script'); @@ -451,22 +263,22 @@ describe('AdminFriggCommands', () => { it('throws if ADMIN_SCRIPT_QUEUE_URL not set', async () => { delete process.env.ADMIN_SCRIPT_QUEUE_URL; - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - await expect(commands.queueScriptBatch([])).rejects.toThrow( + await expect(ctx.queueScriptBatch([])).rejects.toThrow( 'ADMIN_SCRIPT_QUEUE_URL environment variable not set' ); }); it('calls QueuerUtil.batchSend', async () => { process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; - const commands = new AdminFriggCommands({ executionId: 'exec_123' }); + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); const entries = [ { scriptName: 'script-1', params: { id: '1' } }, { scriptName: 'script-2', params: { id: '2' } }, ]; - await commands.queueScriptBatch(entries); + await ctx.queueScriptBatch(entries); expect(mockQueuerUtil.batchSend).toHaveBeenCalledWith( [ @@ -489,12 +301,12 @@ describe('AdminFriggCommands', () => { it('maps entries correctly', async () => { process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const entries = [ { scriptName: 'test-script', params: { value: 'abc' } }, ]; - await commands.queueScriptBatch(entries); + await ctx.queueScriptBatch(entries); const callArgs = mockQueuerUtil.batchSend.mock.calls[0][0]; expect(callArgs).toHaveLength(1); @@ -505,12 +317,12 @@ describe('AdminFriggCommands', () => { it('handles entries without params', async () => { process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const entries = [ { scriptName: 'no-params-script' }, ]; - await commands.queueScriptBatch(entries); + await ctx.queueScriptBatch(entries); const callArgs = mockQueuerUtil.batchSend.mock.calls[0][0]; expect(callArgs[0].params).toEqual({}); @@ -518,16 +330,16 @@ describe('AdminFriggCommands', () => { it('logs batch queuing operation', async () => { process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); const entries = [ { scriptName: 'script-1', params: {} }, { scriptName: 'script-2', params: {} }, { scriptName: 'script-3', params: {} }, ]; - await commands.queueScriptBatch(entries); + await ctx.queueScriptBatch(entries); - const logs = commands.getLogs(); + const logs = ctx.getLogs(); expect(logs).toHaveLength(1); expect(logs[0].level).toBe('info'); expect(logs[0].message).toBe('Queued 3 script continuations'); @@ -536,63 +348,37 @@ describe('AdminFriggCommands', () => { describe('Logging', () => { it('log() adds entry to logs array', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - const entry = commands.log('info', 'Test message', { key: 'value' }); + const entry = ctx.log('info', 'Test message', { key: 'value' }); expect(entry.level).toBe('info'); expect(entry.message).toBe('Test message'); expect(entry.data).toEqual({ key: 'value' }); expect(entry.timestamp).toBeDefined(); - expect(commands.logs).toHaveLength(1); - expect(commands.logs[0]).toBe(entry); + expect(ctx.logs).toHaveLength(1); + expect(ctx.logs[0]).toBe(entry); }); - it('log() persists if executionId set', async () => { - const commands = new AdminFriggCommands({ executionId: 'exec_123' }); - // Force repository creation - commands.adminProcessRepository; - - commands.log('warn', 'Warning message', { detail: 'xyz' }); - - // Give async operation a chance to execute - await new Promise(resolve => setImmediate(resolve)); - - expect(mockAdminProcessRepo.appendProcessLog).toHaveBeenCalled(); - const callArgs = mockAdminProcessRepo.appendProcessLog.mock.calls[0]; - expect(callArgs[0]).toBe('exec_123'); - expect(callArgs[1].level).toBe('warn'); - expect(callArgs[1].message).toBe('Warning message'); - }); - - it('log() does not persist if no executionId', async () => { - const commands = new AdminFriggCommands(); - - commands.log('info', 'Test'); - - await new Promise(resolve => setImmediate(resolve)); - - expect(mockAdminProcessRepo.appendProcessLog).not.toHaveBeenCalled(); - }); + it('log() is in-memory only (no DB persistence)', () => { + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); - it('log() handles persistence failure gracefully', async () => { - const commands = new AdminFriggCommands({ executionId: 'exec_123' }); - // Force repository creation - commands.adminProcessRepository; - mockAdminProcessRepo.appendProcessLog.mockRejectedValue(new Error('DB Error')); + ctx.log('warn', 'Warning message', { detail: 'xyz' }); - // Should not throw - expect(() => commands.log('error', 'Test error')).not.toThrow(); + // Verify entry was added to in-memory logs + expect(ctx.logs).toHaveLength(1); + expect(ctx.logs[0].level).toBe('warn'); + expect(ctx.logs[0].message).toBe('Warning message'); }); it('getLogs() returns all logs', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - commands.log('info', 'First'); - commands.log('warn', 'Second'); - commands.log('error', 'Third'); + ctx.log('info', 'First'); + ctx.log('warn', 'Second'); + ctx.log('error', 'Third'); - const logs = commands.getLogs(); + const logs = ctx.getLogs(); expect(logs).toHaveLength(3); expect(logs[0].message).toBe('First'); @@ -601,43 +387,43 @@ describe('AdminFriggCommands', () => { }); it('clearLogs() clears logs array', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - commands.log('info', 'First'); - commands.log('info', 'Second'); - expect(commands.logs).toHaveLength(2); + ctx.log('info', 'First'); + ctx.log('info', 'Second'); + expect(ctx.logs).toHaveLength(2); - commands.clearLogs(); + ctx.clearLogs(); - expect(commands.logs).toHaveLength(0); + expect(ctx.logs).toHaveLength(0); }); it('getExecutionId() returns executionId', () => { - const commands = new AdminFriggCommands({ executionId: 'exec_789' }); + const ctx = new AdminFriggCommands({ executionId: 'exec_789' }); - expect(commands.getExecutionId()).toBe('exec_789'); + expect(ctx.getExecutionId()).toBe('exec_789'); }); it('getExecutionId() returns null if not set', () => { - const commands = new AdminFriggCommands(); + const ctx = new AdminFriggCommands(); - expect(commands.getExecutionId()).toBeNull(); + expect(ctx.getExecutionId()).toBeNull(); }); }); describe('createAdminFriggCommands factory', () => { - it('creates AdminFriggCommands instance', () => { - const commands = createAdminFriggCommands({ executionId: 'exec_123' }); + it('creates AdminScriptContext instance', () => { + const ctx = createAdminFriggCommands({ executionId: 'exec_123' }); - expect(commands).toBeInstanceOf(AdminFriggCommands); - expect(commands.executionId).toBe('exec_123'); + expect(ctx).toBeInstanceOf(AdminFriggCommands); + expect(ctx.executionId).toBe('exec_123'); }); it('creates with default params', () => { - const commands = createAdminFriggCommands(); + const ctx = createAdminFriggCommands(); - expect(commands).toBeInstanceOf(AdminFriggCommands); - expect(commands.executionId).toBeNull(); + expect(ctx).toBeInstanceOf(AdminFriggCommands); + expect(ctx.executionId).toBeNull(); }); }); }); diff --git a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js index 20986a8bd..b07bb61d2 100644 --- a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js +++ b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js @@ -46,104 +46,13 @@ describe('AdminScriptBase', () => { }); it('should have clean display object without redundant fields', () => { - // Default display should only have UI-specific fields expect(AdminScriptBase.Definition.display).toBeDefined(); expect(AdminScriptBase.Definition.display.category).toBe('maintenance'); - // Should NOT have redundant label/description expect(AdminScriptBase.Definition.display.label).toBeUndefined(); expect(AdminScriptBase.Definition.display.description).toBeUndefined(); }); }); - describe('Static methods', () => { - it('getName() should return the script name', () => { - class TestScript extends AdminScriptBase { - static Definition = { - name: 'my-script', - version: '1.0.0', - description: 'test', - }; - } - - expect(TestScript.getName()).toBe('my-script'); - }); - - it('getCurrentVersion() should return the version', () => { - class TestScript extends AdminScriptBase { - static Definition = { - name: 'my-script', - version: '2.3.1', - description: 'test', - }; - } - - expect(TestScript.getCurrentVersion()).toBe('2.3.1'); - }); - - it('getDefinition() should return the full Definition', () => { - class TestScript extends AdminScriptBase { - static Definition = { - name: 'my-script', - version: '1.0.0', - description: 'test', - source: 'USER_DEFINED', - }; - } - - const definition = TestScript.getDefinition(); - expect(definition).toEqual({ - name: 'my-script', - version: '1.0.0', - description: 'test', - source: 'USER_DEFINED', - }); - }); - - it('getDisplayLabel() should return display.label or fall back to name', () => { - class ScriptWithLabel extends AdminScriptBase { - static Definition = { - name: 'my-script', - version: '1.0.0', - description: 'test', - display: { label: 'My Custom Label' }, - }; - } - - class ScriptWithoutLabel extends AdminScriptBase { - static Definition = { - name: 'another-script', - version: '1.0.0', - description: 'test', - }; - } - - expect(ScriptWithLabel.getDisplayLabel()).toBe('My Custom Label'); - expect(ScriptWithoutLabel.getDisplayLabel()).toBe('another-script'); - }); - - it('getDisplayDescription() should return display.description or fall back to description', () => { - class ScriptWithDisplayDesc extends AdminScriptBase { - static Definition = { - name: 'my-script', - version: '1.0.0', - description: 'Technical description', - display: { description: 'User-friendly description' }, - }; - } - - class ScriptWithoutDisplayDesc extends AdminScriptBase { - static Definition = { - name: 'another-script', - version: '1.0.0', - description: 'Technical description', - }; - } - - expect(ScriptWithDisplayDesc.getDisplayDescription()).toBe('User-friendly description'); - expect(ScriptWithoutDisplayDesc.getDisplayDescription()).toBe('Technical description'); - }); - }); - describe('Constructor', () => { it('should initialize with default values', () => { const script = new AdminScriptBase(); diff --git a/packages/admin-scripts/src/application/admin-frigg-commands.js b/packages/admin-scripts/src/application/admin-frigg-commands.js index 6bf7571c2..c3a51dc85 100644 --- a/packages/admin-scripts/src/application/admin-frigg-commands.js +++ b/packages/admin-scripts/src/application/admin-frigg-commands.js @@ -1,5 +1,20 @@ const { QueuerUtil } = require('@friggframework/core/queues'); +/** + * AdminScriptContext - Execution environment for admin scripts + * + * Provides a controlled surface area for scripts to interact with + * the Frigg platform. Unique capabilities vs direct repo access: + * + * - **Admin bypass**: `instantiate()` passes `_isAdminContext: true` to + * skip user-ownership checks when loading integration instances + * - **Script chaining**: `queueScript()` / `queueScriptBatch()` let scripts + * enqueue follow-up work with parent execution tracking + * - **Execution-scoped logging**: `log()` collects structured entries tied + * to the current execution for post-run inspection + * - **Lazy-loaded repositories**: Repos are exposed directly as getters + * so scripts can query any data they need without wrapper indirection + */ class AdminScriptContext { constructor(params = {}) { this.executionId = params.executionId || null; @@ -13,7 +28,6 @@ class AdminScriptContext { this._userRepository = null; this._moduleRepository = null; this._credentialRepository = null; - this._adminProcessRepository = null; } // ==================== LAZY-LOADED REPOSITORIES ==================== @@ -50,76 +64,6 @@ class AdminScriptContext { return this._credentialRepository; } - get adminProcessRepository() { - if (!this._adminProcessRepository) { - const { createAdminProcessRepository } = require('@friggframework/core/admin-scripts/repositories/admin-process-repository-factory'); - this._adminProcessRepository = createAdminProcessRepository(); - } - return this._adminProcessRepository; - } - - // ==================== INTEGRATION QUERIES ==================== - - async listIntegrations(filter = {}) { - if (filter.userId) { - return this.integrationRepository.findIntegrationsByUserId(filter.userId); - } - return this.integrationRepository.findIntegrations(filter); - } - - async findIntegrationById(id) { - return this.integrationRepository.findIntegrationById(id); - } - - async findIntegrationsByUserId(userId) { - return this.integrationRepository.findIntegrationsByUserId(userId); - } - - async updateIntegrationConfig(integrationId, config) { - return this.integrationRepository.updateIntegrationConfig(integrationId, config); - } - - async updateIntegrationStatus(integrationId, status) { - return this.integrationRepository.updateIntegrationStatus(integrationId, status); - } - - // ==================== USER QUERIES ==================== - - async findUserById(userId) { - return this.userRepository.findIndividualUserById(userId); - } - - async findUserByAppUserId(appUserId) { - return this.userRepository.findIndividualUserByAppUserId(appUserId); - } - - async findUserByUsername(username) { - return this.userRepository.findIndividualUserByUsername(username); - } - - // ==================== ENTITY QUERIES ==================== - - async listEntities(filter = {}) { - if (filter.userId) { - return this.moduleRepository.findEntitiesByUserId(filter.userId); - } - return this.moduleRepository.findEntity(filter); - } - - async findEntityById(entityId) { - return this.moduleRepository.findEntityById(entityId); - } - - // ==================== CREDENTIAL QUERIES ==================== - - async findCredential(filter) { - return this.credentialRepository.findCredential(filter); - } - - async updateCredential(credentialId, updates) { - return this.credentialRepository.updateCredential(credentialId, updates); - } - // ==================== INTEGRATION INSTANTIATION ==================== /** @@ -187,13 +131,6 @@ class AdminScriptContext { timestamp: new Date().toISOString(), }; this.logs.push(entry); - - // Persist to execution record if we have an executionId - if (this.executionId) { - this.adminProcessRepository.appendProcessLog(this.executionId, entry) - .catch(err => console.error('Failed to persist log:', err)); - } - return entry; } diff --git a/packages/admin-scripts/src/application/admin-script-base.js b/packages/admin-scripts/src/application/admin-script-base.js index e6ad30165..2aaa50f50 100644 --- a/packages/admin-scripts/src/application/admin-script-base.js +++ b/packages/admin-scripts/src/application/admin-script-base.js @@ -25,26 +25,6 @@ class AdminScriptBase { }, }; - static getName() { - return this.Definition.name; - } - - static getCurrentVersion() { - return this.Definition.version; - } - - static getDefinition() { - return this.Definition; - } - - static getDisplayLabel() { - return this.Definition.display?.label || this.Definition.name; - } - - static getDisplayDescription() { - return this.Definition.display?.description || this.Definition.description; - } - constructor(params = {}) { this.context = params.context || null; this.executionId = params.executionId || null; diff --git a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js index 2ae9b9746..94781f329 100644 --- a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js +++ b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js @@ -52,16 +52,18 @@ describe('IntegrationHealthCheckScript', () => { beforeEach(() => { mockContext = { log: jest.fn(), - listIntegrations: jest.fn(), - findIntegrationById: jest.fn(), + integrationRepository: { + findIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + updateIntegrationStatus: jest.fn(), + }, instantiate: jest.fn(), - updateIntegrationStatus: jest.fn(), }; script = new IntegrationHealthCheckScript({ context: mockContext }); }); it('should return empty results when no integrations found', async () => { - mockContext.listIntegrations.mockResolvedValue([]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([]); const result = await script.execute({}); @@ -91,7 +93,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); const result = await script.execute({ @@ -118,7 +120,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); const result = await script.execute({ checkCredentials: true, @@ -147,7 +149,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); const result = await script.execute({ checkCredentials: true, @@ -182,7 +184,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); const result = await script.execute({ @@ -215,9 +217,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); - mockContext.updateIntegrationStatus.mockResolvedValue(undefined); + mockContext.integrationRepository.updateIntegrationStatus.mockResolvedValue(undefined); const result = await script.execute({ checkCredentials: true, @@ -226,7 +228,7 @@ describe('IntegrationHealthCheckScript', () => { }); expect(result.healthy).toBe(1); - expect(mockContext.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ACTIVE'); + expect(mockContext.integrationRepository.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ACTIVE'); }); it('should update integration status to ERROR for unhealthy integrations', async () => { @@ -238,8 +240,8 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); - mockContext.updateIntegrationStatus.mockResolvedValue(undefined); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.updateIntegrationStatus.mockResolvedValue(undefined); const result = await script.execute({ checkCredentials: true, @@ -248,7 +250,7 @@ describe('IntegrationHealthCheckScript', () => { }); expect(result.unhealthy).toBe(1); - expect(mockContext.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ERROR'); + expect(mockContext.integrationRepository.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ERROR'); }); it('should not update status when updateStatus is false', async () => { @@ -271,7 +273,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); await script.execute({ @@ -280,7 +282,7 @@ describe('IntegrationHealthCheckScript', () => { updateStatus: false }); - expect(mockContext.updateIntegrationStatus).not.toHaveBeenCalled(); + expect(mockContext.integrationRepository.updateIntegrationStatus).not.toHaveBeenCalled(); }); it('should handle status update failures gracefully', async () => { @@ -303,9 +305,9 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); - mockContext.updateIntegrationStatus.mockRejectedValue(new Error('Update failed')); + mockContext.integrationRepository.updateIntegrationStatus.mockRejectedValue(new Error('Update failed')); const result = await script.execute({ checkCredentials: true, @@ -331,7 +333,7 @@ describe('IntegrationHealthCheckScript', () => { config: { type: 'salesforce', credentials: { access_token: 'token2' } } }; - mockContext.findIntegrationById.mockImplementation((id) => { + mockContext.integrationRepository.findIntegrationById.mockImplementation((id) => { if (id === 'int-1') return Promise.resolve(integration1); if (id === 'int-2') return Promise.resolve(integration2); return Promise.reject(new Error('Not found')); @@ -343,9 +345,9 @@ describe('IntegrationHealthCheckScript', () => { checkConnectivity: false }); - expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-1'); - expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-2'); - expect(mockContext.listIntegrations).not.toHaveBeenCalled(); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.integrationRepository.findIntegrations).not.toHaveBeenCalled(); expect(result.results).toHaveLength(2); }); @@ -361,7 +363,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockRejectedValue(new Error('Instantiation failed')); const result = await script.execute({ @@ -391,7 +393,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); const result = await script.execute({ @@ -415,7 +417,7 @@ describe('IntegrationHealthCheckScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); const result = await script.execute({ checkCredentials: true, diff --git a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js index bfaf6d592..9068ad17c 100644 --- a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js +++ b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js @@ -46,15 +46,17 @@ describe('OAuthTokenRefreshScript', () => { beforeEach(() => { mockContext = { log: jest.fn(), - listIntegrations: jest.fn(), - findIntegrationById: jest.fn(), + integrationRepository: { + findIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + }, instantiate: jest.fn(), }; script = new OAuthTokenRefreshScript({ context: mockContext }); }); it('should return empty results when no integrations found', async () => { - mockContext.listIntegrations.mockResolvedValue([]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([]); const result = await script.execute({}); @@ -70,7 +72,7 @@ describe('OAuthTokenRefreshScript', () => { id: 'int-1', config: {} // No credentials }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); const result = await script.execute({}); @@ -93,7 +95,7 @@ describe('OAuthTokenRefreshScript', () => { } } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); const result = await script.execute({}); @@ -116,7 +118,7 @@ describe('OAuthTokenRefreshScript', () => { } } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); const result = await script.execute({ expiryThresholdHours: 24 @@ -150,7 +152,7 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); const result = await script.execute({ @@ -178,7 +180,7 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); const result = await script.execute({ expiryThresholdHours: 24, @@ -215,7 +217,7 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); const result = await script.execute({ @@ -251,7 +253,7 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockResolvedValue(mockInstance); const result = await script.execute({ @@ -276,7 +278,7 @@ describe('OAuthTokenRefreshScript', () => { config: { credentials: { access_token: 'token2' } } }; - mockContext.findIntegrationById.mockImplementation((id) => { + mockContext.integrationRepository.findIntegrationById.mockImplementation((id) => { if (id === 'int-1') return Promise.resolve(integration1); if (id === 'int-2') return Promise.resolve(integration2); return Promise.reject(new Error('Not found')); @@ -286,9 +288,9 @@ describe('OAuthTokenRefreshScript', () => { integrationIds: ['int-1', 'int-2'] }); - expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-1'); - expect(mockContext.findIntegrationById).toHaveBeenCalledWith('int-2'); - expect(mockContext.listIntegrations).not.toHaveBeenCalled(); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.integrationRepository.findIntegrations).not.toHaveBeenCalled(); expect(result.details).toHaveLength(2); }); @@ -303,7 +305,7 @@ describe('OAuthTokenRefreshScript', () => { } }; - mockContext.listIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); mockContext.instantiate.mockRejectedValue(new Error('Instantiation failed')); const result = await script.execute({ diff --git a/packages/admin-scripts/src/builtins/integration-health-check.js b/packages/admin-scripts/src/builtins/integration-health-check.js index b04d7b05f..a6ffe4ea2 100644 --- a/packages/admin-scripts/src/builtins/integration-health-check.js +++ b/packages/admin-scripts/src/builtins/integration-health-check.js @@ -94,7 +94,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase { let integrations; if (integrationIds && integrationIds.length > 0) { integrations = await Promise.all( - integrationIds.map(id => this.context.findIntegrationById(id).catch(() => null)) + integrationIds.map(id => this.context.integrationRepository.findIntegrationById(id).catch(() => null)) ); integrations = integrations.filter(Boolean); } else { @@ -123,7 +123,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase { if (updateStatus && result.status !== 'unknown') { try { const newStatus = result.status === 'healthy' ? 'ACTIVE' : 'ERROR'; - await this.context.updateIntegrationStatus(integration.id, newStatus); + await this.context.integrationRepository.updateIntegrationStatus(integration.id, newStatus); this.context.log('info', `Updated status for ${integration.id} to ${newStatus}`); } catch (error) { this.context.log('warn', `Failed to update status for ${integration.id}`, { @@ -143,7 +143,7 @@ class IntegrationHealthCheckScript extends AdminScriptBase { } async getAllIntegrations() { - return this.context.listIntegrations({}); + return this.context.integrationRepository.findIntegrations({}); } async checkIntegration(integration, options) { diff --git a/packages/admin-scripts/src/builtins/oauth-token-refresh.js b/packages/admin-scripts/src/builtins/oauth-token-refresh.js index b45739b87..6e895b4a6 100644 --- a/packages/admin-scripts/src/builtins/oauth-token-refresh.js +++ b/packages/admin-scripts/src/builtins/oauth-token-refresh.js @@ -80,7 +80,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase { let integrations; if (integrationIds && integrationIds.length > 0) { integrations = await Promise.all( - integrationIds.map(id => this.context.findIntegrationById(id).catch(() => null)) + integrationIds.map(id => this.context.integrationRepository.findIntegrationById(id).catch(() => null)) ); integrations = integrations.filter(Boolean); } else { @@ -131,7 +131,7 @@ class OAuthTokenRefreshScript extends AdminScriptBase { async getAllIntegrations() { // This is a simplified implementation // In production, would need pagination for large datasets - return this.context.listIntegrations({}); + return this.context.integrationRepository.findIntegrations({}); } async processIntegration(integration, options) { diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js index 1afab4583..6ebeb557f 100644 --- a/packages/admin-scripts/src/infrastructure/script-executor-handler.js +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -18,6 +18,10 @@ async function handler(event) { ({ scriptName } = message); const { executionId, trigger, params } = message; + if (!scriptName || !executionId) { + throw new Error(`Invalid SQS message: missing scriptName or executionId`); + } + console.log(`Processing script: ${scriptName}, executionId: ${executionId}`); const runner = createScriptRunner(); From 179491ee820576004873fdfdbe0e8e10a807098d Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Mon, 16 Feb 2026 02:43:13 -0500 Subject: [PATCH 31/33] fix(admin-scripts): address Cursor Bugbot findings - scheduler config, queue validation, unused deps - Pass scheduler config from env vars to createSchedulerAdapter() instead of empty call - Validate ADMIN_SCRIPT_QUEUE_URL before async execution, return 503 if not configured - Remove unused bcryptjs, lodash, uuid dependencies Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 18 +---------------- packages/admin-scripts/package.json | 5 +---- .../__tests__/admin-script-router.test.js | 20 ++++++++++++++++++- .../src/infrastructure/admin-script-router.js | 17 ++++++++++++++-- 4 files changed, 36 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index f3a1ee9b6..0b691a6c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39750,11 +39750,8 @@ "dependencies": { "@aws-sdk/client-scheduler": "^3.588.0", "@friggframework/core": "^2.0.0-next.0", - "bcryptjs": "^2.4.3", "express": "^4.18.2", - "lodash": "4.17.21", - "serverless-http": "^3.2.0", - "uuid": "^9.0.1" + "serverless-http": "^3.2.0" }, "devDependencies": { "@friggframework/eslint-config": "^2.0.0-next.0", @@ -39775,19 +39772,6 @@ "node": ">=12.0" } }, - "packages/admin-scripts/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "packages/core": { "name": "@friggframework/core", "version": "2.0.0-next.0", diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json index 46acdd4d4..254797d22 100644 --- a/packages/admin-scripts/package.json +++ b/packages/admin-scripts/package.json @@ -6,11 +6,8 @@ "dependencies": { "@aws-sdk/client-scheduler": "^3.588.0", "@friggframework/core": "^2.0.0-next.0", - "bcryptjs": "^2.4.3", "express": "^4.18.2", - "lodash": "4.17.21", - "serverless-http": "^3.2.0", - "uuid": "^9.0.1" + "serverless-http": "^3.2.0" }, "devDependencies": { "@friggframework/eslint-config": "^2.0.0-next.0", diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js index 0e93970db..bb253b297 100644 --- a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -169,6 +169,7 @@ describe('Admin Script Router', () => { }); it('should queue script for async execution', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/test-queue'; mockCommands.createAdminProcess.mockResolvedValue({ id: 'exec-456', }); @@ -188,11 +189,13 @@ describe('Admin Script Router', () => { scriptName: 'test-script', executionId: 'exec-456', }), - process.env.ADMIN_SCRIPT_QUEUE_URL + 'https://sqs.us-east-1.amazonaws.com/123/test-queue' ); + delete process.env.ADMIN_SCRIPT_QUEUE_URL; }); it('should default to async mode', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/test-queue'; mockCommands.createAdminProcess.mockResolvedValue({ id: 'exec-789', }); @@ -205,6 +208,21 @@ describe('Admin Script Router', () => { expect(response.status).toBe(202); expect(response.body.status).toBe('QUEUED'); + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + }); + + it('should return 503 when ADMIN_SCRIPT_QUEUE_URL is not set', async () => { + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + + const response = await request(app) + .post('/admin/scripts/test-script') + .send({ + params: { foo: 'bar' }, + mode: 'async', + }); + + expect(response.status).toBe(503); + expect(response.body.code).toBe('QUEUE_NOT_CONFIGURED'); }); it('should return 404 for non-existent script', async () => { diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js index 753de3f47..8c4215049 100644 --- a/packages/admin-scripts/src/infrastructure/admin-script-router.js +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -24,7 +24,12 @@ router.use(validateAdminApiKey); */ function createScheduleUseCases() { const commands = createAdminScriptCommands(); - const schedulerAdapter = createSchedulerAdapter(); + const schedulerAdapter = createSchedulerAdapter({ + type: process.env.SCHEDULER_PROVIDER || 'local', + targetLambdaArn: process.env.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN, + scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP, + roleArn: process.env.SCHEDULER_ROLE_ARN, + }); const scriptFactory = getScriptFactory(); return { @@ -147,6 +152,14 @@ router.post('/scripts/:scriptName', async (req, res) => { } // Async execution - queue and return immediately + const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; + if (!queueUrl) { + return res.status(503).json({ + error: 'Async execution is not configured (ADMIN_SCRIPT_QUEUE_URL not set)', + code: 'QUEUE_NOT_CONFIGURED', + }); + } + const commands = createAdminScriptCommands(); const execution = await commands.createAdminProcess({ scriptName, @@ -164,7 +177,7 @@ router.post('/scripts/:scriptName', async (req, res) => { trigger: 'MANUAL', params, }, - process.env.ADMIN_SCRIPT_QUEUE_URL + queueUrl ); res.status(202).json({ From 916c55ee668e38d4540e4463ad033a95dfe89328 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 18:36:33 +0000 Subject: [PATCH 32/33] fix(admin-scripts): resolve PR #517 review comments - upsert, state tracking, adapter format - AWS scheduler createSchedule now uses upsert pattern: tries CreateScheduleCommand first, falls back to UpdateScheduleCommand on ConflictException, preventing stale schedules when cron expressions are edited - SQS handler catch block now calls completeAdminProcess with state: 'FAILED' when executionId is available, preventing orphaned execution records stuck in non-terminal state - LocalSchedulerAdapter.listSchedules() now returns normalized format matching AWS adapter contract (Name, State, ScheduleExpression, ScheduleExpressionTimezone) https://claude.ai/code/session_01BRAKMNfvY2Fpnwac7gbuq3 --- .../__tests__/aws-scheduler-adapter.test.js | 37 +++++++++++++++++++ .../__tests__/local-scheduler-adapter.test.js | 19 ++++------ .../src/adapters/aws-scheduler-adapter.js | 25 +++++++++---- .../src/adapters/local-scheduler-adapter.js | 7 +++- .../infrastructure/script-executor-handler.js | 25 ++++++++++++- 5 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js index d8836048f..c46271eb7 100644 --- a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js @@ -206,6 +206,43 @@ describe('AWSSchedulerAdapter', () => { const command = mockSend.mock.calls[0][0]; expect(command.params.FlexibleTimeWindow).toEqual({ Mode: 'OFF' }); }); + + it('should fall back to UpdateScheduleCommand on ConflictException', async () => { + const conflictError = new Error('Schedule already exists'); + conflictError.name = 'ConflictException'; + + mockSend + .mockRejectedValueOnce(conflictError) + .mockResolvedValueOnce({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + const result = await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + expect(result).toEqual({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + scheduleName: 'frigg-script-test-script', + }); + + expect(mockSend).toHaveBeenCalledTimes(2); + expect(mockSend.mock.calls[0][0]._type).toBe('CreateScheduleCommand'); + expect(mockSend.mock.calls[1][0]._type).toBe('UpdateScheduleCommand'); + }); + + it('should rethrow non-conflict errors', async () => { + const otherError = new Error('Access denied'); + otherError.name = 'AccessDeniedException'; + + mockSend.mockRejectedValue(otherError); + + await expect(adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + })).rejects.toThrow('Access denied'); + }); }); describe('deleteSchedule()', () => { diff --git a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js index 732bd48ef..a93b20171 100644 --- a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js +++ b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js @@ -210,12 +210,12 @@ describe('LocalSchedulerAdapter', () => { const schedules = await adapter.listSchedules(); expect(schedules).toHaveLength(3); - expect(schedules.map((s) => s.scriptName)).toContain('script-1'); - expect(schedules.map((s) => s.scriptName)).toContain('script-2'); - expect(schedules.map((s) => s.scriptName)).toContain('script-3'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-1'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-2'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-3'); }); - it('should include all schedule properties', async () => { + it('should include all schedule properties in normalized format', async () => { await adapter.createSchedule({ scriptName: 'test-script', cronExpression: '0 0 * * *', @@ -226,14 +226,11 @@ describe('LocalSchedulerAdapter', () => { const schedules = await adapter.listSchedules(); expect(schedules[0]).toMatchObject({ - scriptName: 'test-script', - cronExpression: '0 0 * * *', - timezone: 'America/New_York', - input: { key: 'value' }, - enabled: true, + Name: 'frigg-script-test-script', + State: 'ENABLED', + ScheduleExpression: '0 0 * * *', + ScheduleExpressionTimezone: 'America/New_York', }); - expect(schedules[0]).toHaveProperty('createdAt'); - expect(schedules[0]).toHaveProperty('updatedAt'); }); }); diff --git a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js index ec86b84ee..dfe64e275 100644 --- a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js @@ -60,7 +60,7 @@ class AWSSchedulerAdapter extends SchedulerAdapter { const client = this.getSchedulerClient(); const scheduleName = `frigg-script-${scriptName}`; - const command = new CreateScheduleCommand({ + const scheduleParams = { Name: scheduleName, GroupName: this.scheduleGroupName, ScheduleExpression: cronExpression, @@ -76,13 +76,24 @@ class AWSSchedulerAdapter extends SchedulerAdapter { }), }, State: 'ENABLED', - }); - - const response = await client.send(command); - return { - scheduleArn: response.ScheduleArn, - scheduleName: scheduleName, }; + + try { + const response = await client.send(new CreateScheduleCommand(scheduleParams)); + return { + scheduleArn: response.ScheduleArn, + scheduleName: scheduleName, + }; + } catch (error) { + if (error.name === 'ConflictException') { + const response = await client.send(new UpdateScheduleCommand(scheduleParams)); + return { + scheduleArn: response.ScheduleArn, + scheduleName: scheduleName, + }; + } + throw error; + } } async deleteSchedule(scriptName) { diff --git a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js index cc9640ee8..7cca0a971 100644 --- a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js +++ b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js @@ -57,7 +57,12 @@ class LocalSchedulerAdapter extends SchedulerAdapter { } async listSchedules() { - return Array.from(this.schedules.values()); + return Array.from(this.schedules.values()).map((schedule) => ({ + Name: `frigg-script-${schedule.scriptName}`, + State: schedule.enabled ? 'ENABLED' : 'DISABLED', + ScheduleExpression: schedule.cronExpression, + ScheduleExpressionTimezone: schedule.timezone, + })); } async getSchedule(scriptName) { diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js index 6ebeb557f..8caf79369 100644 --- a/packages/admin-scripts/src/infrastructure/script-executor-handler.js +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -1,4 +1,5 @@ const { createScriptRunner } = require('../application/script-runner'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); /** * SQS Queue Worker Lambda Handler @@ -12,11 +13,12 @@ async function handler(event) { for (const record of event.Records) { let scriptName; + let executionId; try { const message = JSON.parse(record.body); - ({ scriptName } = message); - const { executionId, trigger, params } = message; + ({ scriptName, executionId } = message); + const { trigger, params } = message; if (!scriptName || !executionId) { throw new Error(`Invalid SQS message: missing scriptName or executionId`); @@ -41,6 +43,25 @@ async function handler(event) { // Only reaches here for unexpected failures (message parse errors, runner construction). // Script execution errors are handled by ScriptRunner and returned as { status: 'FAILED' }. console.error(`Unexpected error processing record:`, error); + + // If we have an executionId, mark the admin process as FAILED + // so the record doesn't stay stuck in a non-terminal state. + if (executionId) { + try { + const commands = createAdminScriptCommands(); + await commands.completeAdminProcess(executionId, { + state: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + } catch (updateError) { + console.error(`Failed to update execution ${executionId} state:`, updateError); + } + } + results.push({ scriptName: scriptName || 'unknown', status: 'FAILED', From 5d463a7e8c78f9a74e2f0daf98a09ed67e895627 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 01:58:21 +0000 Subject: [PATCH 33/33] fix(admin-scripts): align commands with repository interface and wire exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix method name mismatch in admin-script-commands.js: commands now call repository interface methods (createProcess, findProcessById, etc.) instead of non-existent domain-specific names (createAdminProcess, etc.) - Translate command params to repository format (scriptName→name, type→ADMIN_SCRIPT, context object for scriptVersion/trigger/mode/input/audit) - Consolidate completeAdminProcess to use single updateProcessResults call instead of separate updateOutput/updateError/updateMetrics methods - Export createAdminScriptCommands from application/index.js and core/index.js - Standardize requireAdmin middleware to use x-frigg-admin-api-key header (consistent with validateAdminApiKey middleware) - Add missing mock for script-schedule-repository-factory in tests - Update all test assertions to match new repository interface contract https://claude.ai/code/session_01UTMSBwVwDXaGTmtnNJ8VX6 --- .../__tests__/admin-script-commands.test.js | 205 ++++++++++-------- .../commands/admin-script-commands.js | 48 ++-- packages/core/application/index.js | 4 + .../routers/middleware/requireAdmin.js | 8 +- packages/core/index.js | 1 + 5 files changed, 150 insertions(+), 116 deletions(-) diff --git a/packages/core/application/commands/__tests__/admin-script-commands.test.js b/packages/core/application/commands/__tests__/admin-script-commands.test.js index 6339d357d..997698c00 100644 --- a/packages/core/application/commands/__tests__/admin-script-commands.test.js +++ b/packages/core/application/commands/__tests__/admin-script-commands.test.js @@ -6,23 +6,35 @@ jest.mock('../../../database/config', () => ({ PRISMA_QUERY_LOGGING: false, })); -// Mock repository factories +// Mock repository factories - uses interface method names const mockAdminProcessRepo = { - createAdminProcess: jest.fn(), - findAdminProcessById: jest.fn(), - findAdminProcessesByName: jest.fn(), - findAdminProcessesByState: jest.fn(), - updateAdminProcessState: jest.fn(), - updateAdminProcessOutput: jest.fn(), - updateAdminProcessError: jest.fn(), - updateAdminProcessMetrics: jest.fn(), - appendAdminProcessLog: jest.fn(), + createProcess: jest.fn(), + findProcessById: jest.fn(), + findProcessesByName: jest.fn(), + findProcessesByState: jest.fn(), + updateProcessState: jest.fn(), + updateProcessResults: jest.fn(), + appendProcessLog: jest.fn(), }; jest.mock('../../../admin-scripts/repositories/admin-process-repository-factory', () => ({ createAdminProcessRepository: () => mockAdminProcessRepo, })); +const mockScheduleRepo = { + findScheduleByScriptName: jest.fn(), + upsertSchedule: jest.fn(), + deleteSchedule: jest.fn(), + updateScheduleExternalInfo: jest.fn(), + updateScheduleLastTriggered: jest.fn(), + updateScheduleNextTrigger: jest.fn(), + listSchedules: jest.fn(), +}; + +jest.mock('../../../admin-scripts/repositories/script-schedule-repository-factory', () => ({ + createScriptScheduleRepository: () => mockScheduleRepo, +})); + const { createAdminScriptCommands } = require('../admin-script-commands'); describe('createAdminScriptCommands', () => { @@ -55,7 +67,7 @@ describe('createAdminScriptCommands', () => { createdAt: new Date(), }; - mockAdminProcessRepo.createAdminProcess.mockResolvedValue(mockProcess); + mockAdminProcessRepo.createProcess.mockResolvedValue(mockProcess); const result = await commands.createAdminProcess({ scriptName: 'test-script', @@ -70,16 +82,19 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockAdminProcessRepo.createAdminProcess).toHaveBeenCalledWith({ - scriptName: 'test-script', - scriptVersion: '1.0.0', - trigger: 'MANUAL', - mode: 'async', - input: { param: 'value' }, - audit: { - apiKeyName: 'Admin Key', - apiKeyLast4: '1234', - ipAddress: '127.0.0.1', + expect(mockAdminProcessRepo.createProcess).toHaveBeenCalledWith({ + name: 'test-script', + type: 'ADMIN_SCRIPT', + context: { + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { param: 'value' }, + audit: { + apiKeyName: 'Admin Key', + apiKeyLast4: '1234', + ipAddress: '127.0.0.1', + }, }, }); expect(result).toEqual(mockProcess); @@ -98,22 +113,26 @@ describe('createAdminScriptCommands', () => { results: {}, }; - mockAdminProcessRepo.createAdminProcess.mockResolvedValue(mockProcess); + mockAdminProcessRepo.createProcess.mockResolvedValue(mockProcess); await commands.createAdminProcess({ scriptName: 'test', trigger: 'MANUAL', }); - expect(mockAdminProcessRepo.createAdminProcess).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.createProcess).toHaveBeenCalledWith( expect.objectContaining({ - mode: 'async', + name: 'test', + type: 'ADMIN_SCRIPT', + context: expect.objectContaining({ + mode: 'async', + }), }) ); }); it('stores audit info correctly', async () => { - mockAdminProcessRepo.createAdminProcess.mockResolvedValue({ + mockAdminProcessRepo.createProcess.mockResolvedValue({ id: 'proc-1', name: 'test', type: 'ADMIN_SCRIPT', @@ -137,13 +156,15 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockAdminProcessRepo.createAdminProcess).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.createProcess).toHaveBeenCalledWith( expect.objectContaining({ - audit: { - apiKeyName: 'Test Key', - apiKeyLast4: 'abcd', - ipAddress: '192.168.1.1', - }, + context: expect.objectContaining({ + audit: { + apiKeyName: 'Test Key', + apiKeyLast4: 'abcd', + ipAddress: '192.168.1.1', + }, + }), }) ); }); @@ -160,16 +181,16 @@ describe('createAdminScriptCommands', () => { results: {}, }; - mockAdminProcessRepo.findAdminProcessById.mockResolvedValue(mockProcess); + mockAdminProcessRepo.findProcessById.mockResolvedValue(mockProcess); const result = await commands.findAdminProcessById('proc-1'); - expect(mockAdminProcessRepo.findAdminProcessById).toHaveBeenCalledWith('proc-1'); + expect(mockAdminProcessRepo.findProcessById).toHaveBeenCalledWith('proc-1'); expect(result).toEqual(mockProcess); }); it('returns error if not found', async () => { - mockAdminProcessRepo.findAdminProcessById.mockResolvedValue(null); + mockAdminProcessRepo.findProcessById.mockResolvedValue(null); const result = await commands.findAdminProcessById('non-existent'); @@ -186,13 +207,13 @@ describe('createAdminScriptCommands', () => { { id: 'proc-2', name: 'test', type: 'ADMIN_SCRIPT', state: 'FAILED', context: {}, results: {} }, ]; - mockAdminProcessRepo.findAdminProcessesByName.mockResolvedValue( + mockAdminProcessRepo.findProcessesByName.mockResolvedValue( mockProcesses ); const result = await commands.findAdminProcessesByName('test'); - expect(mockAdminProcessRepo.findAdminProcessesByName).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findProcessesByName).toHaveBeenCalledWith( 'test', {} ); @@ -200,7 +221,7 @@ describe('createAdminScriptCommands', () => { }); it('passes options to repository', async () => { - mockAdminProcessRepo.findAdminProcessesByName.mockResolvedValue([]); + mockAdminProcessRepo.findProcessesByName.mockResolvedValue([]); await commands.findAdminProcessesByName('test', { limit: 10, @@ -209,7 +230,7 @@ describe('createAdminScriptCommands', () => { sortOrder: 'desc', }); - expect(mockAdminProcessRepo.findAdminProcessesByName).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findProcessesByName).toHaveBeenCalledWith( 'test', { limit: 10, @@ -221,7 +242,7 @@ describe('createAdminScriptCommands', () => { }); it('returns empty array on error', async () => { - mockAdminProcessRepo.findAdminProcessesByName.mockRejectedValue( + mockAdminProcessRepo.findProcessesByName.mockRejectedValue( new Error('DB error') ); @@ -242,14 +263,14 @@ describe('createAdminScriptCommands', () => { results: {}, }; - mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue(mockUpdated); + mockAdminProcessRepo.updateProcessState.mockResolvedValue(mockUpdated); const result = await commands.updateAdminProcessState( 'proc-1', 'RUNNING' ); - expect(mockAdminProcessRepo.updateAdminProcessState).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.updateProcessState).toHaveBeenCalledWith( 'proc-1', 'RUNNING' ); @@ -265,7 +286,7 @@ describe('createAdminScriptCommands', () => { ]; for (const state of states) { - mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({ + mockAdminProcessRepo.updateProcessState.mockResolvedValue({ id: 'proc-1', name: 'test', type: 'ADMIN_SCRIPT', @@ -304,11 +325,11 @@ describe('createAdminScriptCommands', () => { }, }; - mockAdminProcessRepo.appendAdminProcessLog.mockResolvedValue(mockUpdated); + mockAdminProcessRepo.appendProcessLog.mockResolvedValue(mockUpdated); const result = await commands.appendAdminProcessLog('proc-1', logEntry); - expect(mockAdminProcessRepo.appendAdminProcessLog).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.appendProcessLog).toHaveBeenCalledWith( 'proc-1', logEntry ); @@ -325,7 +346,7 @@ describe('createAdminScriptCommands', () => { timestamp: new Date().toISOString(), }; - mockAdminProcessRepo.appendAdminProcessLog.mockResolvedValue({ + mockAdminProcessRepo.appendProcessLog.mockResolvedValue({ id: 'proc-1', name: 'test', type: 'ADMIN_SCRIPT', @@ -338,7 +359,7 @@ describe('createAdminScriptCommands', () => { await commands.appendAdminProcessLog('proc-1', logEntry); - expect(mockAdminProcessRepo.appendAdminProcessLog).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.appendProcessLog).toHaveBeenCalledWith( 'proc-1', expect.objectContaining({ level }) ); @@ -347,55 +368,52 @@ describe('createAdminScriptCommands', () => { }); describe('completeAdminProcess', () => { - it('updates state, output, error, and metrics', async () => { - mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); - mockAdminProcessRepo.updateAdminProcessOutput.mockResolvedValue({}); - mockAdminProcessRepo.updateAdminProcessError.mockResolvedValue({}); - mockAdminProcessRepo.updateAdminProcessMetrics.mockResolvedValue({}); + it('updates state, output, and metrics via updateProcessResults', async () => { + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateProcessResults.mockResolvedValue({}); + + const metrics = { + startTime: new Date(), + endTime: new Date(), + durationMs: 1234, + }; const result = await commands.completeAdminProcess('proc-1', { state: 'COMPLETED', output: { result: 'success' }, error: null, - metrics: { - startTime: new Date(), - endTime: new Date(), - durationMs: 1234, - }, + metrics, }); - expect(mockAdminProcessRepo.updateAdminProcessState).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.updateProcessState).toHaveBeenCalledWith( 'proc-1', 'COMPLETED' ); - expect(mockAdminProcessRepo.updateAdminProcessOutput).toHaveBeenCalledWith( - 'proc-1', - { result: 'success' } - ); - expect(mockAdminProcessRepo.updateAdminProcessMetrics).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.updateProcessResults).toHaveBeenCalledWith( 'proc-1', - expect.objectContaining({ durationMs: 1234 }) + expect.objectContaining({ + output: { result: 'success' }, + metrics: expect.objectContaining({ durationMs: 1234 }), + }) ); expect(result).toEqual({ success: true }); }); - it('handles partial updates', async () => { - mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); + it('handles partial updates - state only', async () => { + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); await commands.completeAdminProcess('proc-1', { state: 'FAILED', // No output, error, or metrics }); - expect(mockAdminProcessRepo.updateAdminProcessState).toHaveBeenCalled(); - expect(mockAdminProcessRepo.updateAdminProcessOutput).not.toHaveBeenCalled(); - expect(mockAdminProcessRepo.updateAdminProcessError).not.toHaveBeenCalled(); - expect(mockAdminProcessRepo.updateAdminProcessMetrics).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateProcessState).toHaveBeenCalled(); + expect(mockAdminProcessRepo.updateProcessResults).not.toHaveBeenCalled(); }); - it('updates error details on failure', async () => { - mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); - mockAdminProcessRepo.updateAdminProcessError.mockResolvedValue({}); + it('updates error details on failure via updateProcessResults', async () => { + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateProcessResults.mockResolvedValue({}); await commands.completeAdminProcess('proc-1', { state: 'FAILED', @@ -406,40 +424,45 @@ describe('createAdminScriptCommands', () => { }, }); - expect(mockAdminProcessRepo.updateAdminProcessError).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.updateProcessResults).toHaveBeenCalledWith( 'proc-1', { - name: 'ValidationError', - message: 'Invalid input', - stack: 'Error: ...\n at ...', + error: { + name: 'ValidationError', + message: 'Invalid input', + stack: 'Error: ...\n at ...', + }, } ); }); it('allows output to be null or undefined', async () => { - mockAdminProcessRepo.updateAdminProcessState.mockResolvedValue({}); - mockAdminProcessRepo.updateAdminProcessOutput.mockResolvedValue({}); + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); + mockAdminProcessRepo.updateProcessResults.mockResolvedValue({}); - // Test with null + // Test with null - output: null should be included in results await commands.completeAdminProcess('proc-1', { state: 'COMPLETED', output: null, }); - expect(mockAdminProcessRepo.updateAdminProcessOutput).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.updateProcessResults).toHaveBeenCalledWith( 'proc-1', - null + { output: null } ); jest.clearAllMocks(); - // Test with undefined (should not call update) + // Test with undefined (should not include output in results) + mockAdminProcessRepo.updateProcessState.mockResolvedValue({}); + await commands.completeAdminProcess('proc-2', { state: 'COMPLETED', // output is undefined }); - expect(mockAdminProcessRepo.updateAdminProcessOutput).not.toHaveBeenCalled(); + // No results to update, so updateProcessResults should not be called + expect(mockAdminProcessRepo.updateProcessResults).not.toHaveBeenCalled(); }); }); @@ -450,11 +473,11 @@ describe('createAdminScriptCommands', () => { { id: 'proc-2', name: 'test', type: 'ADMIN_SCRIPT', state: 'FAILED', context: {}, results: {} }, ]; - mockAdminProcessRepo.findAdminProcessesByState.mockResolvedValue(mockProcesses); + mockAdminProcessRepo.findProcessesByState.mockResolvedValue(mockProcesses); const result = await commands.findRecentAdminProcesses({ state: 'FAILED' }); - expect(mockAdminProcessRepo.findAdminProcessesByState).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findProcessesByState).toHaveBeenCalledWith( 'FAILED', { limit: 20, @@ -466,25 +489,25 @@ describe('createAdminScriptCommands', () => { }); it('uses default limit of 20', async () => { - mockAdminProcessRepo.findAdminProcessesByState.mockResolvedValue([]); + mockAdminProcessRepo.findProcessesByState.mockResolvedValue([]); await commands.findRecentAdminProcesses({ state: 'COMPLETED' }); - expect(mockAdminProcessRepo.findAdminProcessesByState).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findProcessesByState).toHaveBeenCalledWith( 'COMPLETED', expect.objectContaining({ limit: 20 }) ); }); it('allows custom limit', async () => { - mockAdminProcessRepo.findAdminProcessesByState.mockResolvedValue([]); + mockAdminProcessRepo.findProcessesByState.mockResolvedValue([]); await commands.findRecentAdminProcesses({ state: 'RUNNING', limit: 50, }); - expect(mockAdminProcessRepo.findAdminProcessesByState).toHaveBeenCalledWith( + expect(mockAdminProcessRepo.findProcessesByState).toHaveBeenCalledWith( 'RUNNING', expect.objectContaining({ limit: 50 }) ); @@ -494,11 +517,11 @@ describe('createAdminScriptCommands', () => { const result = await commands.findRecentAdminProcesses({}); expect(result).toEqual([]); - expect(mockAdminProcessRepo.findAdminProcessesByState).not.toHaveBeenCalled(); + expect(mockAdminProcessRepo.findProcessesByState).not.toHaveBeenCalled(); }); it('returns empty array on error', async () => { - mockAdminProcessRepo.findAdminProcessesByState.mockRejectedValue( + mockAdminProcessRepo.findProcessesByState.mockRejectedValue( new Error('DB error') ); diff --git a/packages/core/application/commands/admin-script-commands.js b/packages/core/application/commands/admin-script-commands.js index ec493587b..c2aa2900b 100644 --- a/packages/core/application/commands/admin-script-commands.js +++ b/packages/core/application/commands/admin-script-commands.js @@ -69,13 +69,16 @@ function createAdminScriptCommands() { audit, }) { try { - const process = await adminProcessRepository.createAdminProcess({ - scriptName, - scriptVersion, - trigger, - mode: mode || 'async', - input, - audit, + const process = await adminProcessRepository.createProcess({ + name: scriptName, + type: 'ADMIN_SCRIPT', + context: { + scriptVersion, + trigger, + mode: mode || 'async', + input, + audit, + }, }); return process; } catch (error) { @@ -91,7 +94,7 @@ function createAdminScriptCommands() { */ async findAdminProcessById(processId) { try { - const process = await adminProcessRepository.findAdminProcessById(processId); + const process = await adminProcessRepository.findProcessById(processId); if (!process) { const error = new Error(`Execution ${processId} not found`); error.code = 'EXECUTION_NOT_FOUND'; @@ -112,7 +115,7 @@ function createAdminScriptCommands() { */ async findAdminProcessesByName(scriptName, options = {}) { try { - const processes = await adminProcessRepository.findAdminProcessesByName( + const processes = await adminProcessRepository.findProcessesByName( scriptName, options ); @@ -132,7 +135,7 @@ function createAdminScriptCommands() { */ async updateAdminProcessState(processId, state) { try { - const updated = await adminProcessRepository.updateAdminProcessState( + const updated = await adminProcessRepository.updateProcessState( processId, state ); @@ -151,7 +154,7 @@ function createAdminScriptCommands() { */ async appendAdminProcessLog(processId, logEntry) { try { - const updated = await adminProcessRepository.appendAdminProcessLog( + const updated = await adminProcessRepository.appendProcessLog( processId, logEntry ); @@ -175,18 +178,19 @@ function createAdminScriptCommands() { */ async completeAdminProcess(processId, { state, output, error, metrics }) { try { - // Update each field independently (partial updates allowed) + // Update state if provided if (state) { - await adminProcessRepository.updateAdminProcessState(processId, state); - } - if (output !== undefined) { - await adminProcessRepository.updateAdminProcessOutput(processId, output); - } - if (error) { - await adminProcessRepository.updateAdminProcessError(processId, error); + await adminProcessRepository.updateProcessState(processId, state); } - if (metrics) { - await adminProcessRepository.updateAdminProcessMetrics(processId, metrics); + + // Build results object from provided fields and merge in one call + const resultsUpdate = {}; + if (output !== undefined) resultsUpdate.output = output; + if (error) resultsUpdate.error = error; + if (metrics) resultsUpdate.metrics = metrics; + + if (Object.keys(resultsUpdate).length > 0) { + await adminProcessRepository.updateProcessResults(processId, resultsUpdate); } return { success: true }; @@ -210,7 +214,7 @@ function createAdminScriptCommands() { // If state filter provided, use state query if (state) { - return await adminProcessRepository.findAdminProcessesByState(state, { + return await adminProcessRepository.findProcessesByState(state, { limit, sortBy: 'createdAt', sortOrder: 'desc', diff --git a/packages/core/application/index.js b/packages/core/application/index.js index b2cda436f..136af5132 100644 --- a/packages/core/application/index.js +++ b/packages/core/application/index.js @@ -8,6 +8,9 @@ const { createCredentialCommands } = require('./commands/credential-commands'); const { createSchedulerCommands, } = require('./commands/scheduler-commands'); +const { + createAdminScriptCommands, +} = require('./commands/admin-script-commands'); /** * Create a unified command factory with all CRUD operations @@ -59,6 +62,7 @@ module.exports = { createEntityCommands, createCredentialCommands, createSchedulerCommands, + createAdminScriptCommands, // Legacy standalone function findIntegrationContextByExternalEntityId, diff --git a/packages/core/handlers/routers/middleware/requireAdmin.js b/packages/core/handlers/routers/middleware/requireAdmin.js index cd8d30485..d599882d2 100644 --- a/packages/core/handlers/routers/middleware/requireAdmin.js +++ b/packages/core/handlers/routers/middleware/requireAdmin.js @@ -1,8 +1,10 @@ /** * Middleware to require admin API key authentication. - * Checks for X-API-Key header matching ADMIN_API_KEY environment variable. + * Checks for x-frigg-admin-api-key header matching ADMIN_API_KEY environment variable. * In non-production environments, allows all requests through for easier development. * + * Uses the same header convention as validateAdminApiKey (handlers/middleware/admin-auth.js). + * * @param {import('express').Request} req - Express request object * @param {import('express').Response} res - Express response object * @param {import('express').NextFunction} next - Express next middleware function @@ -14,10 +16,10 @@ const requireAdmin = (req, res, next) => { return next(); } - const apiKey = req.headers['x-api-key']; + const apiKey = req.headers['x-frigg-admin-api-key']; if (!apiKey) { - console.error('[requireAdmin] Missing X-API-Key header'); + console.error('[requireAdmin] Missing x-frigg-admin-api-key header'); return res.status(401).json({ status: 'error', message: 'Unauthorized - Admin API key required', diff --git a/packages/core/index.js b/packages/core/index.js index fbb396948..a37010584 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -163,6 +163,7 @@ module.exports = { createEntityCommands: application.createEntityCommands, createCredentialCommands: application.createCredentialCommands, createSchedulerCommands: application.createSchedulerCommands, + createAdminScriptCommands: application.createAdminScriptCommands, findIntegrationContextByExternalEntityId: application.findIntegrationContextByExternalEntityId, integrationCommands: application.integrationCommands,